Entries carry per-field field_sources, write user_history diffs, resolve aliased/merged works, and support quick add, per-user moves (with alias and draft tombstone) and keyset-paginated library listing with child rollups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
98 lines
4.3 KiB
TypeScript
98 lines
4.3 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { forEachDialect } from "@sttracker/db/testing";
|
|
import { NotFoundError } from "../src/errors.ts";
|
|
import { getEntry, moveEntryToWork, quickAdd, softDeleteEntry, upsertEntry } from "../src/entries.ts";
|
|
import { getHistory, recordHistory, restoreVersion } from "../src/history.ts";
|
|
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
|
|
|
forEachDialect("history", (getDb) => {
|
|
test("recordHistory stores only the changed keys", async () => {
|
|
const db = getDb();
|
|
const userId = await makeUser(db);
|
|
const id = await recordHistory(db.k, {
|
|
userId,
|
|
entity: "entry",
|
|
entityId: "e1",
|
|
before: { status: "planned", rating: 50, tags: ["a"] },
|
|
after: { status: "completed", rating: 50, tags: ["a"] },
|
|
actor: "user",
|
|
});
|
|
expect(id).not.toBeNull();
|
|
const none = await recordHistory(db.k, {
|
|
userId,
|
|
entity: "entry",
|
|
entityId: "e1",
|
|
before: { rating: 1 },
|
|
after: { rating: 1 },
|
|
actor: "user",
|
|
});
|
|
expect(none).toBeNull();
|
|
const rows = await getHistory(db, userId, "entry", "e1");
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]!.before).toEqual({ status: "planned" });
|
|
expect(rows[0]!.after).toEqual({ status: "completed" });
|
|
expect(rows[0]!.actor).toBe("user");
|
|
});
|
|
|
|
test("restoreVersion brings back previous review", async () => {
|
|
const db = getDb();
|
|
const userId = await makeUser(db);
|
|
const workId = await makeWork(db);
|
|
const e = await upsertEntry(db, userId, workId, { review: "first take" }, "user");
|
|
await upsertEntry(db, userId, workId, { review: "second take", rating: 60 }, "plugin:trakt:c1");
|
|
|
|
const history = await getHistory(db, userId, "entry", e.id);
|
|
expect(history).toHaveLength(2);
|
|
const latest = history[0]!; // newest first
|
|
expect(latest.before).toEqual({ review: "first take", rating: null });
|
|
expect(latest.actor).toBe("plugin:trakt:c1");
|
|
|
|
await restoreVersion(db, userId, latest.id);
|
|
const view = await getEntry(db, userId, e.id);
|
|
expect(view.review).toBe("first take");
|
|
expect(view.rating).toBeNull();
|
|
expect(view.fieldSources.review).toBe("user");
|
|
expect(view.fieldSources.rating).toBe("user");
|
|
expect(await getHistory(db, userId, "entry", e.id)).toHaveLength(3);
|
|
});
|
|
|
|
test("restoreVersion undoes a soft delete", async () => {
|
|
const db = getDb();
|
|
const userId = await makeUser(db);
|
|
const e = await upsertEntry(db, userId, await makeWork(db), { status: "planned" }, "user");
|
|
await softDeleteEntry(db, userId, e.id, "user");
|
|
const [del] = await getHistory(db, userId, "entry", e.id);
|
|
expect(del!.after.deletedAt).toBeString();
|
|
await restoreVersion(db, userId, del!.id);
|
|
expect((await getEntry(db, userId, e.id)).status).toBe("planned");
|
|
});
|
|
|
|
test("restoreVersion undoes a move back to the draft work", async () => {
|
|
const db = getDb();
|
|
const userId = await makeUser(db);
|
|
const draft = await quickAdd(db, userId, { title: "Dune??" });
|
|
const shared = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } });
|
|
await moveEntryToWork(db, userId, draft.id, shared, "user");
|
|
const [move] = await getHistory(db, userId, "entry", draft.id);
|
|
await restoreVersion(db, userId, move!.id);
|
|
const back = await getEntry(db, userId, draft.id);
|
|
expect(back.workId).toBe(draft.workId);
|
|
expect(back.draft).toBe(true);
|
|
const work = await db.k.selectFrom("works").selectAll().where("id", "=", draft.workId).executeTakeFirstOrThrow();
|
|
expect(work.merged_into_id).toBeNull();
|
|
// the shared work now aliases back to the draft for this user; no alias cycle remains
|
|
const aliases = await db.k.selectFrom("work_aliases").selectAll().where("user_id", "=", userId).execute();
|
|
expect(aliases.map((a) => [a.from_work_id, a.to_work_id])).toEqual([[shared, draft.workId]]);
|
|
});
|
|
|
|
test("another user's history is invisible", async () => {
|
|
const db = getDb();
|
|
const ada = await makeUser(db);
|
|
const bob = await makeUser(db, "bob");
|
|
const e = await upsertEntry(db, ada, await makeWork(db), { review: "x" }, "user");
|
|
expect(await getHistory(db, bob, "entry", e.id)).toEqual([]);
|
|
const [h] = await getHistory(db, ada, "entry", e.id);
|
|
await expect(restoreVersion(db, bob, h!.id)).rejects.toThrow(NotFoundError);
|
|
});
|
|
});
|