import { expect, test } from "bun:test"; import type { Db } from "@sttracker/db/db"; import { newId, nowIso } from "@sttracker/db/ids"; import { fromJson } from "@sttracker/db/json"; import { forEachDialect } from "@sttracker/db/testing"; import { AuthError, ValidationError } from "../src/errors.ts"; import { matchWork } from "../src/matching.ts"; import { adminMergeWorks, createWork, findWorkByExternalIds, getWork, resolveAlias, searchWorks, updateWorkFromHint, } from "../src/works.ts"; async function addUser(db: Db, role: "admin" | "member" = "member"): Promise { const id = newId(); const now = nowIso(); await db.k .insertInto("users") .values({ id, username: `u${id}`, display_name: "U", password_hash: "x", role, preferences: "{}", created_at: now, updated_at: now }) .execute(); return id; } async function addEntry(db: Db, userId: string, workId: string): Promise { const id = newId(); const now = nowIso(); await db.k .insertInto("entries") .values({ id, user_id: userId, work_id: workId, status: "none", rating: null, review: null, private_notes: null, favorite: 0, tags: "[]", custom_fields: "{}", visibility: "private", overrides: "{}", media_type_override: null, field_sources: "{}", created_at: now, updated_at: now, deleted_at: null, }) .execute(); return id; } forEachDialect("works", (getDb) => { test("createWork stores external ids; findWorkByExternalIds finds any namespace", async () => { const db = getDb(); const id = await createWork(db, { hint: { title: "Dune", mediaType: "book", year: 1965, externalIds: { isbn13: "9780441013593", hardcover_book: "7" } } }, "test"); expect(await findWorkByExternalIds(db, { nope: "1", hardcover_book: "7" })).toBe(id); expect(await findWorkByExternalIds(db, { isbn13: "0" })).toBeNull(); expect((await getWork(db, id))?.year).toBe(1965); }); test("updateWorkFromHint updates shared work, logs history, never touches private", async () => { const db = getDb(); const u = await addUser(db); const shared = await createWork(db, { hint: { title: "Dune", mediaType: "book", externalIds: { a: "1" } } }, "t"); await updateWorkFromHint(db, shared, { title: "Dune", mediaType: "book", year: 1965, description: "Sand", externalIds: { a: "1", b: "2" } }, "plug"); const w = await getWork(db, shared); expect(w?.year).toBe(1965); expect(w?.description).toBe("Sand"); expect(await findWorkByExternalIds(db, { b: "2" })).toBe(shared); const hist = await db.k.selectFrom("work_history").selectAll().where("work_id", "=", shared).where("actor", "=", "plug").execute(); expect(hist).toHaveLength(1); expect(fromJson>(hist[0]!.before, {}).year).toBeNull(); const priv = await createWork(db, { hint: { title: "Mine", mediaType: "book", externalIds: {} }, ownerUserId: u }, "t"); await updateWorkFromHint(db, priv, { title: "Changed", mediaType: "book", externalIds: {} }, "plug"); expect((await getWork(db, priv))?.title).toBe("Mine"); }); test("private works invisible to other users in search", async () => { const db = getDb(); const u1 = await addUser(db); const u2 = await addUser(db); await createWork(db, { hint: { title: "Secret 100%", mediaType: "book", externalIds: {} }, ownerUserId: u1 }, "t"); await createWork(db, { hint: { title: "Public Thing", mediaType: "book", externalIds: { x: "1" } } }, "t"); expect((await searchWorks(db, u1, "secret 100%")).map((w) => w.title)).toEqual(["Secret 100%"]); expect(await searchWorks(db, u2, "secret")).toEqual([]); expect((await searchWorks(db, u2, "PUBLIC")).map((w) => w.title)).toEqual(["Public Thing"]); expect(await searchWorks(db, u2, "public", { mediaType: "game" })).toEqual([]); }); test("adminMergeWorks requires admin", async () => { const db = getDb(); const member = await addUser(db); const a = await createWork(db, { hint: { title: "A", mediaType: "book", externalIds: { x: "1" } } }, "t"); const b = await createWork(db, { hint: { title: "B", mediaType: "book", externalIds: { x: "2" } } }, "t"); await expect(adminMergeWorks(db, member, a, b)).rejects.toBeInstanceOf(AuthError); }); test("adminMergeWorks tombstones, moves ids, repoints entries, emits merged event on conflict", async () => { const db = getDb(); const admin = await addUser(db, "admin"); const u1 = await addUser(db); const u2 = await addUser(db); const from = await createWork(db, { hint: { title: "Old", mediaType: "book", externalIds: { x: "1" } } }, "t"); const to = await createWork(db, { hint: { title: "New", mediaType: "book", externalIds: { x: "2" } } }, "t"); const e1 = await addEntry(db, u1, from); const e2from = await addEntry(db, u2, from); await addEntry(db, u2, to); await adminMergeWorks(db, admin, from, to); expect((await getWork(db, from))?.id).toBe(to); expect(await findWorkByExternalIds(db, { x: "1" })).toBe(to); expect(await resolveAlias(db, u1, from)).toBe(to); expect((await db.k.selectFrom("entries").select("work_id").where("id", "=", e1).executeTakeFirstOrThrow()).work_id).toBe(to); expect((await db.k.selectFrom("entries").select("work_id").where("id", "=", e2from).executeTakeFirstOrThrow()).work_id).toBe(from); const ev = await db.k.selectFrom("change_events").selectAll().execute(); expect(ev).toHaveLength(1); expect(ev[0]).toMatchObject({ user_id: u2, type: "merged", status: "pending", target_entity: "entry", target_id: e2from }); expect(fromJson(ev[0]!.payload, {})).toEqual({ fromWorkId: from, toWorkId: to }); expect(await db.k.selectFrom("work_history").select("id").where("work_id", "=", from).where("actor", "=", admin).execute()).toHaveLength(1); }); test("createWork refuses external ids on a private work", async () => { const db = getDb(); const u = await addUser(db); const p = createWork(db, { hint: { title: "Mine", mediaType: "book", externalIds: { isbn13: "1" } }, ownerUserId: u }, "t"); await expect(p).rejects.toBeInstanceOf(ValidationError); await expect(p).rejects.toMatchObject({ field: "externalIds", reason: "private works cannot carry external ids" }); expect(await db.k.selectFrom("works").select("id").execute()).toHaveLength(0); }); test("findWorkByExternalIds only returns works visible to the user", async () => { const db = getDb(); const a = await addUser(db); const b = await addUser(db); const priv = await createWork(db, { hint: { title: "Mine", mediaType: "book", externalIds: {} }, ownerUserId: a }, "t"); // Legacy data: a private work carrying an external id (no longer creatable through createWork). await db.k.insertInto("external_ids").values({ id: newId(), work_id: priv, namespace: "isbn13", value: "42" }).execute(); expect(await findWorkByExternalIds(db, { isbn13: "42" }, a)).toBe(priv); expect(await findWorkByExternalIds(db, { isbn13: "42" }, b)).toBeNull(); expect(await findWorkByExternalIds(db, { isbn13: "42" })).toBeNull(); const shared = await createWork(db, { hint: { title: "Theirs", mediaType: "book", externalIds: { asin: "X" } } }, "t"); const hint = { title: "Mine", mediaType: "book" as const, externalIds: { isbn13: "42", asin: "X" } }; expect(await matchWork(db, b, hint, "p")).toEqual({ workId: shared, created: false, draft: false }); expect(await matchWork(db, a, hint, "p")).toEqual({ workId: priv, created: false, draft: true }); }); test("getWork returns null on a merged_into cycle", async () => { const db = getDb(); const a = await createWork(db, { hint: { title: "A", mediaType: "book", externalIds: { x: "1" } } }, "t"); const b = await createWork(db, { hint: { title: "B", mediaType: "book", externalIds: { x: "2" } } }, "t"); await db.k.updateTable("works").set({ merged_into_id: b }).where("id", "=", a).execute(); await db.k.updateTable("works").set({ merged_into_id: a }).where("id", "=", b).execute(); expect(await getWork(db, a)).toBeNull(); }); test("search treats a literal _ as a character, not a wildcard", async () => { const db = getDb(); const u = await addUser(db); await createWork(db, { hint: { title: "snake_case", mediaType: "book", externalIds: { x: "1" } } }, "t"); await createWork(db, { hint: { title: "snakeXcase", mediaType: "book", externalIds: { x: "2" } } }, "t"); expect((await searchWorks(db, u, "e_c")).map((w) => w.title)).toEqual(["snake_case"]); }); test("adminMergeWorks refuses private works", async () => { const db = getDb(); const admin = await addUser(db, "admin"); const shared = await createWork(db, { hint: { title: "A", mediaType: "book", externalIds: { x: "1" } } }, "t"); const priv = await createWork(db, { hint: { title: "B", mediaType: "book", externalIds: {} }, ownerUserId: admin }, "t"); await expect(adminMergeWorks(db, admin, priv, shared)).rejects.toBeInstanceOf(ValidationError); await expect(adminMergeWorks(db, admin, shared, priv)).rejects.toBeInstanceOf(ValidationError); expect((await getWork(db, priv))?.id).toBe(priv); }); test("adminMergeWorks moves children that do not collide, so matching does not recreate them", async () => { const db = getDb(); const admin = await addUser(db, "admin"); const u = await addUser(db); const show = (id: string) => ({ title: "Lost", mediaType: "show" as const, externalIds: { tmdb_show: id } }); const ep = (showId: string, season: number) => ({ title: "Pilot", mediaType: "episode" as const, externalIds: {}, position: { season, episode: 1 }, parent: { title: `Season ${season}`, mediaType: "season" as const, externalIds: {}, position: { season }, parent: show(showId) }, }); const a1 = await matchWork(db, u, ep("A", 1), "p"); const a2 = await matchWork(db, u, ep("A", 2), "p"); const b2 = await matchWork(db, u, ep("B", 2), "p"); const showA = (await findWorkByExternalIds(db, { tmdb_show: "A" }))!; const showB = (await findWorkByExternalIds(db, { tmdb_show: "B" }))!; const seasonA1 = (await getWork(db, a1.workId))!.parent_id!; const seasonA2 = (await getWork(db, a2.workId))!.parent_id!; const before = (await db.k.selectFrom("works").select("id").execute()).length; await adminMergeWorks(db, admin, showA, showB); expect((await getWork(db, seasonA1))?.parent_id).toBe(showB); // moved: B had no season 1 expect((await getWork(db, seasonA2))?.parent_id).toBe(showA); // left: B already had season 2 const again = await matchWork(db, u, ep("A", 1), "p"); expect(again).toEqual({ workId: a1.workId, created: false, draft: false }); expect((await matchWork(db, u, ep("A", 2), "p")).workId).toBe(b2.workId); expect((await db.k.selectFrom("works").select("id").execute()).length).toBe(before); }); });