diff --git a/src/routes/api/recipes/+server.ts b/src/routes/api/recipes/+server.ts new file mode 100644 index 0000000..6cd73f4 --- /dev/null +++ b/src/routes/api/recipes/+server.ts @@ -0,0 +1,61 @@ +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { z } from 'zod'; +import { getDb } from '$lib/server/db'; +import { validateBody } from '$lib/server/api-helpers'; +import { insertRecipe } from '$lib/server/recipes/repository'; + +const IngredientSchema = z.object({ + position: z.number().int().nonnegative(), + quantity: z.number().nullable(), + unit: z.string().max(30).nullable(), + name: z.string().min(1).max(200), + note: z.string().max(300).nullable(), + raw_text: z.string().max(500), + section_heading: z.string().max(200).nullable() +}); + +const StepSchema = z.object({ + position: z.number().int().positive(), + text: z.string().min(1).max(4000) +}); + +const CreateRecipeSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().max(2000).nullable(), + servings_default: z.number().int().nonnegative().nullable(), + servings_unit: z.string().max(30).nullable(), + prep_time_min: z.number().int().nonnegative().nullable(), + cook_time_min: z.number().int().nonnegative().nullable(), + total_time_min: z.number().int().nonnegative().nullable(), + ingredients: z.array(IngredientSchema), + steps: z.array(StepSchema) +}); + +// Anlegen eines kompletten Rezepts aus Scratch. Wird vom Foto-Import-Flow +// genutzt, nachdem der Nutzer im Editor die AI-Extraktion geprüft/korrigiert +// und auf Speichern getippt hat. Der bestehende /api/recipes/blank-Endpoint +// bleibt für den „leer anlegen"-Flow unverändert. +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json().catch(() => null); + const p = validateBody(body, CreateRecipeSchema); + const id = insertRecipe(getDb(), { + id: null, + title: p.title, + description: p.description, + source_url: null, + source_domain: null, + image_path: null, + servings_default: p.servings_default, + servings_unit: p.servings_unit, + prep_time_min: p.prep_time_min, + cook_time_min: p.cook_time_min, + total_time_min: p.total_time_min, + cuisine: null, + category: null, + ingredients: p.ingredients, + steps: p.steps, + tags: [] + }); + return json({ id }, { status: 201 }); +}; diff --git a/tests/integration/recipes-post.test.ts b/tests/integration/recipes-post.test.ts new file mode 100644 index 0000000..67f00c8 --- /dev/null +++ b/tests/integration/recipes-post.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { openInMemoryForTest } from '../../src/lib/server/db'; + +const { testDb } = vi.hoisted(() => { + // Lazy holder; real DB instantiated in beforeEach. + return { testDb: { current: null as ReturnType | null } }; +}); + +vi.mock('$lib/server/db', async () => { + const actual = + await vi.importActual( + '../../src/lib/server/db' + ); + return { + ...actual, + getDb: () => { + if (!testDb.current) throw new Error('test DB not initialised'); + return testDb.current; + } + }; +}); + +import { POST } from '../../src/routes/api/recipes/+server'; + +function mkReq(body: unknown) { + return { + request: new Request('http://test/api/recipes', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body) + }) + }; +} + +const validBody = { + title: 'Aus Foto erstellt', + description: 'Abrakadabra — Rezept da.', + servings_default: 4, + servings_unit: 'Portionen', + prep_time_min: 10, + cook_time_min: 20, + total_time_min: null, + ingredients: [ + { + position: 1, + quantity: 1, + unit: null, + name: 'Apfel', + note: null, + raw_text: '1 Apfel', + section_heading: null + } + ], + steps: [{ position: 1, text: 'Apfel schneiden.' }] +}; + +beforeEach(() => { + testDb.current = openInMemoryForTest(); +}); + +describe('POST /api/recipes', () => { + it('happy path returns 201 + id', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = await POST(mkReq(validBody) as any); + expect(res.status).toBe(201); + const body = await res.json(); + expect(typeof body.id).toBe('number'); + expect(body.id).toBeGreaterThan(0); + }); + + it('400 on empty title', async () => { + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + POST(mkReq({ ...validBody, title: '' }) as any) + ).rejects.toMatchObject({ status: 400 }); + }); + + it('400 on missing ingredients array', async () => { + const bad = { ...validBody } as Partial; + delete bad.ingredients; + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + POST(mkReq(bad) as any) + ).rejects.toMatchObject({ status: 400 }); + }); +});