fix(api): graceful shutdown, единый обработчик ошибок и корректные 404

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-23 15:53:18 +07:00
1 parent 0948455d15
commit 140ffdb7b1
10 files changed
+335 -9

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { z } from 'zod'
import { buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
describe('error handler', () => {
it('необработанная ошибка → 500 без деталей', async () => {
app = await buildTestApp()
app.get('/__boom', async () => {
throw new Error('secret internals')
})
const res = await app.inject({ method: 'GET', url: '/__boom' })
expect(res.statusCode).toBe(500)
expect(res.json()).toEqual({
error: { code: 'INTERNAL', message: 'Internal server error' },
})
expect(res.body).not.toContain('secret internals')
})
it('ZodError → 400 BAD_REQUEST', async () => {
app = await buildTestApp()
app.get('/__zod', async () => z.object({ a: z.string() }).parse({}))
const res = await app.inject({ method: 'GET', url: '/__zod' })
expect(res.statusCode).toBe(400)
expect(res.json().error.code).toBe('BAD_REQUEST')
})
it('ошибка с statusCode 4xx сохраняет статус', async () => {
app = await buildTestApp()
app.get('/__teapot', async () => {
throw Object.assign(new Error('nope'), { statusCode: 418 })
})
const res = await app.inject({ method: 'GET', url: '/__teapot' })
expect(res.statusCode).toBe(418)
})
})
describe('not found', () => {
it.each(['/oauth/nope', '/.well-known/nope', '/api/v1/nope'])(
'%s → 404 JSON',
async (url) => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url })
expect(res.statusCode).toBe(404)
expect(res.json().error.code).toBe('NOT_FOUND')
},
)
it('без STATIC_DIR неизвестный путь → 404 JSON', async () => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/some/spa/route' })
expect(res.statusCode).toBe(404)
})
it('со STATIC_DIR: SPA-путь → index.html, /oauth и /.well-known → 404', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
app = await buildTestApp({ STATIC_DIR: dir })
const spa = await app.inject({ method: 'GET', url: '/some/spa/route' })
expect(spa.statusCode).toBe(200)
expect(spa.body).toContain('spa')
for (const url of ['/oauth/nope', '/.well-known/nope']) {
const res = await app.inject({ method: 'GET', url })
expect(res.statusCode).toBe(404)
expect(res.json().error.code).toBe('NOT_FOUND')
}
})
})
+120
View File
@@ -0,0 +1,120 @@
import { EventEmitter } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDb, closeDb, migrateSchema } from '@authportal/db'
import { registerShutdown } from '../src/lib/shutdown.js'
import { buildTestApp } from './helpers.js'
function fakeApp(close: () => Promise<void>) {
return {
close,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
} as unknown as Parameters<typeof registerShutdown>[0]
}
describe('registerShutdown', () => {
afterEach(() => {
vi.useRealTimers()
})
it('закрывает приложение один раз и выходит с 0', async () => {
const close = vi.fn(async () => {})
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(fakeApp(close), {
timeoutMs: 1000,
exit,
proc: proc as never,
})
proc.emit('SIGTERM')
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0))
expect(close).toHaveBeenCalledTimes(1)
})
it('повторный сигнал форсирует выход, close не вызывается дважды', async () => {
let release!: () => void
const close = vi.fn(
() => new Promise<void>((r) => (release = r)),
)
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(fakeApp(close), {
timeoutMs: 1000,
exit,
proc: proc as never,
})
proc.emit('SIGTERM')
proc.emit('SIGINT')
expect(exit).toHaveBeenCalledWith(1)
expect(close).toHaveBeenCalledTimes(1)
release()
})
it('по таймауту принудительно выходит с кодом 1', async () => {
vi.useFakeTimers()
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(
fakeApp(() => new Promise<void>(() => {})),
{ timeoutMs: 500, exit, proc: proc as never },
)
proc.emit('SIGTERM')
await vi.advanceTimersByTimeAsync(501)
expect(exit).toHaveBeenCalledWith(1)
})
it('ошибка close → выход с кодом 1', async () => {
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(
fakeApp(async () => {
throw new Error('boom')
}),
{ timeoutMs: 1000, exit, proc: proc as never },
)
proc.emit('SIGTERM')
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
})
it('unhandledRejection запускает shutdown с кодом 1', async () => {
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(fakeApp(async () => {}), {
timeoutMs: 1000,
exit,
proc: proc as never,
})
proc.emit('unhandledRejection', new Error('x'))
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
})
it('dispose снимает обработчики', () => {
const proc = new EventEmitter()
const h = registerShutdown(fakeApp(async () => {}), {
timeoutMs: 1000,
exit: vi.fn(),
proc: proc as never,
})
h.dispose()
expect(proc.listenerCount('SIGTERM')).toBe(0)
expect(proc.listenerCount('unhandledRejection')).toBe(0)
})
})
describe('closeDb', () => {
it('идемпотентно закрывает БД', () => {
const { sqlite } = createDb('sqlite::memory:')
migrateSchema(sqlite)
closeDb(sqlite)
expect(sqlite.open).toBe(false)
expect(() => closeDb(sqlite)).not.toThrow()
expect(() => sqlite.prepare('SELECT 1')).toThrow()
})
it('app.close() закрывает БД приложения', async () => {
const app = await buildTestApp()
await app.ready()
expect(app.sqlite.open).toBe(true)
await app.close()
expect(app.sqlite.open).toBe(false)
})
})