From 6c8de6fa3a364e3af848a66224974d43b0f8951a Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:10:52 +0200 Subject: [PATCH] feat(db): recordView/listViews fuer recipe_view INSERT OR REPLACE fuer idempotenten Bump des last_viewed_at Timestamps. listViews-Helper nur fuer Tests; Sort-Query laeuft direkt in listAllRecipesPaginated via LEFT JOIN. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/server/recipes/views.ts | 35 +++++++++++++++++ tests/integration/recipe-views.test.ts | 53 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 src/lib/server/recipes/views.ts diff --git a/src/lib/server/recipes/views.ts b/src/lib/server/recipes/views.ts new file mode 100644 index 0000000..66b5c07 --- /dev/null +++ b/src/lib/server/recipes/views.ts @@ -0,0 +1,35 @@ +import type Database from 'better-sqlite3'; + +export function recordView( + db: Database.Database, + profileId: number, + recipeId: number +): void { + // INSERT OR REPLACE re-fires the DEFAULT (CURRENT_TIMESTAMP) on conflict, + // so subsequent views of the same recipe by the same profile bump the + // timestamp without breaking the composite PK. + db.prepare( + `INSERT OR REPLACE INTO recipe_view (profile_id, recipe_id) + VALUES (?, ?)` + ).run(profileId, recipeId); +} + +export type ViewRow = { + profile_id: number; + recipe_id: number; + last_viewed_at: string; +}; + +export function listViews( + db: Database.Database, + profileId: number +): ViewRow[] { + return db + .prepare( + `SELECT profile_id, recipe_id, last_viewed_at + FROM recipe_view + WHERE profile_id = ? + ORDER BY last_viewed_at DESC` + ) + .all(profileId) as ViewRow[]; +} diff --git a/tests/integration/recipe-views.test.ts b/tests/integration/recipe-views.test.ts index 3b785b9..934608f 100644 --- a/tests/integration/recipe-views.test.ts +++ b/tests/integration/recipe-views.test.ts @@ -1,5 +1,14 @@ 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'; + +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', () => { @@ -29,3 +38,47 @@ describe('014_recipe_views migration', () => { 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(); + }); +});