import { describe, expect, it, vi } from 'vitest' import type { Response } from 'express' import { z } from 'zod' import { AppError, ConflictError, ForbiddenError, NotFoundError, UnauthorizedError, ValidationError } from './index' import { errorMiddleware } from './errorMiddleware' function createResponseStub() { const res = { status: vi.fn(), json: vi.fn(), } res.status.mockReturnValue(res) res.json.mockReturnValue(res) return res as unknown as Response & typeof res } function handle(error: unknown) { const res = createResponseStub() errorMiddleware(error, {} as any, res, vi.fn()) return res } describe('AppError subclasses', () => { it.each([ [new ValidationError('Bad payload'), 400, 'validation_error'], [new UnauthorizedError(), 401, 'unauthorized'], [new ForbiddenError(), 403, 'forbidden'], [new NotFoundError(), 404, 'not_found'], [new ConflictError(), 409, 'conflict'], ])('sets stable status and error code for %s', (error, statusCode, code) => { expect(error).toBeInstanceOf(AppError) expect(error.statusCode).toBe(statusCode) expect(error.error).toBe(code) }) }) describe('errorMiddleware', () => { it('normalizes Zod errors into validation responses', () => { const result = z.object({ email: z.string().email() }).safeParse({ email: 'not-an-email' }) expect(result.success).toBe(false) const res = handle(result.success ? new Error('unexpected') : result.error) expect(res.status).toHaveBeenCalledWith(400) expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'validation_error', message: 'Invalid request body', statusCode: 400, issues: expect.any(Array), })) }) it('normalizes Prisma not found errors', () => { const res = handle({ code: 'P2025' }) expect(res.status).toHaveBeenCalledWith(404) expect(res.json).toHaveBeenCalledWith({ error: 'not_found', message: 'Resource not found', statusCode: 404, }) }) it('normalizes Prisma unique constraint errors', () => { const res = handle({ code: 'P2002' }) expect(res.status).toHaveBeenCalledWith(409) expect(res.json).toHaveBeenCalledWith({ error: 'conflict', message: 'A resource with this value already exists', statusCode: 409, }) }) it.each(['P2021', 'P2022'])('normalizes Prisma schema mismatch errors for %s', (code) => { const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined) const res = handle({ code }) expect(res.status).toHaveBeenCalledWith(503) expect(res.json).toHaveBeenCalledWith({ error: 'database_schema_mismatch', message: 'Database schema is out of date. Run database migrations and retry.', statusCode: 503, requestId: undefined, }) spy.mockRestore() }) it('preserves AppError metadata in the response body', () => { const res = handle(new AppError('Plan required', 402, 'payment_required', { requiredPlan: 'PRO' })) expect(res.status).toHaveBeenCalledWith(402) expect(res.json).toHaveBeenCalledWith({ error: 'payment_required', message: 'Plan required', statusCode: 402, requiredPlan: 'PRO', }) }) it('falls back to a 500 internal error response', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined) const res = handle(new Error('boom')) expect(res.status).toHaveBeenCalledWith(500) expect(res.json).toHaveBeenCalledWith({ error: 'internal_error', message: 'Internal server error', statusCode: 500, requestId: undefined }) spy.mockRestore() }) })