Files
Lucas WintherandClaude Opus 5.5 c2e8c83f86 feat(core,server): log feed, titles on changes, work titles on copies
GET log (engagements and listens, keyset over at/kind/id), listChanges
title and intoTitle, listCopies workTitle, all via batched lookups.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:11:53 +01:00

108 lines
4.8 KiB
TypeScript

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<string> {
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);
});
});