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 .prepare("INSERT INTO recipe (title, created_at) VALUES (?, datetime('now')) RETURNING id") .get(title) as { id: number }; 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(); const cols = db.prepare("PRAGMA table_info(recipe_view)").all() as Array<{ name: string; type: string; notnull: number; pk: number; }>; const byName = Object.fromEntries(cols.map((c) => [c.name, c])); expect(byName.profile_id?.type).toBe('INTEGER'); expect(byName.profile_id?.notnull).toBe(1); expect(byName.profile_id?.pk).toBe(1); expect(byName.recipe_id?.type).toBe('INTEGER'); expect(byName.recipe_id?.notnull).toBe(1); expect(byName.recipe_id?.pk).toBe(2); expect(byName.last_viewed_at?.type).toBe('TIMESTAMP'); expect(byName.last_viewed_at?.notnull).toBe(1); }); it('has index on (profile_id, last_viewed_at DESC)', () => { const db = openInMemoryForTest(); const idxList = db .prepare("PRAGMA index_list(recipe_view)") .all() as Array<{ name: string }>; expect(idxList.some((i) => i.name === 'idx_recipe_view_recent')).toBe(true); }); }); describe('recordView', () => { it('inserts a view row with default timestamp', () => { const db = openInMemoryForTest(); const profile = createProfile(db, 'Test'); const recipeId = seedRecipe(db, 'Pasta'); recordView(db, profile.id, recipeId); const rows = listViews(db, profile.id); expect(rows.length).toBe(1); expect(rows[0].recipe_id).toBe(recipeId); expect(rows[0].last_viewed_at).toMatch(/^\d{4}-\d{2}-\d{2}/); }); it('updates timestamp on subsequent view of same recipe', async () => { const db = openInMemoryForTest(); const profile = createProfile(db, 'Test'); const recipeId = seedRecipe(db, 'Pasta'); recordView(db, profile.id, recipeId); const first = listViews(db, profile.id)[0].last_viewed_at; // tiny delay so the second timestamp differs await new Promise((r) => setTimeout(r, 1100)); recordView(db, profile.id, recipeId); const rows = listViews(db, profile.id); expect(rows.length).toBe(1); expect(rows[0].last_viewed_at >= first).toBe(true); }); it('throws on unknown profile_id (FK)', () => { const db = openInMemoryForTest(); const recipeId = seedRecipe(db, 'Pasta'); expect(() => recordView(db, 999, recipeId)).toThrow(); }); it('throws on unknown recipe_id (FK)', () => { const db = openInMemoryForTest(); const profile = createProfile(db, 'Test'); expect(() => recordView(db, profile.id, 999)).toThrow(); }); }); describe("listAllRecipesPaginated sort='viewed'", () => { it('puts recently-viewed recipes first, NULLs alphabetically last', async () => { const db = openInMemoryForTest(); const profile = createProfile(db, 'Test'); const recipeA = seedRecipe(db, 'Apfelkuchen'); const recipeB = seedRecipe(db, 'Brokkoli'); // Inserted in reverse-alphabetical order (Z before D) to prove the // tiebreaker sorts by title, not insertion order. const recipeC = seedRecipe(db, 'Zwiebelkuchen'); const recipeD = seedRecipe(db, 'Donauwelle'); // View order: B then A. C and D never viewed. recordView(db, profile.id, recipeB); await new Promise((r) => setTimeout(r, 1100)); recordView(db, profile.id, recipeA); const hits = listAllRecipesPaginated(db, 'viewed', 50, 0, profile.id); // Viewed: A (most recent), B — then unviewed alphabetically: D before C. expect(hits.map((h) => h.id)).toEqual([recipeA, recipeB, recipeD, recipeC]); }); it('falls back to alphabetical when profileId is null', () => { const db = openInMemoryForTest(); seedRecipe(db, 'Couscous'); seedRecipe(db, 'Apfelkuchen'); seedRecipe(db, 'Brokkoli'); const hits = listAllRecipesPaginated(db, 'viewed', 50, 0, null); expect(hits.map((h) => h.title)).toEqual(['Apfelkuchen', 'Brokkoli', 'Couscous']); }); it('keeps existing sorts working unchanged', () => { const db = openInMemoryForTest(); seedRecipe(db, 'Couscous'); seedRecipe(db, 'Apfelkuchen'); seedRecipe(db, 'Brokkoli'); const hits = listAllRecipesPaginated(db, 'name', 50, 0); 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 }); }); }); // --------------------------------------------------------------------------- // GET /api/recipes/all — sort=viewed + profile_id // --------------------------------------------------------------------------- import { GET as allGet } from '../../src/routes/api/recipes/all/+server'; describe('GET /api/recipes/all sort=viewed', () => { it('passes profile_id through and returns viewed-order hits', async () => { const db = openInMemoryForTest(); testDb.current = db; const profile = createProfile(db, 'Test'); const a = seedRecipe(db, 'Apfel'); const b = seedRecipe(db, 'Birne'); recordView(db, profile.id, b); await new Promise((r) => setTimeout(r, 1100)); recordView(db, profile.id, a); const url = new URL(`http://localhost/api/recipes/all?sort=viewed&profile_id=${profile.id}&limit=10`); const res = await allGet({ url } as never); expect(res.status).toBe(200); const body = await res.json(); expect(body.sort).toBe('viewed'); expect(body.hits.map((h: { id: number }) => h.id)).toEqual([a, b]); }); it('400 on invalid sort', async () => { const url = new URL('http://localhost/api/recipes/all?sort=invalid'); await expect(allGet({ url } as never)).rejects.toMatchObject({ status: 400 }); }); });