refactor(server): http query utils for pagination
This commit is contained in:
@@ -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<HonoEnv>()
|
||||
@@ -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'),
|
||||
})
|
||||
|
||||
@@ -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<HonoEnv>()
|
||||
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user