Files
STTracker/packages/core/test/entries.test.ts
T
Lucas WintherandClaude Opus 5.5 c6865b2522 fix(core): drill-down lists descendants under untracked intermediates
listEntries({ parentId: X }) now applies the top-level rule relative to X: this user's
live entries on descendants of X (up to three levels) with no live-entry work strictly
between them and X, so it agrees with childSummary. Restoring an engagement's re-parenting
onto a soft-deleted entry throws ValidationError("entryId", "target entry is deleted").

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:05:12 +01:00

390 lines
20 KiB
TypeScript

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 { addEngagement, listEngagements } from "../src/engagements.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);
// grandchildren: show → season (no entry) → episodes still roll up to the show
const show2 = await makeWork(db, { title: "The Expanse", mediaType: "show" });
const show2Entry = await upsertEntry(db, userId, show2, {}, "user");
const season = await makeWork(db, { title: "Season 1", mediaType: "season", parentId: show2 });
for (const status of ["completed", "planned"] as const) {
const ep = await makeWork(db, { title: "Episode", mediaType: "episode", parentId: season });
await upsertEntry(db, userId, ep, { status }, "user");
}
const top2 = await listEntries(db, userId, {});
expect(top2.items.map((i) => i.id).sort()).toEqual([showEntry.id, lone.id, show2Entry.id].sort());
expect(top2.items.find((i) => i.id === show2Entry.id)!.childSummary).toEqual({ total: 2, completed: 1 });
// drilling into the show lists the episodes, since the season between them has no entry
const drill = await listEntries(db, userId, { parentId: show2 });
expect(drill.items.map((i) => i.work.mediaType)).toEqual(["episode", "episode"]);
expect(await listEntries(db, userId, { parentId: show2, q: "nothing matches" })).toEqual({ items: [], nextCursor: null });
// once the season has an entry, the show lists the season and the season lists the episodes
const seasonEntry = await upsertEntry(db, userId, season, { status: "in_progress" }, "user");
expect((await listEntries(db, userId, { parentId: show2 })).items.map((i) => i.id)).toEqual([seasonEntry.id]);
const seasonDrill = await listEntries(db, userId, { parentId: season });
expect(seasonDrill.items.map((i) => i.work.mediaType)).toEqual(["episode", "episode"]);
expect(seasonDrill.items.find((i) => i.id === seasonEntry.id)).toBeUndefined();
});
test("orphaned child entries are top level until an ancestor has an entry", async () => {
const db = getDb();
const userId = await makeUser(db);
const show = await makeWork(db, { title: "Severance", mediaType: "show" });
const season = await makeWork(db, { title: "Season 1", mediaType: "season", parentId: show });
const ep = await makeWork(db, { title: "Good News About Hell", mediaType: "episode", parentId: season });
const epEntry = await upsertEntry(db, userId, ep, { status: "completed" }, "user");
expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]);
// another user's show entry does not hide it
const bob = await makeUser(db, "bob");
await upsertEntry(db, bob, show, {}, "user");
expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]);
const showEntry = await upsertEntry(db, userId, show, {}, "user");
expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([showEntry.id]);
// a soft-deleted ancestor entry does not hide it
await softDeleteEntry(db, userId, showEntry.id, "user");
expect((await listEntries(db, userId, {})).items.map((i) => i.id)).toEqual([epEntry.id]);
});
test("title pagination does not skip non-ASCII titles", async () => {
const db = getDb();
const userId = await makeUser(db);
const titles = ["Éclair", "Ölund", "Zeta", "alpha", "日本", "éclair 2", "BETA"];
for (const title of titles) await upsertEntry(db, userId, await makeWork(db, { title }), {}, "user");
const seen: string[] = [];
let cursor: string | undefined;
for (let i = 0; i < 20; i++) {
const page = await listEntries(db, userId, { sort: "title", limit: 1, cursor });
seen.push(...page.items.map((x) => x.work.title));
if (!page.nextCursor) break;
cursor = page.nextCursor;
}
expect(seen).toHaveLength(titles.length);
expect([...seen].sort()).toEqual([...titles].sort());
});
test("concurrent upserts of a new entry yield one entry with both patches", async () => {
const db = getDb();
const userId = await makeUser(db);
const workId = await makeWork(db);
const [a, b] = await Promise.all([
upsertEntry(db, userId, workId, { rating: 70 }, "plugin:trakt:c1"),
upsertEntry(db, userId, workId, { tags: ["cosy"] }, "user"),
]);
expect(a.id).toBe(b.id);
const rows = await db.k.selectFrom("entries").selectAll().where("user_id", "=", userId).execute();
expect(rows).toHaveLength(1);
const view = await getEntry(db, userId, a.id);
expect(view.rating).toBe(70);
expect(view.tags).toEqual(["cosy"]);
expect(view.fieldSources).toEqual({ rating: "plugin:trakt:c1", tags: "user" });
});
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);
});
test("merge keeps 'user' field_sources on combined fields", 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, { tags: ["x"], overrides: { year: 1 } }, "plugin:trakt:c1");
await upsertEntry(db, userId, b, { tags: ["y"], status: "completed" }, "user");
await upsertEntry(db, userId, b, { overrides: { title: "B!" } }, "plugin:hardcover:c2");
const merged = await moveEntryToWork(db, userId, src.id, b, "plugin:steam:c3");
expect(merged.tags.sort()).toEqual(["x", "y"]);
expect(merged.overrides).toEqual({ year: 1, title: "B!" });
expect(merged.fieldSources).toEqual({ tags: "user", status: "user", overrides: "plugin:hardcover:c2" });
});
test("merge into a soft-deleted entry carries the moved field_sources as-is", 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: 60, review: "mine" }, "user");
const dst = await upsertEntry(db, userId, b, { status: "dropped", tags: ["old"] }, "plugin:trakt:c1");
await softDeleteEntry(db, userId, dst.id, "user");
const merged = await moveEntryToWork(db, userId, src.id, b, "plugin:trakt:c2");
expect(merged.id).toBe(dst.id);
expect(merged.deletedAt).toBeNull();
expect(merged.rating).toBe(60);
expect(merged.status).toBe("none");
expect(merged.tags).toEqual([]);
expect(merged.fieldSources).toEqual({ rating: "user", review: "user" });
});
test("merge concatenates differing text, records the dropped rating and moves engagements with history", 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, { review: "from A", privateNotes: "same", rating: 40 }, "user");
const dst = await upsertEntry(db, userId, b, { review: "from B", privateNotes: "same", rating: 90 }, "user");
const eg = await addEngagement(db, userId, src.id, { platform: "Kindle" }, "user");
const merged = await moveEntryToWork(db, userId, src.id, b, "user");
expect(merged.review).toBe("from B\n\n---\n\nfrom A");
expect(merged.privateNotes).toBe("same");
expect(merged.rating).toBe(90);
const [h] = await getHistory(db, userId, "entry", dst.id);
expect(h!.before.rating).toBe(40);
expect(h!.after.rating).toBe(90);
expect(h!.before.review).toBe("from B");
expect((await listEngagements(db, userId, dst.id)).map((e) => e.id)).toEqual([eg.id]);
const [egh] = await getHistory(db, userId, "engagement", eg.id);
expect(egh!.before).toEqual({ entryId: src.id });
expect(egh!.after).toEqual({ entryId: dst.id });
});
});