import { describe, it, expect } from 'vitest' import { ZodError, z } from 'zod' import { errorResponse, errorResponseFromCode, type ErrorEnvelope, } from '../get-structured-error' import { getErrorEntry, listErrorCodes } from '../structured-errors' import { AccountsNotInChartError, EntryDateOutsideFiscalPeriodError, JournalEntryNotBalancedError, } from '@/lib/bookkeeping/errors' const noopLogger = { error: () => {}, } async function readEnvelope(res: Response): Promise { return (await res.json()) as ErrorEnvelope } describe('structured-errors registry', () => { it('has entries for the canonical generic codes', () => { for (const code of [ 'INTERNAL_ERROR', 'VALIDATION_ERROR', 'UNAUTHORIZED', 'FORBIDDEN', 'NOT_FOUND', 'CONFLICT', 'RATE_LIMITED', 'COMPANY_CONTEXT_MISSING', ]) { const entry = getErrorEntry(code) expect(entry, `missing entry for ${code}`).toBeDefined() expect(entry?.message_sv).toBeTruthy() expect(entry?.message_en).toBeTruthy() } }) it('listErrorCodes returns at least the bookkeeping + generic + provider codes', () => { const codes = listErrorCodes() expect(codes.length).toBeGreaterThan(20) expect(codes).toContain('JOURNAL_ENTRY_NOT_BALANCED') expect(codes).toContain('PROVIDER_AUTH_EXPIRED') }) }) describe('errorResponse', () => { it('maps BookkeepingError to its code + structured details + Swedish message', async () => { const err = new JournalEntryNotBalancedError(100, 90) const res = errorResponse(err, noopLogger, { requestId: 'req_1' }) expect(res.status).toBe(400) expect(res.headers.get('X-Request-Id')).toBe('req_1') const body = await readEnvelope(res) expect(body.error.code).toBe('JOURNAL_ENTRY_NOT_BALANCED') expect(body.error.message).toMatch(/balanserar inte/i) expect(body.error.requestId).toBe('req_1') expect(body.error.details).toMatchObject({ totalDebit: 100, totalCredit: 90 }) }) it('preserves AccountsNotInChartError details', async () => { const err = new AccountsNotInChartError(['1930', '2641']) const res = errorResponse(err, noopLogger, { requestId: 'req_2' }) const body = await readEnvelope(res) expect(body.error.code).toBe('ACCOUNTS_NOT_IN_CHART') expect(body.error.details).toMatchObject({ account_numbers: ['1930', '2641'] }) }) it('maps ZodError to VALIDATION_ERROR with field issues', async () => { let zodErr: ZodError try { z.object({ name: z.string().min(1) }).parse({ name: '' }) throw new Error('should have thrown') } catch (e) { zodErr = e as ZodError } const res = errorResponse(zodErr, noopLogger, { requestId: 'req_3' }) expect(res.status).toBe(400) const body = await readEnvelope(res) expect(body.error.code).toBe('VALIDATION_ERROR') expect(body.error.details).toMatchObject({ issues: expect.arrayContaining([ expect.objectContaining({ field: 'name' }), ]), }) }) it('maps Postgres unique violation to VALIDATION_ERROR with pgCode', async () => { const pgErr = Object.assign(new Error('duplicate key'), { code: '23505' }) const res = errorResponse(pgErr, noopLogger, { requestId: 'req_4' }) expect(res.status).toBe(400) const body = await readEnvelope(res) expect(body.error.code).toBe('VALIDATION_ERROR') expect(body.error.details).toMatchObject({ pgCode: '23505' }) }) it('falls back to INTERNAL_ERROR for unknown shapes', async () => { const res = errorResponse(new Error('boom'), noopLogger, { requestId: 'req_5' }) expect(res.status).toBe(500) const body = await readEnvelope(res) expect(body.error.code).toBe('INTERNAL_ERROR') expect(body.error.requestId).toBe('req_5') }) it('passes through entries with remediation hints', async () => { const res = errorResponseFromCode('PROVIDER_AUTH_EXPIRED', noopLogger, { requestId: 'req_6' }) const body = await readEnvelope(res) expect(body.error.code).toBe('PROVIDER_AUTH_EXPIRED') expect(res.status).toBe(401) }) it('errorResponseFromCode emits requestId in header', () => { const res = errorResponseFromCode('NOT_FOUND', noopLogger, { requestId: 'req_7' }) expect(res.headers.get('X-Request-Id')).toBe('req_7') }) it('preserves EntryDateOutsideFiscalPeriodError fields', async () => { const err = new EntryDateOutsideFiscalPeriodError( '2026-01-01', 'FY2025', '2025-01-01', '2025-12-31', ) const body = await readEnvelope(errorResponse(err, noopLogger, { requestId: 'req_8' })) expect(body.error.code).toBe('ENTRY_DATE_OUTSIDE_FISCAL_PERIOD') expect(body.error.details).toMatchObject({ entryDate: '2026-01-01', periodName: 'FY2025', periodStart: '2025-01-01', periodEnd: '2025-12-31', }) }) })