feat(core): engagements, lists and copies with history restore

Engagements store partial dates as value + precision and track field_sources;
lists (list/grouping/thread, grouping-only rating/review) keep dense item
order and snapshot items in history; copies store price in cents. All three
restore through restoreVersion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 19:35:15 +01:00
1 parent 13b81b1cd5
commit c7471f7761
7 files changed
+1209

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
import { expect, test } from "bun:test";
import { forEachDialect } from "@sttracker/db/testing";
import { createCopy, listCopies, softDeleteCopy, updateCopy } from "../src/copies.ts";
import { defineCustomField } from "../src/custom-fields.ts";
import { NotFoundError, ValidationError } from "../src/errors.ts";
import { getHistory, restoreVersion } from "../src/history.ts";
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
forEachDialect("copies", (getDb) => {
test("createCopy stores price in cents and partial acquired date", async () => {
const db = getDb();
const userId = await makeUser(db);
const workId = await makeWork(db);
const c = await createCopy(db, userId, {
workId,
format: "hardback",
acquiredFrom: "Waterstones",
acquiredAt: { value: "2019-03", precision: "month" },
priceCents: 1299,
currency: "GBP",
});
expect(c.priceCents).toBe(1299);
expect(c.acquiredAt).toEqual({ value: "2019-03", precision: "month" });
const row = await db.k.selectFrom("copies").selectAll().where("id", "=", c.id).executeTakeFirstOrThrow();
expect(row.price_cents).toBe(1299);
expect(row.acquired_precision).toBe("month");
await expect(createCopy(db, userId, { workId, priceCents: 12.5 })).rejects.toThrow(ValidationError);
await expect(createCopy(db, userId, { workId, currency: "pounds" })).rejects.toThrow(ValidationError);
await expect(createCopy(db, userId, {})).rejects.toThrow(ValidationError);
});
test("unmatched copy keeps its title hint; listCopies filters by work", async () => {
const db = getDb();
const userId = await makeUser(db);
const workId = await makeWork(db);
const steam = await createCopy(db, userId, { titleHint: "HALF-LIFE 2", format: "Steam", sourceRef: "steam:220" }, "plugin:steam:c1");
const book = await createCopy(db, userId, { workId });
expect(steam.workId).toBeNull();
expect(steam.titleHint).toBe("HALF-LIFE 2");
expect((await listCopies(db, userId, {})).map((c) => c.id).sort()).toEqual([steam.id, book.id].sort());
expect((await listCopies(db, userId, { workId })).map((c) => c.id)).toEqual([book.id]);
});
test("updateCopy writes history; soft delete and restore", async () => {
const db = getDb();
const userId = await makeUser(db);
const def = await defineCustomField(db, userId, { name: "Signed", type: "boolean", level: "copy" });
const c = await createCopy(db, userId, { workId: await makeWork(db), notes: "shelf 3" });
const up = await updateCopy(db, userId, c.id, { notes: "shelf 4", customFields: { [def.id]: true } });
expect(up.notes).toBe("shelf 4");
expect(up.customFields).toEqual({ [def.id]: true });
await expect(updateCopy(db, userId, c.id, { customFields: { [def.id]: "yes" } })).rejects.toThrow(ValidationError);
const [h] = await getHistory(db, userId, "copy", c.id);
expect(h!.before).toEqual({ notes: "shelf 3", customFields: {} });
await softDeleteCopy(db, userId, c.id);
expect(await listCopies(db, userId, {})).toEqual([]);
const [del] = await getHistory(db, userId, "copy", c.id);
await restoreVersion(db, userId, del!.id);
expect((await listCopies(db, userId, {})).map((x) => x.id)).toEqual([c.id]);
});
test("another user's copies are not found", async () => {
const db = getDb();
const ada = await makeUser(db);
const bob = await makeUser(db, "bob");
const c = await createCopy(db, ada, { titleHint: "x" });
await expect(updateCopy(db, bob, c.id, { notes: "y" })).rejects.toThrow(NotFoundError);
await expect(softDeleteCopy(db, bob, c.id)).rejects.toThrow(NotFoundError);
expect(await listCopies(db, bob, {})).toEqual([]);
await expect(createCopy(db, bob, { workId: await makeWork(db, { ownerUserId: ada }) })).rejects.toThrow(
NotFoundError,
);
});
});
+126
View File
@@ -0,0 +1,126 @@
import { expect, test } from "bun:test";
import { forEachDialect } from "@sttracker/db/testing";
import { defineCustomField } from "../src/custom-fields.ts";
import {
addEngagement,
listEngagements,
softDeleteEngagement,
updateEngagement,
} from "../src/engagements.ts";
import { upsertEntry } from "../src/entries.ts";
import { NotFoundError, ValidationError } from "../src/errors.ts";
import { getHistory, restoreVersion } from "../src/history.ts";
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
forEachDialect("engagements", (getDb) => {
async function setup(mediaType = "book") {
const db = getDb();
const userId = await makeUser(db);
const entry = await upsertEntry(db, userId, await makeWork(db, { mediaType }), { status: "completed" }, "user");
return { db, userId, entry };
}
test("engagement with year precision keeps '2019'", async () => {
const { db, userId, entry } = await setup();
const eg = await addEngagement(
db,
userId,
entry.id,
{ finishedAt: { value: "2019", precision: "year" }, platform: "Kindle" },
"user",
);
expect(eg.finishedAt).toEqual({ value: "2019", precision: "year" });
expect(eg.startedAt).toBeNull();
expect(eg.fieldSources).toEqual({ finishedAt: "user", platform: "user" });
const row = await db.k.selectFrom("engagements").selectAll().where("id", "=", eg.id).executeTakeFirstOrThrow();
expect(row.finished_at).toBe("2019");
expect(row.finished_precision).toBe("year");
expect(row.started_at).toBeNull();
expect(row.started_precision).toBe("unknown");
const [listed] = await listEngagements(db, userId, entry.id);
expect(listed!.finishedAt).toEqual({ value: "2019", precision: "year" });
});
test("partial dates must match their precision", async () => {
const { db, userId, entry } = await setup();
const bad = [
{ value: "2019-03", precision: "year" },
{ value: "2019-02-30", precision: "day" },
{ value: "", precision: "day" },
{ value: "2019", precision: "bogus" },
];
for (const d of bad) {
await expect(addEngagement(db, userId, entry.id, { startedAt: d as never }, "user")).rejects.toThrow(
ValidationError,
);
}
const ok = await addEngagement(
db,
userId,
entry.id,
{ startedAt: { value: "2019-03-04T20:15:00Z", precision: "day" }, finishedAt: { value: "", precision: "unknown" } },
"user",
);
expect(ok.startedAt).toEqual({ value: "2019-03-04T20:15:00Z", precision: "day" });
expect(ok.finishedAt).toBeNull();
});
test("updateEngagement touches only patched keys and writes history", async () => {
const { db, userId, entry } = await setup();
const eg = await addEngagement(db, userId, entry.id, { rating: 70, notes: "great" }, "user");
const up = await updateEngagement(db, userId, eg.id, { rating: 90, progress: { page: 120 } }, "plugin:hardcover:c1");
expect(up.notes).toBe("great");
expect(up.rating).toBe(90);
expect(up.progress).toEqual({ page: 120 });
expect(up.fieldSources).toEqual({ rating: "plugin:hardcover:c1", notes: "user", progress: "plugin:hardcover:c1" });
const [h] = await getHistory(db, userId, "engagement", eg.id);
expect(h!.before).toEqual({ rating: 70, progress: {} });
expect(h!.after).toEqual({ rating: 90, progress: { page: 120 } });
await restoreVersion(db, userId, h!.id);
const [back] = await listEngagements(db, userId, entry.id);
expect(back!.rating).toBe(70);
expect(back!.fieldSources.rating).toBe("user");
await expect(updateEngagement(db, userId, eg.id, { rating: -1 }, "user")).rejects.toThrow(ValidationError);
await expect(updateEngagement(db, userId, eg.id, { bogus: 1 } as never, "user")).rejects.toThrow(ValidationError);
});
test("engagement custom fields use engagement-level definitions", async () => {
const { db, userId, entry } = await setup();
const entryLevel = await defineCustomField(db, userId, { name: "Mood", type: "text" });
const def = await defineCustomField(db, userId, { name: "Format", type: "single_select", options: ["audio"], level: "engagement" });
const eg = await addEngagement(db, userId, entry.id, { customFields: { [def.id]: "audio" } }, "user");
expect(eg.customFields).toEqual({ [def.id]: "audio" });
await expect(
addEngagement(db, userId, entry.id, { customFields: { [entryLevel.id]: "x" } }, "user"),
).rejects.toThrow(ValidationError);
});
test("softDeleteEngagement hides it; restore brings it back", async () => {
const { db, userId, entry } = await setup();
const eg = await addEngagement(db, userId, entry.id, { platform: "Switch" }, "user");
await softDeleteEngagement(db, userId, eg.id, "user");
expect(await listEngagements(db, userId, entry.id)).toEqual([]);
await expect(updateEngagement(db, userId, eg.id, { platform: "PC" }, "user")).rejects.toThrow(NotFoundError);
const [del] = await getHistory(db, userId, "engagement", eg.id);
await restoreVersion(db, userId, del!.id);
expect((await listEngagements(db, userId, entry.id)).map((e) => e.id)).toEqual([eg.id]);
});
test("engagements sort by start date, undated last", async () => {
const { db, userId, entry } = await setup();
const undated = await addEngagement(db, userId, entry.id, {}, "user");
const later = await addEngagement(db, userId, entry.id, { startedAt: { value: "2021-05", precision: "month" } }, "user");
const early = await addEngagement(db, userId, entry.id, { startedAt: { value: "2019", precision: "year" } }, "user");
expect((await listEngagements(db, userId, entry.id)).map((e) => e.id)).toEqual([early.id, later.id, undated.id]);
});
test("another user's engagements are not found", async () => {
const { db, userId, entry } = await setup();
const bob = await makeUser(db, "bob");
const eg = await addEngagement(db, userId, entry.id, {}, "user");
await expect(addEngagement(db, bob, entry.id, {}, "user")).rejects.toThrow(NotFoundError);
await expect(updateEngagement(db, bob, eg.id, { rating: 1 }, "user")).rejects.toThrow(NotFoundError);
await expect(softDeleteEngagement(db, bob, eg.id, "user")).rejects.toThrow(NotFoundError);
await expect(listEngagements(db, bob, entry.id)).rejects.toThrow(NotFoundError);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { expect, test } from "bun:test";
import { forEachDialect } from "@sttracker/db/testing";
import { NotFoundError, ValidationError } from "../src/errors.ts";
import { getHistory, restoreVersion } from "../src/history.ts";
import {
addListItem,
createList,
getList,
listLists,
moveListItem,
removeListItem,
softDeleteList,
updateList,
} from "../src/lists.ts";
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
forEachDialect("lists", (getDb) => {
test("grouping list can be rated", async () => {
const db = getDb();
const userId = await makeUser(db);
const g = await createList(db, userId, { name: "Silent Hill, everything", kind: "grouping", rating: 90, review: "Dread." });
expect(g.kind).toBe("grouping");
expect(g.rating).toBe(90);
expect(g.review).toBe("Dread.");
expect(g.visibility).toBe("private");
const up = await updateList(db, userId, g.id, { rating: 95 });
expect(up.rating).toBe(95);
await expect(createList(db, userId, { name: "Plain", kind: "list", rating: 50 })).rejects.toThrow(ValidationError);
await expect(createList(db, userId, { name: "Bad", kind: "grouping", rating: 101 })).rejects.toThrow(
ValidationError,
);
await expect(updateList(db, userId, g.id, { kind: "thread" })).rejects.toThrow(ValidationError);
await expect(createList(db, userId, { name: " " })).rejects.toThrow(ValidationError);
await expect(createList(db, userId, { name: "x", kind: "box" as never })).rejects.toThrow(ValidationError);
});
test("items keep order through add, move and remove", async () => {
const db = getDb();
const userId = await makeUser(db);
const l = await createList(db, userId, { name: "Cosmic dread", kind: "thread" });
const [a, b, c] = [await makeWork(db, { title: "A" }), await makeWork(db, { title: "B" }), await makeWork(db, { title: "C" })];
const ia = await addListItem(db, userId, l.id, { workId: a, note: "start here" });
const ib = await addListItem(db, userId, l.id, { workId: b });
const ic = await addListItem(db, userId, l.id, { workId: c });
const titles = async () => (await getList(db, userId, l.id)).items.map((i) => i.work.title);
expect(await titles()).toEqual(["A", "B", "C"]);
expect((await getList(db, userId, l.id)).items[0]!.note).toBe("start here");
await moveListItem(db, userId, ic.id, 0);
expect(await titles()).toEqual(["C", "A", "B"]);
await moveListItem(db, userId, ic.id, 99);
expect(await titles()).toEqual(["A", "B", "C"]);
await removeListItem(db, userId, ib.id);
expect(await titles()).toEqual(["A", "C"]);
expect((await getList(db, userId, l.id)).items.map((i) => i.position)).toEqual([0, 1]);
const inserted = await addListItem(db, userId, l.id, { workId: b, position: 1 });
expect(await titles()).toEqual(["A", "B", "C"]);
expect(inserted.position).toBe(1);
expect(ia.position).toBe(0);
});
test("removing an item is recorded and restorable", async () => {
const db = getDb();
const userId = await makeUser(db);
const l = await createList(db, userId, { name: "Reading order" });
await addListItem(db, userId, l.id, { workId: await makeWork(db, { title: "A" }) });
const ib = await addListItem(db, userId, l.id, { workId: await makeWork(db, { title: "B" }) });
await removeListItem(db, userId, ib.id);
const [h] = await getHistory(db, userId, "list", l.id);
await restoreVersion(db, userId, h!.id);
expect((await getList(db, userId, l.id)).items.map((i) => i.work.title)).toEqual(["A", "B"]);
});
test("listLists and soft delete", async () => {
const db = getDb();
const userId = await makeUser(db);
const l1 = await createList(db, userId, { name: "One" });
const l2 = await createList(db, userId, { name: "Two", kind: "grouping" });
expect((await listLists(db, userId)).map((l) => l.id).sort()).toEqual([l1.id, l2.id].sort());
expect((await listLists(db, userId, { kind: "grouping" })).map((l) => l.id)).toEqual([l2.id]);
await softDeleteList(db, userId, l1.id);
expect((await listLists(db, userId)).map((l) => l.id)).toEqual([l2.id]);
await expect(getList(db, userId, l1.id)).rejects.toThrow(NotFoundError);
});
test("another user's lists and private works are not found", async () => {
const db = getDb();
const ada = await makeUser(db);
const bob = await makeUser(db, "bob");
const l = await createList(db, ada, { name: "Mine" });
const item = await addListItem(db, ada, l.id, { workId: await makeWork(db) });
await expect(getList(db, bob, l.id)).rejects.toThrow(NotFoundError);
await expect(updateList(db, bob, l.id, { name: "x" })).rejects.toThrow(NotFoundError);
await expect(addListItem(db, bob, l.id, { workId: await makeWork(db) })).rejects.toThrow(NotFoundError);
await expect(moveListItem(db, bob, item.id, 0)).rejects.toThrow(NotFoundError);
await expect(removeListItem(db, bob, item.id)).rejects.toThrow(NotFoundError);
expect(await listLists(db, bob)).toEqual([]);
const adaPrivate = await makeWork(db, { ownerUserId: ada });
const bobList = await createList(db, bob, { name: "Bob's" });
await expect(addListItem(db, bob, bobList.id, { workId: adaPrivate })).rejects.toThrow(NotFoundError);
});
});