Files
STTracker/packages/core/test/history.test.ts
T
Lucas WintherandClaude Opus 5.5 7154ca0a61 fix(core): race-safe entry upsert and lossless per-user moves
- upsertEntry inserts with ON CONFLICT DO NOTHING and patches through the update path,
  so a concurrent creator never aborts the caller's transaction on Postgres.
- Undoing a move deletes the forward alias and writes no reverse alias.
- Merging into an existing entry keeps 'user' field_sources on combined fields, carries
  the moved entry's field_sources as-is when reviving a deleted target, concatenates
  differing review/private notes, records a dropped rating in history, and writes history
  for re-parented engagements (restorable).
- Moves re-point this user's copies (with history) and list items to the target work.
- rejectSuggestion requires a pending suggestion; engagements of a deleted entry are not
  editable; restoring an entry does not re-check custom fields against current definitions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:59:54 +01:00

115 lines
5.3 KiB
TypeScript

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