import { describe, it, expect, vi } from 'vitest' import Fastify from 'fastify' import { registerRawBodyParser, registerTrace, registerErrorHandler, createAiLimit, CODE_STATUS, } from '../src/index.js' function silentLogger() { const child = () => ({ error: vi.fn(), info: vi.fn() }) return { child, info: vi.fn() } } function build({ limiter } = {}) { const app = Fastify({ logger: false }) registerRawBodyParser(app) registerTrace(app, { logger: silentLogger() }) registerErrorHandler(app) if (limiter) { const aiLimit = createAiLimit({ limiter }) app.post('/llm', { preHandler: aiLimit('llm') }, async () => ({ ok: true })) } return app } describe('server-kit 装配骨架', () => { it('trace hook 回带 x-trace-id,rawBody 标记路由拿到原始字节', async () => { const app = build() let seenRaw = null app.post('/hook', { config: { rawBody: true } }, async (request) => { seenRaw = request.rawBody return { ok: true } }) const res = await app.inject({ method: 'POST', url: '/hook', payload: { a: 1 } }) expect(res.headers['x-trace-id']).toBeTruthy() expect(Buffer.isBuffer(seenRaw)).toBe(true) expect(JSON.parse(seenRaw.toString('utf8'))).toEqual({ a: 1 }) }) it('错误码 → HTTP 状态映射,且 status<500 透传 details', async () => { const app = build() app.get('/boom', async () => { const err = new Error('nope') err.code = 'INSUFFICIENT_BALANCE' err.details = { needed: 5 } throw err }) const res = await app.inject({ method: 'GET', url: '/boom' }) expect(res.statusCode).toBe(402) const body = res.json() expect(body.error.code).toBe('INSUFFICIENT_BALANCE') expect(body.error.details.needed).toBe(5) expect(body.error.details.requestId).toBeTruthy() expect(body.error.details.traceId).toBe(res.headers['x-trace-id']) expect(CODE_STATUS.INSUFFICIENT_BALANCE).toBe(402) }) it('waiting list 预期业务错误不退化成 500', async () => { const app = build() app.get('/waitlist-expired', async () => { const err = new Error('Invite token expired') err.code = 'waitlist.invite_expired' throw err }) const res = await app.inject({ method: 'GET', url: '/waitlist-expired' }) expect(res.statusCode).toBe(400) expect(res.json().error).toMatchObject({ code: 'waitlist.invite_expired', message: 'Invite token expired', retryable: false, }) expect(CODE_STATUS['waitlist.invite_not_found']).toBe(404) }) it('waiting list 邀请投递失败映射为可识别的上游错误', async () => { const app = build() app.post('/waitlist-delivery', async () => { const err = new Error('Waitlist invite delivery failed') err.code = 'waitlist.invite_delivery_failed' throw err }) const res = await app.inject({ method: 'POST', url: '/waitlist-delivery' }) expect(res.statusCode).toBe(502) expect(res.json().error).toMatchObject({ code: 'waitlist.invite_delivery_failed', message: 'Internal server error', retryable: true, }) expect(CODE_STATUS['waitlist.invite_delivery_failed']).toBe(502) }) it('服务器错误按 trace 记录结构化日志', async () => { const error = vi.fn() const logger = { child: () => ({ error }), info: vi.fn() } const app = Fastify({ logger: false }) registerTrace(app, { logger }) registerErrorHandler(app) app.get('/logged-error', async () => { throw new Error('logged failure') }) const res = await app.inject({ method: 'GET', url: '/logged-error' }) const body = res.json() expect(error).toHaveBeenCalledWith('request failed', expect.objectContaining({ err: expect.any(Error), status: 500, traceId: res.headers['x-trace-id'], requestId: body.error.details.requestId, })) }) it('500 类错误不泄漏内部信息、不透传 details', async () => { const app = build() app.get('/oops', async () => { const err = new Error('secret stack') err.details = { internal: true } throw err }) const res = await app.inject({ method: 'GET', url: '/oops' }) expect(res.statusCode).toBe(500) const body = res.json() expect(body.error.message).toBe('Internal server error') expect(body.error.details.internal).toBeUndefined() expect(body.error.details.requestId).toBeTruthy() expect(body.error.details.traceId).toBe(res.headers['x-trace-id']) }) it('aiLimit 超限返 429 + retry-after', async () => { const limiter = { consume: vi.fn(async () => ({ allowed: false, remaining: 0, retryAfterMs: 5000, ruleId: 'llm:ip' })) } const app = build({ limiter }) const res = await app.inject({ method: 'POST', url: '/llm', payload: {} }) expect(res.statusCode).toBe(429) expect(res.headers['retry-after']).toBe('5') expect(res.json().error.code).toBe('rate_limited') }) })