import { expect, test } from "bun:test"; import type { Db } from "@sttracker/db/db"; import { newId, nowIso } from "@sttracker/db/ids"; import { forEachDialect } from "@sttracker/db/testing"; import { addEngagement, softDeleteEngagement } from "../src/engagements.ts"; import { softDeleteEntry, upsertEntry } from "../src/entries.ts"; import { ValidationError } from "../src/errors.ts"; import { listLog } from "../src/log.ts"; import { makeUser, makeWork } from "./user-layer-fixtures.ts"; async function listen(db: Db, userId: string, listenedAt: string, track: string, workId: string | null = null): Promise { const id = newId(); await db.k .insertInto("listens") .values({ id, user_id: userId, listened_at: listenedAt, artist: "Low", track, album: "Hey What", duration_ms: 200_000, mbids: "{}", work_id: workId, source: "listenbrainz", source_key: `${listenedAt}:${track}`, created_at: nowIso(), }) .execute(); return id; } async function setup(db: Db) { const userId = await makeUser(db); const dune = await makeWork(db, { title: "Dune" }); const entry = await upsertEntry(db, userId, dune, { overrides: { title: "Dune (1965)" } }, "user"); const finished = await addEngagement(db, userId, entry.id, { finishedAt: { value: "2026-10-01", precision: "day" } }, "user"); const started = await addEngagement(db, userId, entry.id, { startedAt: { value: "2025-03", precision: "month" } }, "user"); const undated = await addEngagement(db, userId, entry.id, {}, "user"); const gone = await addEngagement(db, userId, entry.id, { finishedAt: { value: "2026-01-01", precision: "day" } }, "user"); await softDeleteEngagement(db, userId, gone.id, "user"); // An engagement on a deleted entry, and another user's data, never show. const game = await makeWork(db, { title: "Outer Wilds", mediaType: "game" }); const deletedEntry = await upsertEntry(db, userId, game, {}, "user"); await addEngagement(db, userId, deletedEntry.id, { finishedAt: { value: "2026-05-01", precision: "day" } }, "user"); await softDeleteEntry(db, userId, deletedEntry.id, "user"); const other = await makeUser(db, "bob"); const otherEntry = await upsertEntry(db, other, dune, {}, "user"); await addEngagement(db, other, otherEntry.id, { finishedAt: { value: "2026-06-01", precision: "day" } }, "user"); await listen(db, other, "2026-06-01T10:00:00.000Z", "Not mine"); // Two listens tie with the finished read on `at`, so pages must break ties by kind, then id. const l1 = await listen(db, userId, "2026-10-01", "Days Like These"); const l2 = await listen(db, userId, "2026-10-01", "White Horses"); return { userId, entry, finished, started, undated, l1, l2 }; } forEachDialect("log", (getDb) => { test("newest first: engagements of live entries and listens, with titles", async () => { const db = getDb(); const s = await setup(db); const { items, nextCursor } = await listLog(db, s.userId); expect(nextCursor).toBeNull(); expect(items.map((i) => i.id)).toEqual([s.undated.id, s.l2, s.l1, s.finished.id, s.started.id]); const read = items.find((i) => i.id === s.finished.id)!; expect(read).toMatchObject({ kind: "engagement", at: "2026-10-01", entryId: s.entry.id, title: "Dune (1965)", mediaType: "book", finishedAt: { value: "2026-10-01", precision: "day" }, startedAt: null, }); const heard = items.find((i) => i.id === s.l1)!; expect(heard).toMatchObject({ kind: "listen", at: "2026-10-01", title: "Days Like These", artist: "Low", track: "Days Like These", album: "Hey What", mediaType: "track" }); expect(items.find((i) => i.id === s.started.id)!.at).toBe("2025-03"); }); test("keyset pages cross the kind boundary without gaps or repeats", async () => { const db = getDb(); const s = await setup(db); const all = (await listLog(db, s.userId)).items.map((i) => i.id); for (const limit of [1, 2, 3]) { const seen: string[] = []; let cursor: string | undefined; for (let guard = 0; guard < 10; guard++) { const page = await listLog(db, s.userId, { limit, cursor }); expect(page.items.length).toBeLessThanOrEqual(limit); seen.push(...page.items.map((i) => i.id)); if (!page.nextCursor) break; cursor = page.nextCursor; } expect(seen).toEqual(all); } }); test("limit is clamped and a bad cursor is a validation error", async () => { const db = getDb(); const s = await setup(db); expect((await listLog(db, s.userId, { limit: 0 })).items).toHaveLength(1); expect((await listLog(db, s.userId, { limit: 1000 })).items).toHaveLength(5); await expect(listLog(db, s.userId, { cursor: "nonsense" })).rejects.toThrow(ValidationError); }); });