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) <noreply@anthropic.com>
This commit is contained in:
35
src/lib/server/recipes/views.ts
Normal file
35
src/lib/server/recipes/views.ts
Normal file
@@ -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[];
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { openInMemoryForTest } from '../../src/lib/server/db';
|
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<typeof openInMemoryForTest>, 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', () => {
|
describe('014_recipe_views migration', () => {
|
||||||
it('creates recipe_view table with expected columns', () => {
|
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);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user