From 2714a44c5bf6d16d017b13fbb9e453e45aff33ed Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Fri, 27 Mar 2026 18:22:30 +0800 Subject: [PATCH] refactor(server): http query utils for pagination --- apps/server/src/routes/flux.ts | 28 +---- apps/server/src/routes/tests/flux.test.ts | 108 ++++++++++++++++++ apps/server/src/utils/http-query.ts | 46 ++++++++ .../server/src/utils/tests/http-query.test.ts | 56 +++++++++ 4 files changed, 213 insertions(+), 25 deletions(-) create mode 100644 apps/server/src/routes/tests/flux.test.ts create mode 100644 apps/server/src/utils/http-query.ts create mode 100644 apps/server/src/utils/tests/http-query.test.ts diff --git a/apps/server/src/routes/flux.ts b/apps/server/src/routes/flux.ts index fdcc85043..04f8ad31a 100644 --- a/apps/server/src/routes/flux.ts +++ b/apps/server/src/routes/flux.ts @@ -3,32 +3,10 @@ import type { FluxAuditService } from '../services/flux-audit' import type { HonoEnv } from '../types/hono' import { Hono } from 'hono' -import { fallback, integer, nonEmpty, object, optional, parse, pipe, string, transform } from 'valibot' +import { parse } from 'valibot' import { authGuard } from '../middlewares/auth' - -const FluxHistoryQuerySchema = object({ - limit: fallback( - pipe( - optional(string(), '20'), - nonEmpty(), - transform(input => Number.parseInt(input, 10)), - integer(), - transform(value => Math.min(Math.max(value, 1), 100)), - ), - 20, - ), - offset: fallback( - pipe( - optional(string(), '0'), - nonEmpty(), - transform(input => Number.parseInt(input, 10)), - integer(), - transform(value => Math.max(value, 0)), - ), - 0, - ), -}) +import { LimitOffsetPaginationQuerySchema } from '../utils/http-query' export function createFluxRoutes(fluxService: FluxService, fluxAuditService: FluxAuditService) { return new Hono() @@ -40,7 +18,7 @@ export function createFluxRoutes(fluxService: FluxService, fluxAuditService: Flu }) .get('/history', async (c) => { const user = c.get('user')! - const { limit, offset } = parse(FluxHistoryQuerySchema, { + const { limit, offset } = parse(LimitOffsetPaginationQuerySchema, { limit: c.req.query('limit'), offset: c.req.query('offset'), }) diff --git a/apps/server/src/routes/tests/flux.test.ts b/apps/server/src/routes/tests/flux.test.ts new file mode 100644 index 000000000..44600ab83 --- /dev/null +++ b/apps/server/src/routes/tests/flux.test.ts @@ -0,0 +1,108 @@ +import type { FluxService } from '../../services/flux' +import type { FluxAuditService } from '../../services/flux-audit' +import type { HonoEnv } from '../../types/hono' + +import { Hono } from 'hono' +import { describe, expect, it, vi } from 'vitest' + +import { ApiError } from '../../utils/error' +import { createFluxRoutes } from '../flux' + +function createMockFluxService(): FluxService { + return { + getFlux: vi.fn(async (userId: string) => ({ userId, flux: 42 })), + updateStripeCustomerId: vi.fn(), + } as any +} + +function createMockFluxAuditService(): FluxAuditService { + return { + createEntry: vi.fn(), + createEntries: vi.fn(), + getHistory: vi.fn(async (_userId: string, limit: number, offset: number) => ({ + records: [ + { + id: 'ledger-1', + type: 'credit', + amount: 5, + description: 'Top up', + metadata: { source: 'test' }, + createdAt: new Date('2026-03-27T10:00:00.000Z'), + }, + ], + hasMore: limit === 100 && offset === 0, + })), + } as any +} + +function createTestApp(fluxService: FluxService, fluxAuditService: FluxAuditService) { + const routes = createFluxRoutes(fluxService, fluxAuditService) + const app = new Hono() + + app.onError((err, c) => { + if (err instanceof ApiError) { + return c.json({ + error: err.errorCode, + message: err.message, + details: err.details, + }, err.statusCode) + } + + return c.json({ error: 'Internal Server Error', message: err.message }, 500) + }) + + app.use('*', async (c, next) => { + const user = (c.env as any)?.user + if (user) { + c.set('user', user) + } + await next() + }) + + app.route('/api/users/me/flux', routes) + return app +} + +const testUser = { id: 'user-1', name: 'Test User', email: 'test@example.com' } + +describe('fluxRoutes', () => { + it('get /api/users/me/flux should return the current user balance', async () => { + const fluxService = createMockFluxService() + const app = createTestApp(fluxService, createMockFluxAuditService()) + + const res = await app.fetch( + new Request('http://localhost/api/users/me/flux'), + { user: testUser } as any, + ) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ userId: 'user-1', flux: 42 }) + expect(fluxService.getFlux).toHaveBeenCalledWith('user-1') + }) + + it('get /api/users/me/flux/history should clamp pagination query values', async () => { + const fluxAuditService = createMockFluxAuditService() + const app = createTestApp(createMockFluxService(), fluxAuditService) + + const res = await app.fetch( + new Request('http://localhost/api/users/me/flux/history?limit=999&offset=-12'), + { user: testUser } as any, + ) + + expect(res.status).toBe(200) + expect(fluxAuditService.getHistory).toHaveBeenCalledWith('user-1', 100, 0) + expect(await res.json()).toEqual({ + records: [ + { + id: 'ledger-1', + type: 'credit', + amount: 5, + description: 'Top up', + metadata: { source: 'test' }, + createdAt: '2026-03-27T10:00:00.000Z', + }, + ], + hasMore: true, + }) + }) +}) diff --git a/apps/server/src/utils/http-query.ts b/apps/server/src/utils/http-query.ts new file mode 100644 index 000000000..aba1a543d --- /dev/null +++ b/apps/server/src/utils/http-query.ts @@ -0,0 +1,46 @@ +import { fallback, integer, object, optional, pipe, string, transform } from 'valibot' + +interface QueryIntegerSchemaOptions { + defaultValue: number + minimum?: number + maximum?: number +} + +function clampQueryInteger(value: number, minimum?: number, maximum?: number): number { + if (minimum != null && value < minimum) + return minimum + + if (maximum != null && value > maximum) + return maximum + + return value +} + +/** + * Parse a query-string integer with an explicit default and optional bounds. + * Invalid, missing, or empty inputs fall back to the declared default. + */ +export function createQueryIntegerSchema(options: QueryIntegerSchemaOptions) { + return fallback( + pipe( + optional(string(), String(options.defaultValue)), + transform(input => input.trim()), + transform(input => Number.parseInt(input, 10)), + integer(), + transform(value => clampQueryInteger(value, options.minimum, options.maximum)), + ), + options.defaultValue, + ) +} + +export const LimitOffsetPaginationQuerySchema = object({ + limit: createQueryIntegerSchema({ + defaultValue: 20, + minimum: 1, + maximum: 100, + }), + offset: createQueryIntegerSchema({ + defaultValue: 0, + minimum: 0, + }), +}) diff --git a/apps/server/src/utils/tests/http-query.test.ts b/apps/server/src/utils/tests/http-query.test.ts new file mode 100644 index 000000000..e0af961c0 --- /dev/null +++ b/apps/server/src/utils/tests/http-query.test.ts @@ -0,0 +1,56 @@ +import { parse } from 'valibot' +import { describe, expect, it } from 'vitest' + +import { createQueryIntegerSchema, LimitOffsetPaginationQuerySchema } from '../http-query' + +describe('http query utils', () => { + it('uses the declared default when the query value is missing', () => { + const schema = createQueryIntegerSchema({ + defaultValue: 20, + minimum: 1, + maximum: 100, + }) + + expect(parse(schema, undefined)).toBe(20) + }) + + it('falls back to the declared default when the query value is invalid', () => { + const schema = createQueryIntegerSchema({ + defaultValue: 20, + minimum: 1, + maximum: 100, + }) + + expect(parse(schema, 'NaN')).toBe(20) + expect(parse(schema, '')).toBe(20) + }) + + it('clamps values to the declared bounds', () => { + const schema = createQueryIntegerSchema({ + defaultValue: 20, + minimum: 1, + maximum: 100, + }) + + expect(parse(schema, '-12')).toBe(1) + expect(parse(schema, '999')).toBe(100) + }) + + it('parses limit/offset pagination queries with defaults and clamping', () => { + expect(parse(LimitOffsetPaginationQuerySchema, { + limit: undefined, + offset: undefined, + })).toEqual({ + limit: 20, + offset: 0, + }) + + expect(parse(LimitOffsetPaginationQuerySchema, { + limit: '999', + offset: '-5', + })).toEqual({ + limit: 100, + offset: 0, + }) + }) +})