import { describe, it, expect } from 'vitest'; import { openInMemoryForTest } from '../../src/lib/server/db'; 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'; 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; } 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'); const recipeC = seedRecipe(db, 'Couscous'); // View order: B then A. C 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); expect(hits.map((h) => h.id)).toEqual([recipeA, recipeB, 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']); }); });