feat(server): api-helpers fuer parsePositiveIntParam + validateBody

- src/lib/server/api-helpers.ts mit parsePositiveIntParam(),
  validateBody<T>() 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
This commit is contained in:
hsiegeln
2026-04-18 22:16:00 +02:00
parent 830c740747
commit 739cc2d058
2 changed files with 134 additions and 0 deletions

View File

@@ -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');
});
});