feat(api): POST /api/recipes fuer Scratch-Insert aus Foto-Import
This commit is contained in:
61
src/routes/api/recipes/+server.ts
Normal file
61
src/routes/api/recipes/+server.ts
Normal file
@@ -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 });
|
||||||
|
};
|
||||||
86
tests/integration/recipes-post.test.ts
Normal file
86
tests/integration/recipes-post.test.ts
Normal file
@@ -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<typeof openInMemoryForTest> | null } };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('$lib/server/db', async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import('../../src/lib/server/db')>(
|
||||||
|
'../../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<typeof validBody>;
|
||||||
|
delete bad.ingredients;
|
||||||
|
await expect(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
POST(mkReq(bad) as any)
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user