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); }); });