import { expect, test } from "bun:test"; import { forEachDialect } from "@sttracker/db/testing"; import { defineCustomField } from "../src/custom-fields.ts"; 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(); // undoing removes the forward alias and writes no reverse alias expect(await db.k.selectFrom("work_aliases").selectAll().where("user_id", "=", userId).execute()).toEqual([]); // a later sync onto the shared work lands on the shared work, not on the draft const synced = await upsertEntry(db, userId, shared, { rating: 80 }, "plugin:hardcover:c1"); expect(synced.workId).toBe(shared); expect(synced.id).not.toBe(draft.id); expect((await getEntry(db, userId, draft.id)).rating).toBeNull(); }); test("restoreVersion does not re-check custom fields against today's definitions", async () => { const db = getDb(); const userId = await makeUser(db); const def = await defineCustomField(db, userId, { name: "Mood", type: "text" }); const e = await upsertEntry(db, userId, await makeWork(db), { customFields: { [def.id]: "cosy" } }, "user"); await upsertEntry(db, userId, e.workId, { customFields: {} }, "user"); await db.k.deleteFrom("custom_field_definitions").where("id", "=", def.id).execute(); const [h] = await getHistory(db, userId, "entry", e.id); await restoreVersion(db, userId, h!.id); expect((await getEntry(db, userId, e.id)).customFields).toEqual({ [def.id]: "cosy" }); }); 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); }); });