From 739cc2d0584cb197569ff317a55b6d38d226e81f Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:16:00 +0200 Subject: [PATCH] feat(server): api-helpers fuer parsePositiveIntParam + validateBody - src/lib/server/api-helpers.ts mit parsePositiveIntParam(), validateBody() und ErrorResponse type - 13 unit tests fuer die beiden helper (HttpError-Shape verifiziert) - Konsolidiert spaeter 9x parseId und 11x safeParse-Bloecke aus den +server.ts handlern Findings aus REVIEW-2026-04-18.md (Refactor A) und redundancy.md --- src/lib/server/api-helpers.ts | 39 ++++++++++++++ tests/unit/api-helpers.test.ts | 95 ++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/lib/server/api-helpers.ts create mode 100644 tests/unit/api-helpers.test.ts diff --git a/src/lib/server/api-helpers.ts b/src/lib/server/api-helpers.ts new file mode 100644 index 0000000..5e9a27d --- /dev/null +++ b/src/lib/server/api-helpers.ts @@ -0,0 +1,39 @@ +import { error } from '@sveltejs/kit'; +import type { ZodSchema } from 'zod'; + +// Shared error body shape for SvelteKit `error()` calls. `issues` is set +// when validateBody fails so the client can show a precise validation +// hint; everywhere else only `message` is used. +export type ErrorResponse = { + message: string; + issues?: unknown; +}; + +/** + * Parse a route param (or query param) as a positive integer (>=1). + * Throws SvelteKit `error(400)` with `Missing ` when null/undefined, + * or `Invalid ` when the value is not an integer >= 1. + */ +export function parsePositiveIntParam( + raw: string | undefined | null, + field: string +): number { + if (raw == null) error(400, { message: `Missing ${field}` }); + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) error(400, { message: `Invalid ${field}` }); + return n; +} + +/** + * Validate an unknown body against a Zod schema. Throws SvelteKit + * `error(400, { message: 'Invalid body', issues })` on mismatch and returns + * the typed parse result on success. Accepts `null` (the typical result of + * `await request.json().catch(() => null)`). + */ +export function validateBody(body: unknown, schema: ZodSchema): T { + const parsed = schema.safeParse(body); + if (!parsed.success) { + error(400, { message: 'Invalid body', issues: parsed.error.issues }); + } + return parsed.data; +} diff --git a/tests/unit/api-helpers.test.ts b/tests/unit/api-helpers.test.ts new file mode 100644 index 0000000..e7dc20c --- /dev/null +++ b/tests/unit/api-helpers.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { parsePositiveIntParam, validateBody } from '../../src/lib/server/api-helpers'; + +// SvelteKit's `error()` throws an HttpError shape with { status, body }. +// We verify both — wrapping these everywhere costs nothing and keeps the +// API contract stable. + +function expectHttpError(fn: () => unknown, status: number, message?: string) { + try { + fn(); + } catch (err) { + const e = err as { status?: number; body?: { message?: string } }; + expect(e.status, `status should be ${status}`).toBe(status); + if (message !== undefined) { + expect(e.body?.message).toBe(message); + } + return; + } + throw new Error('expected fn to throw, but it returned normally'); +} + +describe('parsePositiveIntParam', () => { + it('parses a valid positive integer', () => { + expect(parsePositiveIntParam('42', 'id')).toBe(42); + expect(parsePositiveIntParam('1', 'id')).toBe(1); + expect(parsePositiveIntParam('999999', 'id')).toBe(999999); + }); + + it('throws 400 for zero', () => { + expectHttpError(() => parsePositiveIntParam('0', 'id'), 400, 'Invalid id'); + }); + + it('throws 400 for negative numbers', () => { + expectHttpError(() => parsePositiveIntParam('-1', 'id'), 400, 'Invalid id'); + }); + + it('throws 400 for non-integer', () => { + expectHttpError(() => parsePositiveIntParam('1.5', 'id'), 400, 'Invalid id'); + }); + + it('throws 400 for non-numeric strings', () => { + expectHttpError(() => parsePositiveIntParam('abc', 'id'), 400, 'Invalid id'); + }); + + it('throws 400 for empty string', () => { + expectHttpError(() => parsePositiveIntParam('', 'id'), 400, 'Invalid id'); + }); + + it('throws 400 for null', () => { + expectHttpError(() => parsePositiveIntParam(null, 'id'), 400, 'Missing id'); + }); + + it('throws 400 for undefined', () => { + expectHttpError(() => parsePositiveIntParam(undefined, 'id'), 400, 'Missing id'); + }); + + it('uses the provided field name in error messages', () => { + expectHttpError(() => parsePositiveIntParam('foo', 'recipe_id'), 400, 'Invalid recipe_id'); + expectHttpError(() => parsePositiveIntParam(null, 'recipe_id'), 400, 'Missing recipe_id'); + }); +}); + +describe('validateBody', () => { + const Schema = z.object({ + name: z.string().min(1), + age: z.number().int().nonnegative() + }); + + it('returns parsed data when valid', () => { + const result = validateBody({ name: 'foo', age: 42 }, Schema); + expect(result).toEqual({ name: 'foo', age: 42 }); + }); + + it('throws 400 with message and issues on schema mismatch', () => { + try { + validateBody({ name: '', age: -1 }, Schema); + throw new Error('expected throw'); + } catch (err) { + const e = err as { status?: number; body?: { message?: string; issues?: unknown[] } }; + expect(e.status).toBe(400); + expect(e.body?.message).toBe('Invalid body'); + expect(Array.isArray(e.body?.issues)).toBe(true); + expect(e.body?.issues?.length).toBeGreaterThan(0); + } + }); + + it('throws 400 for null body (request.json failure case)', () => { + expectHttpError(() => validateBody(null, Schema), 400, 'Invalid body'); + }); + + it('throws 400 for primitive non-object body', () => { + expectHttpError(() => validateBody('a string', Schema), 400, 'Invalid body'); + }); +});