feat(core): entries, user history and match suggestion accept/reject
Entries carry per-field field_sources, write user_history diffs, resolve aliased/merged works, and support quick add, per-user moves (with alias and draft tombstone) and keyset-paginated library listing with child rollups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
56b86e27da
commit
13b81b1cd5
5 files changed
+1340
No files matched your search
@@ -0,0 +1,255 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { newId } from "@sttracker/db/ids";
|
||||
import { forEachDialect } from "@sttracker/db/testing";
|
||||
import { defineCustomField } from "../src/custom-fields.ts";
|
||||
import { NotFoundError, ValidationError } from "../src/errors.ts";
|
||||
import {
|
||||
getEntry,
|
||||
listEntries,
|
||||
moveEntryToWork,
|
||||
quickAdd,
|
||||
softDeleteEntry,
|
||||
upsertEntry,
|
||||
} from "../src/entries.ts";
|
||||
import { getHistory } from "../src/history.ts";
|
||||
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
||||
|
||||
forEachDialect("entries", (getDb) => {
|
||||
test("upsertEntry records field_sources and history", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const workId = await makeWork(db);
|
||||
const created = await upsertEntry(db, userId, workId, { status: "planned", tags: ["sci-fi"] }, "user");
|
||||
expect(created.status).toBe("planned");
|
||||
expect(created.visibility).toBe("private");
|
||||
expect(created.fieldSources).toEqual({ status: "user", tags: "user" });
|
||||
|
||||
const updated = await upsertEntry(db, userId, workId, { rating: 80, status: "completed" }, "plugin:trakt:c1");
|
||||
expect(updated.id).toBe(created.id);
|
||||
expect(updated.rating).toBe(80);
|
||||
expect(updated.tags).toEqual(["sci-fi"]);
|
||||
expect(updated.fieldSources).toEqual({ status: "plugin:trakt:c1", tags: "user", rating: "plugin:trakt:c1" });
|
||||
|
||||
const history = await getHistory(db, userId, "entry", created.id);
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history[0]!.before).toEqual({ status: "planned", rating: null });
|
||||
expect(history[0]!.after).toEqual({ status: "completed", rating: 80 });
|
||||
expect(history[0]!.actor).toBe("plugin:trakt:c1");
|
||||
});
|
||||
|
||||
test("upsertEntry works inside a caller's transaction", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const workId = await makeWork(db);
|
||||
await expect(
|
||||
db.k.transaction().execute(async (tx) => {
|
||||
await upsertEntry(db, userId, workId, { status: "planned" }, "user", tx);
|
||||
throw new Error("rollback");
|
||||
}),
|
||||
).rejects.toThrow("rollback");
|
||||
expect((await listEntries(db, userId, {})).items).toHaveLength(0);
|
||||
const e = await db.k.transaction().execute((tx) => upsertEntry(db, userId, workId, { favorite: true }, "user", tx));
|
||||
expect(e.favorite).toBe(true);
|
||||
expect(e.status).toBe("none");
|
||||
});
|
||||
|
||||
test("visibility default comes from user preferences", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db, "pub", { defaultVisibility: "public" });
|
||||
const e = await upsertEntry(db, userId, await makeWork(db), {}, "user");
|
||||
expect(e.visibility).toBe("public");
|
||||
expect(e.fieldSources).toEqual({});
|
||||
});
|
||||
|
||||
test("rating 101 rejected", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const workId = await makeWork(db);
|
||||
await expect(upsertEntry(db, userId, workId, { rating: 101 }, "user")).rejects.toThrow(ValidationError);
|
||||
await expect(upsertEntry(db, userId, workId, { rating: 7.5 }, "user")).rejects.toThrow(ValidationError);
|
||||
await expect(upsertEntry(db, userId, workId, { status: "done" as never }, "user")).rejects.toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
expect((await listEntries(db, userId, {})).items).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("custom field values are validated against the user's entry definitions", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const workId = await makeWork(db, { mediaType: "book" });
|
||||
const def = await defineCustomField(db, userId, { name: "Mood", type: "single_select", options: ["cosy"] });
|
||||
const gameOnly = await defineCustomField(db, userId, { name: "Hours", type: "number", appliesTo: ["game"] });
|
||||
const e = await upsertEntry(db, userId, workId, { customFields: { [def.id]: "cosy" } }, "user");
|
||||
expect(e.customFields).toEqual({ [def.id]: "cosy" });
|
||||
await expect(upsertEntry(db, userId, workId, { customFields: { [def.id]: "grim" } }, "user")).rejects.toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
await expect(upsertEntry(db, userId, workId, { customFields: { [gameOnly.id]: 3 } }, "user")).rejects.toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
});
|
||||
|
||||
test("quickAdd creates draft entry", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const view = await quickAdd(db, userId, { title: "Zine I found" });
|
||||
expect(view.draft).toBe(true);
|
||||
expect(view.work.title).toBe("Zine I found");
|
||||
expect(view.work.mediaType).toBe("other");
|
||||
expect(view.status).toBe("none");
|
||||
const work = await db.k.selectFrom("works").selectAll().where("id", "=", view.workId).executeTakeFirstOrThrow();
|
||||
expect(work.owner_user_id).toBe(userId);
|
||||
|
||||
const shared = await upsertEntry(db, userId, await makeWork(db, { externalIds: { isbn13: "1" } }), {}, "user");
|
||||
expect((await getEntry(db, userId, shared.id)).draft).toBe(false);
|
||||
expect((await listEntries(db, userId, { draft: true })).items.map((i) => i.id)).toEqual([view.id]);
|
||||
await expect(quickAdd(db, userId, { title: " " })).rejects.toThrow(ValidationError);
|
||||
await expect(quickAdd(db, userId, { title: "x", mediaType: "zine" as never })).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("listEntries rolls up children", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const show = await makeWork(db, { title: "Severance", mediaType: "show" });
|
||||
const showEntry = await upsertEntry(db, userId, show, { status: "in_progress" }, "user");
|
||||
for (const status of ["completed", "completed", "in_progress"] as const) {
|
||||
const ep = await makeWork(db, { title: "Episode", mediaType: "episode", parentId: show });
|
||||
await upsertEntry(db, userId, ep, { status }, "user");
|
||||
}
|
||||
const lone = await upsertEntry(db, userId, await makeWork(db, { title: "Dune" }), {}, "user");
|
||||
|
||||
const top = await listEntries(db, userId, {});
|
||||
expect(top.items.map((i) => i.id).sort()).toEqual([showEntry.id, lone.id].sort());
|
||||
const showView = top.items.find((i) => i.id === showEntry.id)!;
|
||||
expect(showView.childSummary).toEqual({ total: 3, completed: 2 });
|
||||
expect(top.items.find((i) => i.id === lone.id)!.childSummary).toBeNull();
|
||||
|
||||
const children = await listEntries(db, userId, { parentId: show });
|
||||
expect(children.items).toHaveLength(3);
|
||||
expect(children.items.every((i) => i.work.mediaType === "episode")).toBe(true);
|
||||
});
|
||||
|
||||
test("listEntries filters, sorts and paginates", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const other = await makeUser(db, "bob");
|
||||
const titles = ["Alpha", "bravo", "Charlie", "delta", "Echo"];
|
||||
const ids: string[] = [];
|
||||
for (const [i, title] of titles.entries()) {
|
||||
const w = await makeWork(db, { title, mediaType: i % 2 ? "game" : "book" });
|
||||
const e = await upsertEntry(
|
||||
db,
|
||||
userId,
|
||||
w,
|
||||
{ rating: i === 4 ? null : i * 10, tags: i < 2 ? ["Cosy"] : [], status: i === 0 ? "completed" : "planned" },
|
||||
"user",
|
||||
);
|
||||
ids.push(e.id);
|
||||
await upsertEntry(db, other, w, { status: "completed" }, "user");
|
||||
}
|
||||
// updated sort: newest first, keyset pagination
|
||||
const p1 = await listEntries(db, userId, { limit: 2 });
|
||||
expect(p1.items).toHaveLength(2);
|
||||
expect(p1.nextCursor).not.toBeNull();
|
||||
const p2 = await listEntries(db, userId, { limit: 2, cursor: p1.nextCursor! });
|
||||
const p3 = await listEntries(db, userId, { limit: 2, cursor: p2.nextCursor! });
|
||||
expect(p3.nextCursor).toBeNull();
|
||||
expect([...p1.items, ...p2.items, ...p3.items].map((i) => i.id)).toEqual([...ids].reverse());
|
||||
|
||||
const byTitle = await listEntries(db, userId, { sort: "title", limit: 3 });
|
||||
const byTitle2 = await listEntries(db, userId, { sort: "title", limit: 3, cursor: byTitle.nextCursor! });
|
||||
expect([...byTitle.items, ...byTitle2.items].map((i) => i.work.title)).toEqual(titles);
|
||||
|
||||
const byRating = await listEntries(db, userId, { sort: "rating", limit: 2 });
|
||||
const byRating2 = await listEntries(db, userId, { sort: "rating", limit: 10, cursor: byRating.nextCursor! });
|
||||
expect([...byRating.items, ...byRating2.items].map((i) => i.rating)).toEqual([30, 20, 10, 0, null]);
|
||||
|
||||
expect((await listEntries(db, userId, { mediaType: "game" })).items).toHaveLength(2);
|
||||
expect((await listEntries(db, userId, { status: "completed" })).items.map((i) => i.id)).toEqual([ids[0]!]);
|
||||
expect((await listEntries(db, userId, { tag: "cosy" })).items).toHaveLength(2);
|
||||
expect((await listEntries(db, userId, { q: "RAV" })).items.map((i) => i.id)).toEqual([ids[1]!]);
|
||||
expect((await listEntries(db, userId, { q: "100%" })).items).toHaveLength(0);
|
||||
await expect(listEntries(db, userId, { cursor: "garbage" })).rejects.toThrow(ValidationError);
|
||||
await expect(listEntries(db, userId, { sort: "title", cursor: p1.nextCursor! })).rejects.toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
});
|
||||
|
||||
test("overrides apply to the work view", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const workId = await makeWork(db, { title: "Dune", year: 1965 });
|
||||
const e = await upsertEntry(db, userId, workId, { overrides: { title: "Dune (my edition)", year: 1990 } }, "user");
|
||||
const view = await getEntry(db, userId, e.id);
|
||||
expect(view.work.title).toBe("Dune (my edition)");
|
||||
expect(view.work.year).toBe(1990);
|
||||
expect(view.work.coverUrl).toBe("");
|
||||
await expect(upsertEntry(db, userId, workId, { overrides: { colour: "red" } as never }, "user")).rejects.toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
});
|
||||
|
||||
test("other users' entries and private works are not found", async () => {
|
||||
const db = getDb();
|
||||
const ada = await makeUser(db);
|
||||
const bob = await makeUser(db, "bob");
|
||||
const draft = await quickAdd(db, ada, { title: "Secret" });
|
||||
await expect(getEntry(db, bob, draft.id)).rejects.toThrow(NotFoundError);
|
||||
await expect(upsertEntry(db, bob, draft.workId, {}, "user")).rejects.toThrow(NotFoundError);
|
||||
await expect(softDeleteEntry(db, bob, draft.id, "user")).rejects.toThrow(NotFoundError);
|
||||
await expect(moveEntryToWork(db, bob, draft.id, await makeWork(db), "user")).rejects.toThrow(NotFoundError);
|
||||
await expect(getEntry(db, ada, newId())).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
test("softDeleteEntry hides the entry and upsert revives it", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const workId = await makeWork(db);
|
||||
const e = await upsertEntry(db, userId, workId, { rating: 40 }, "user");
|
||||
await softDeleteEntry(db, userId, e.id, "user");
|
||||
await expect(getEntry(db, userId, e.id)).rejects.toThrow(NotFoundError);
|
||||
expect((await listEntries(db, userId, {})).items).toHaveLength(0);
|
||||
const again = await upsertEntry(db, userId, workId, { status: "planned" }, "user");
|
||||
expect(again.id).toBe(e.id);
|
||||
expect(again.deletedAt).toBeNull();
|
||||
expect(again.rating).toBe(40);
|
||||
});
|
||||
|
||||
test("moveEntryToWork leaves alias", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const draft = await quickAdd(db, userId, { title: "Dune??", mediaType: "book" });
|
||||
const shared = await makeWork(db, { title: "Dune", externalIds: { isbn13: "9780441013593" } });
|
||||
const moved = await moveEntryToWork(db, userId, draft.id, shared, "user");
|
||||
expect(moved.id).toBe(draft.id);
|
||||
expect(moved.workId).toBe(shared);
|
||||
|
||||
const alias = await db.k
|
||||
.selectFrom("work_aliases")
|
||||
.selectAll()
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(alias.from_work_id).toBe(draft.workId);
|
||||
expect(alias.to_work_id).toBe(shared);
|
||||
const old = await db.k.selectFrom("works").selectAll().where("id", "=", draft.workId).executeTakeFirstOrThrow();
|
||||
expect(old.merged_into_id).toBe(shared);
|
||||
expect((await getEntry(db, userId, draft.id)).draft).toBe(false);
|
||||
const [h] = await getHistory(db, userId, "entry", draft.id);
|
||||
expect(h!.after).toEqual({ workId: shared });
|
||||
});
|
||||
|
||||
test("moveEntryToWork merges into an existing entry on the target", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const a = await makeWork(db, { title: "A" });
|
||||
const b = await makeWork(db, { title: "B" });
|
||||
const src = await upsertEntry(db, userId, a, { rating: 70, tags: ["x"] }, "user");
|
||||
const dst = await upsertEntry(db, userId, b, { status: "completed", tags: ["y"] }, "user");
|
||||
const merged = await moveEntryToWork(db, userId, src.id, b, "user");
|
||||
expect(merged.id).toBe(dst.id);
|
||||
expect(merged.status).toBe("completed");
|
||||
expect(merged.rating).toBe(70);
|
||||
expect(merged.tags.sort()).toEqual(["x", "y"]);
|
||||
await expect(getEntry(db, userId, src.id)).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { forEachDialect } from "@sttracker/db/testing";
|
||||
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();
|
||||
// the shared work now aliases back to the draft for this user; no alias cycle remains
|
||||
const aliases = await db.k.selectFrom("work_aliases").selectAll().where("user_id", "=", userId).execute();
|
||||
expect(aliases.map((a) => [a.from_work_id, a.to_work_id])).toEqual([[shared, draft.workId]]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { newId, nowIso } from "@sttracker/db/ids";
|
||||
import { forEachDialect } from "@sttracker/db/testing";
|
||||
import { acceptSuggestion, getEntry, quickAdd, rejectSuggestion } from "../src/entries.ts";
|
||||
import { NotFoundError } from "../src/errors.ts";
|
||||
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
||||
|
||||
async function suggest(db: Db, userId: string, workId: string, candidateWorkId: string): Promise<string> {
|
||||
const id = newId();
|
||||
await db.k
|
||||
.insertInto("match_suggestions")
|
||||
.values({ id, user_id: userId, work_id: workId, candidate_work_id: candidateWorkId, score: 0.9, status: "pending", created_at: nowIso() })
|
||||
.execute();
|
||||
return id;
|
||||
}
|
||||
|
||||
async function statusOf(db: Db, id: string): Promise<string> {
|
||||
return (await db.k.selectFrom("match_suggestions").select("status").where("id", "=", id).executeTakeFirstOrThrow()).status;
|
||||
}
|
||||
|
||||
forEachDialect("suggestions", (getDb) => {
|
||||
test("acceptSuggestion moves the entry and rejects the other pending suggestions", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const draft = await quickAdd(db, userId, { title: "Dune??", mediaType: "book" });
|
||||
const good = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } });
|
||||
const other = await makeWork(db, { title: "Dune Messiah" });
|
||||
const s1 = await suggest(db, userId, draft.workId, good);
|
||||
const s2 = await suggest(db, userId, draft.workId, other);
|
||||
await acceptSuggestion(db, userId, s1);
|
||||
expect(await statusOf(db, s1)).toBe("accepted");
|
||||
expect(await statusOf(db, s2)).toBe("rejected");
|
||||
const view = await getEntry(db, userId, draft.id);
|
||||
expect(view.workId).toBe(good);
|
||||
expect(view.draft).toBe(false);
|
||||
});
|
||||
|
||||
test("rejectSuggestion; other users get NotFoundError", async () => {
|
||||
const db = getDb();
|
||||
const ada = await makeUser(db);
|
||||
const bob = await makeUser(db, "bob");
|
||||
const s = await suggest(db, ada, await makeWork(db), await makeWork(db));
|
||||
await expect(acceptSuggestion(db, bob, s)).rejects.toThrow(NotFoundError);
|
||||
await expect(rejectSuggestion(db, bob, s)).rejects.toThrow(NotFoundError);
|
||||
expect(await statusOf(db, s)).toBe("pending");
|
||||
await rejectSuggestion(db, ada, s);
|
||||
expect(await statusOf(db, s)).toBe("rejected");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user