import { describe, it, expect } from 'vitest' import { z } from 'zod' import { validateBody, validateQuery } from '../validate' import { getErrorMessage } from '@/lib/errors/get-error-message' // ============================================================ // Helpers // ============================================================ function createJsonRequest(body: unknown): Request { return new Request('http://localhost/api/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } function createMalformedRequest(): Request { return new Request('http://localhost/api/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: 'not valid json {{{', }) } function createRequestWithQuery(params: Record): Request { const url = new URL('http://localhost/api/test') for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value) } return new Request(url.toString()) } const TestSchema = z.object({ name: z.string().min(1), age: z.number().int().positive(), email: z.string().email().optional(), }) const QuerySchema = z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().min(1).max(100).default(50), }) // ============================================================ // validateBody // ============================================================ describe('validateBody', () => { it('returns success with parsed data on valid input', async () => { const request = createJsonRequest({ name: 'Alice', age: 30 }) const result = await validateBody(request, TestSchema) expect(result.success).toBe(true) if (result.success) { expect(result.data).toEqual({ name: 'Alice', age: 30 }) } }) it('returns success with optional fields', async () => { const request = createJsonRequest({ name: 'Bob', age: 25, email: 'bob@test.com' }) const result = await validateBody(request, TestSchema) expect(result.success).toBe(true) if (result.success) { expect(result.data.email).toBe('bob@test.com') } }) it('returns failure response on invalid body', async () => { const request = createJsonRequest({ name: '', age: -5 }) const result = await validateBody(request, TestSchema) expect(result.success).toBe(false) if (!result.success) { const body = await result.response.json() expect(result.response.status).toBe(400) // Inverted on purpose: the old assertion pinned the constant // 'Validation failed', which is exactly the bug. `error` now carries the // actionable field message so clients that read only `error` show // something useful; `errors[]` keeps the full machine-readable detail and // `type` stays the discriminator. expect(body.error).not.toBe('Validation failed') expect(body.error).toMatch(/^Valideringsfel: /) expect(body.error).toContain('name') expect(body.error).toContain('age') expect(body.type).toBe('validation_error') expect(body.errors).toBeInstanceOf(Array) expect(body.errors.length).toBeGreaterThan(0) } }) it('summarizes at most three issues and counts the rest', async () => { const WideSchema = z.object({ a: z.string(), b: z.string(), c: z.string(), d: z.string(), e: z.string(), }) const request = createJsonRequest({}) const result = await validateBody(request, WideSchema) expect(result.success).toBe(false) if (!result.success) { const body = await result.response.json() expect(body.errors).toHaveLength(5) expect(body.error).toContain('(+2 till)') // Only the first three are named in the prose. expect(body.error).not.toContain('d:') expect(body.error).not.toContain('e:') } }) it('surfaces the actionable Swedish sentence to a client that reads only body.error', async () => { // Reproduces the failing UI path: pages like arsredovisning/page.tsx and // assets/[id]/dispose/page.tsx forward `body.error` (a string) into // getErrorMessage, which collapsed the constant into "Något gick fel." const SwedishSchema = z.object({ period_start: z.string({ message: 'Ange periodens startdatum' }), }) const request = await validateBody(createJsonRequest({}), SwedishSchema) expect(request.success).toBe(false) if (!request.success) { const body = await request.response.json() const shown = getErrorMessage(body.error, { statusCode: 400 }) expect(shown).toBe(body.error) expect(shown).toContain('Ange periodens startdatum') expect(shown).not.toBe('Något gick fel. Försök igen.') // Clients that forward the whole body keep the existing errors[] path. expect(getErrorMessage(body, { statusCode: 400 })).toContain( 'Ange periodens startdatum', ) } }) it('keeps the machine-readable discriminator and per-field detail intact', async () => { const request = createJsonRequest({ name: '', age: -5 }) const result = await validateBody(request, TestSchema) expect(result.success).toBe(false) if (!result.success) { const body = await result.response.json() // A machine consumer branches on `type` / `errors[].code`, never on prose. // Both are pinned to concrete values here: asserting only `typeof` would // keep passing if the codes turned into empty strings. expect(body.type).toBe('validation_error') for (const issue of body.errors as Array>) { expect(typeof issue.field).toBe('string') expect(typeof issue.message).toBe('string') expect(typeof issue.code).toBe('string') } // Field-keyed lookup, the way a client maps issues onto form inputs. const byField = new Map( (body.errors as Array<{ field: string; code: string }>).map((e) => [e.field, e.code]), ) expect(byField.get('name')).toBe('too_small') expect(byField.get('age')).toBe('too_small') } }) it('returns field paths in error details', async () => { const request = createJsonRequest({ name: 'Alice', age: 'not-a-number' }) const result = await validateBody(request, TestSchema) expect(result.success).toBe(false) if (!result.success) { const body = await result.response.json() const ageError = body.errors.find((e: { field: string }) => e.field === 'age') expect(ageError).toBeDefined() expect(ageError.code).toBeDefined() } }) it('returns error for malformed JSON', async () => { const request = createMalformedRequest() const result = await validateBody(request, TestSchema) expect(result.success).toBe(false) if (!result.success) { const body = await result.response.json() expect(result.response.status).toBe(400) expect(body.error).toBe('Invalid JSON in request body') expect(body.type).toBe('validation_error') } }) it('reports all validation errors, not just the first', async () => { // Missing name and age const request = createJsonRequest({}) const result = await validateBody(request, TestSchema) expect(result.success).toBe(false) if (!result.success) { const body = await result.response.json() expect(body.errors.length).toBeGreaterThanOrEqual(2) } }) it('strips unknown fields (Zod default behavior)', async () => { const request = createJsonRequest({ name: 'Alice', age: 30, secret: 'hidden' }) const result = await validateBody(request, TestSchema) expect(result.success).toBe(true) if (result.success) { expect((result.data as Record).secret).toBeUndefined() } }) it('rejects invalid email format', async () => { const request = createJsonRequest({ name: 'Alice', age: 30, email: 'not-email' }) const result = await validateBody(request, TestSchema) expect(result.success).toBe(false) }) }) // ============================================================ // validateQuery // ============================================================ describe('validateQuery', () => { it('returns success with parsed query params', () => { const request = createRequestWithQuery({ page: '3', limit: '25' }) const result = validateQuery(request, QuerySchema) expect(result.success).toBe(true) if (result.success) { expect(result.data.page).toBe(3) expect(result.data.limit).toBe(25) } }) it('applies defaults for missing params', () => { const request = createRequestWithQuery({}) const result = validateQuery(request, QuerySchema) expect(result.success).toBe(true) if (result.success) { expect(result.data.page).toBe(1) expect(result.data.limit).toBe(50) } }) it('coerces string values to numbers', () => { const request = createRequestWithQuery({ page: '10' }) const result = validateQuery(request, QuerySchema) expect(result.success).toBe(true) if (result.success) { expect(typeof result.data.page).toBe('number') } }) it('returns failure for invalid query params', () => { const request = createRequestWithQuery({ page: '0', limit: '200' }) const result = validateQuery(request, QuerySchema) expect(result.success).toBe(false) if (!result.success) { const body = result.response as unknown as { status: number } expect(result.response.status).toBe(400) } }) it('includes error details in response', () => { const request = createRequestWithQuery({ limit: 'abc' }) const result = validateQuery(request, QuerySchema) expect(result.success).toBe(false) if (!result.success) { // The response is a NextResponse: we verify it's a 400 expect(result.response.status).toBe(400) } }) }) // ============================================================ // Integration: validateBody with domain schemas // ============================================================ describe('validateBody with domain schemas', () => { // Demonstrates using validateBody with the actual schemas from schemas.ts // This pattern is what API routes should use const InvoiceSchema = z.object({ customer_id: z.string().uuid(), invoice_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), items: z.array(z.object({ description: z.string().min(1), amount: z.number().positive(), })).min(1), }) it('validates a well-formed invoice request', async () => { const request = createJsonRequest({ customer_id: '550e8400-e29b-41d4-a716-446655440000', invoice_date: '2025-03-15', items: [{ description: 'Service', amount: 1000 }], }) const result = await validateBody(request, InvoiceSchema) expect(result.success).toBe(true) }) it('catches nested array validation errors', async () => { const request = createJsonRequest({ customer_id: '550e8400-e29b-41d4-a716-446655440000', invoice_date: '2025-03-15', items: [{ description: '', amount: -1 }], }) const result = await validateBody(request, InvoiceSchema) expect(result.success).toBe(false) if (!result.success) { const body = await result.response.json() // Should catch both description and amount errors expect(body.errors.length).toBeGreaterThanOrEqual(2) const fields = body.errors.map((e: { field: string }) => e.field) expect(fields.some((f: string) => f.includes('items'))).toBe(true) } }) })