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:
1 parent
13b81b1cd5
commit
c7471f7761
7 files changed
+1209
No files matched your search
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user