- 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]>
115 lines
5.3 KiB
TypeScript
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);
|
|
});
|
|
});
|