2026-04-22 14:23:36 +02:00
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
2026-04-22 14:04:27 +02:00
|
|
|
import { openInMemoryForTest } from '../../src/lib/server/db';
|
2026-04-22 14:23:36 +02:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-22 14:10:52 +02:00
|
|
|
import { recordView, listViews } from '../../src/lib/server/recipes/views';
|
|
|
|
|
import { createProfile } from '../../src/lib/server/profiles/repository';
|
2026-04-22 14:17:17 +02:00
|
|
|
import { listAllRecipesPaginated } from '../../src/lib/server/recipes/search-local';
|
2026-04-22 14:23:36 +02:00
|
|
|
import { POST } from '../../src/routes/api/recipes/[id]/view/+server';
|
2026-04-22 14:10:52 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-04-22 14:04:27 +02:00
|
|
|
|
2026-04-22 14:23:36 +02:00
|
|
|
function mkReq(body: unknown) {
|
|
|
|
|
return new Request('http://test/api/recipes/1/view', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'content-type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(body)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 14:04:27 +02:00
|
|
|
describe('014_recipe_views migration', () => {
|
2026-04-22 14:08:17 +02:00
|
|
|
it('creates recipe_view table with expected columns', () => {
|
2026-04-22 14:04:27 +02:00
|
|
|
const db = openInMemoryForTest();
|
2026-04-22 14:08:17 +02:00
|
|
|
const cols = db.prepare("PRAGMA table_info(recipe_view)").all() as Array<{
|
2026-04-22 14:04:27 +02:00
|
|
|
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');
|
2026-04-22 14:08:17 +02:00
|
|
|
expect(byName.recipe_id?.notnull).toBe(1);
|
2026-04-22 14:04:27 +02:00
|
|
|
expect(byName.recipe_id?.pk).toBe(2);
|
2026-04-22 14:08:17 +02:00
|
|
|
expect(byName.last_viewed_at?.type).toBe('TIMESTAMP');
|
2026-04-22 14:04:27 +02:00
|
|
|
expect(byName.last_viewed_at?.notnull).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('has index on (profile_id, last_viewed_at DESC)', () => {
|
|
|
|
|
const db = openInMemoryForTest();
|
|
|
|
|
const idxList = db
|
2026-04-22 14:08:17 +02:00
|
|
|
.prepare("PRAGMA index_list(recipe_view)")
|
2026-04-22 14:04:27 +02:00
|
|
|
.all() as Array<{ name: string }>;
|
2026-04-22 14:08:17 +02:00
|
|
|
expect(idxList.some((i) => i.name === 'idx_recipe_view_recent')).toBe(true);
|
2026-04-22 14:04:27 +02:00
|
|
|
});
|
|
|
|
|
});
|
2026-04-22 14:10:52 +02:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-22 14:17:17 +02:00
|
|
|
|
|
|
|
|
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');
|
2026-04-22 14:20:51 +02:00
|
|
|
// 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');
|
2026-04-22 14:17:17 +02:00
|
|
|
|
2026-04-22 14:20:51 +02:00
|
|
|
// View order: B then A. C and D never viewed.
|
2026-04-22 14:17:17 +02:00
|
|
|
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);
|
2026-04-22 14:20:51 +02:00
|
|
|
// Viewed: A (most recent), B — then unviewed alphabetically: D before C.
|
|
|
|
|
expect(hits.map((h) => h.id)).toEqual([recipeA, recipeB, recipeD, recipeC]);
|
2026-04-22 14:17:17 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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']);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-22 14:23:36 +02:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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 });
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-22 14:31:17 +02:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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 });
|
|
|
|
|
});
|
|
|
|
|
});
|