server-kit.test.js 4.87 KB
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')
  })
})