Files
kochwas/tests/integration/recipe-views.test.ts
hsiegeln b31223add5 feat(api): /api/recipes/all akzeptiert sort=viewed + profile_id
VALID_SORTS um 'viewed' erweitert. parseProfileId-Helper analog zu
/api/wishlist. Wert wird an listAllRecipesPaginated als 5. Param
durchgereicht.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:31:17 +02:00

271 lines
10 KiB
TypeScript

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<typeof openInMemoryForTest> | null }
}));
vi.mock('$lib/server/db', async () => {
const actual =
await vi.importActual<typeof import('../../src/lib/server/db')>(
'../../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<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;
}
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 });
});
});