feat(api): POST /api/recipes/[id]/view fuer View-Beacon
Body { profile_id } via zod validiert. FK-Violation (unbekanntes
Profil oder Rezept) wird zu 404 normalisiert. Erfolg liefert 204
ohne Body — fire-and-forget vom Client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
30
src/routes/api/recipes/[id]/view/+server.ts
Normal file
30
src/routes/api/recipes/[id]/view/+server.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { z } from 'zod';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { validateBody } from '$lib/server/api-helpers';
|
||||
import { recordView } from '$lib/server/recipes/views';
|
||||
|
||||
const Schema = z.object({
|
||||
profile_id: z.number().int().positive()
|
||||
});
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request }) => {
|
||||
const recipeId = Number(params.id);
|
||||
if (!Number.isInteger(recipeId) || recipeId <= 0) {
|
||||
error(400, { message: 'Invalid recipe id' });
|
||||
}
|
||||
const body = validateBody(await request.json().catch(() => null), Schema);
|
||||
|
||||
try {
|
||||
recordView(getDb(), body.profile_id, recipeId);
|
||||
} catch (e) {
|
||||
// FK violation (unknown profile or recipe) → 404
|
||||
if (e instanceof Error && /FOREIGN KEY constraint failed/i.test(e.message)) {
|
||||
error(404, { message: 'Recipe or profile not found' });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
@@ -1,8 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { openInMemoryForTest } from '../../src/lib/server/db';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level mock so the POST handler uses the in-memory test DB.
|
||||
// Must be declared before any import of the handler itself.
|
||||
// ---------------------------------------------------------------------------
|
||||
const { testDb } = vi.hoisted(() => ({
|
||||
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 { recordView, listViews } from '../../src/lib/server/recipes/views';
|
||||
import { createProfile } from '../../src/lib/server/profiles/repository';
|
||||
import { listAllRecipesPaginated } from '../../src/lib/server/recipes/search-local';
|
||||
import { POST } from '../../src/routes/api/recipes/[id]/view/+server';
|
||||
|
||||
function seedRecipe(db: ReturnType<typeof openInMemoryForTest>, title: string): number {
|
||||
const r = db
|
||||
@@ -11,6 +35,14 @@ function seedRecipe(db: ReturnType<typeof openInMemoryForTest>, title: string):
|
||||
return r.id;
|
||||
}
|
||||
|
||||
function mkReq(body: unknown) {
|
||||
return new Request('http://test/api/recipes/1/view', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
describe('014_recipe_views migration', () => {
|
||||
it('creates recipe_view table with expected columns', () => {
|
||||
const db = openInMemoryForTest();
|
||||
@@ -125,3 +157,83 @@ describe("listAllRecipesPaginated sort='viewed'", () => {
|
||||
expect(hits.map((h) => h.title)).toEqual(['Apfelkuchen', 'Brokkoli', 'Couscous']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/recipes/[id]/view — endpoint integration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeEach(() => {
|
||||
testDb.current = openInMemoryForTest();
|
||||
});
|
||||
|
||||
describe('POST /api/recipes/[id]/view', () => {
|
||||
it('204 + view row written on success', async () => {
|
||||
const db = testDb.current!;
|
||||
const profile = createProfile(db, 'Tester');
|
||||
const recipeId = seedRecipe(db, 'Pasta');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const res = await POST({ params: { id: String(recipeId) }, request: mkReq({ profile_id: profile.id }) } as any);
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
const rows = listViews(db, profile.id);
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].recipe_id).toBe(recipeId);
|
||||
});
|
||||
|
||||
it('400 on recipe id = 0', async () => {
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
POST({ params: { id: '0' }, request: mkReq({ profile_id: 1 }) } as any)
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('400 on non-numeric recipe id', async () => {
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
POST({ params: { id: 'abc' }, request: mkReq({ profile_id: 1 }) } as any)
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('400 on missing profile_id in body', async () => {
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
POST({ params: { id: '1' }, request: mkReq({}) } as any)
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('400 on non-positive profile_id', async () => {
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
POST({ params: { id: '1' }, request: mkReq({ profile_id: 0 }) } as any)
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('400 on malformed JSON body', async () => {
|
||||
const badReq = new Request('http://test/api/recipes/1/view', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: 'not-json'
|
||||
});
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
POST({ params: { id: '1' }, request: badReq } as any)
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('404 on unknown profile_id (FK violation)', async () => {
|
||||
const recipeId = seedRecipe(testDb.current!, 'Pasta');
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
POST({ params: { id: String(recipeId) }, request: mkReq({ profile_id: 999 }) } as any)
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('404 on unknown recipe_id (FK violation)', async () => {
|
||||
const profile = createProfile(testDb.current!, 'Tester');
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
POST({ params: { id: '99999' }, request: mkReq({ profile_id: profile.id }) } as any)
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user