diff --git a/src/routes/api/recipes/[id]/view/+server.ts b/src/routes/api/recipes/[id]/view/+server.ts new file mode 100644 index 0000000..ef309de --- /dev/null +++ b/src/routes/api/recipes/[id]/view/+server.ts @@ -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 }); +}; diff --git a/tests/integration/recipe-views.test.ts b/tests/integration/recipe-views.test.ts index ef0eebb..4c15f59 100644 --- a/tests/integration/recipe-views.test.ts +++ b/tests/integration/recipe-views.test.ts @@ -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 | 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 { 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, title: string): number { const r = db @@ -11,6 +35,14 @@ function seedRecipe(db: ReturnType, 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 }); + }); +});