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 { fuzzyScore, matchWork } from "../src/matching.ts"; import { adminMergeWorks, createWork, getWork } from "../src/works.ts"; async function addUser(db: Db): Promise { const id = newId(); const now = nowIso(); await db.k .insertInto("users") .values({ id, username: `u${id}`, display_name: "U", password_hash: "x", role: "member", preferences: "{}", created_at: now, updated_at: now }) .execute(); return id; } test("fuzzyScore", () => { expect(fuzzyScore({ title: "The Expanse: Leviathan Wakes" }, { title: "Leviathan Wakes" })).toBeGreaterThan(0.6); expect(fuzzyScore({ title: "Dune" }, { title: "Dune Messiah" })).toBeLessThan(0.6); expect(fuzzyScore({ title: "Pokémon" }, { title: "Pokemon" })).toBe(1); expect( fuzzyScore({ title: "Pokémon", year: 1996, creators: [{ name: "Satoshi" }] }, { title: "pokemon!", year: 1996, creators: [{ name: "satoshi" }] }), ).toBe(1); // year off by one counts half, creator mismatch counts zero expect(fuzzyScore({ title: "A B", year: 2000, creators: [{ name: "x" }] }, { title: "A B", year: 2001, creators: [{ name: "y" }] })).toBeCloseTo(0.8, 5); }); forEachDialect("matching", (getDb) => { test("match by isbn13 links existing", async () => { const db = getDb(); const u = await addUser(db); const id = await createWork(db, { hint: { title: "Dune", mediaType: "book", externalIds: { isbn13: "9780441013593" } } }, "t"); const r = await matchWork(db, u, { title: "Dune (Ace)", mediaType: "book", year: 1990, externalIds: { isbn13: "9780441013593", asin: "B1" } }, "plug"); expect(r).toEqual({ workId: id, created: false, draft: false }); expect((await getWork(db, id))?.year).toBe(1990); }); test("unseen external id creates shared work", async () => { const db = getDb(); const u = await addUser(db); const r = await matchWork(db, u, { title: "Heat", mediaType: "movie", externalIds: { tmdb_movie: "949" } }, "p"); expect(r.created).toBe(true); expect(r.draft).toBe(false); expect((await getWork(db, r.workId))?.owner_user_id).toBeNull(); }); const chain = () => ({ title: "Pilot", mediaType: "episode" as const, externalIds: {}, position: { episode: 1, season: 1 }, parent: { title: "Season 1", mediaType: "season" as const, externalIds: {}, position: { season: 1 }, parent: { title: "Lost", mediaType: "show" as const, externalIds: { tmdb_show: "4607" } }, }, }); test("episode hint creates show→season→episode chain once", async () => { const db = getDb(); const u = await addUser(db); const r1 = await matchWork(db, u, chain(), "p"); const r2 = await matchWork(db, u, chain(), "p"); expect(r1.created).toBe(true); expect(r2).toEqual({ workId: r1.workId, created: false, draft: false }); expect(await db.k.selectFrom("works").select("id").execute()).toHaveLength(3); const ep = await getWork(db, r1.workId); expect(ep?.position).toEqual({ season: 1, episode: 1 }); expect(ep?.parent_id).not.toBeNull(); const season = await getWork(db, ep!.parent_id!); expect(season?.media_type).toBe("season"); expect((await getWork(db, season!.parent_id!))?.media_type).toBe("show"); }); test("position key order does not matter", async () => { const db = getDb(); const u = await addUser(db); const a = await matchWork(db, u, chain(), "p"); const h = chain(); h.position = { season: 1, episode: 1 }; expect((await matchWork(db, u, h, "p")).workId).toBe(a.workId); }); test("hint without ids creates private draft with suggestions", async () => { const db = getDb(); const u = await addUser(db); const shared = await createWork(db, { hint: { title: "Leviathan Wakes", mediaType: "book", externalIds: { isbn13: "1" } } }, "t"); await createWork(db, { hint: { title: "Leviathan Wakes", mediaType: "game", externalIds: { igdb: "1" } } }, "t"); await createWork(db, { hint: { title: "Unrelated", mediaType: "book", externalIds: { isbn13: "2" } } }, "t"); const r = await matchWork(db, u, { title: "The Expanse: Leviathan Wakes", mediaType: "book", externalIds: {} }, "p"); expect(r.created).toBe(true); expect(r.draft).toBe(true); expect((await getWork(db, r.workId))?.owner_user_id).toBe(u); const sug = await db.k.selectFrom("match_suggestions").selectAll().execute(); expect(sug).toHaveLength(1); expect(sug[0]).toMatchObject({ user_id: u, work_id: r.workId, candidate_work_id: shared, status: "pending" }); expect(sug[0]!.score).toBeGreaterThan(0.6); }); test("concurrent matches of the same new external id create one work", async () => { const db = getDb(); const u = await addUser(db); const hint = () => ({ title: "Heat", mediaType: "movie" as const, externalIds: { tmdb_movie: "949" } }); const rs = await Promise.all([matchWork(db, u, hint(), "p"), matchWork(db, u, hint(), "p"), matchWork(db, u, hint(), "p")]); expect(new Set(rs.map((r) => r.workId)).size).toBe(1); expect(await db.k.selectFrom("works").select("id").execute()).toHaveLength(1); expect(await db.k.selectFrom("external_ids").select("id").execute()).toHaveLength(1); }); test("concurrent matches of the same new episode chain create it once", async () => { const db = getDb(); const u = await addUser(db); const rs = await Promise.all([matchWork(db, u, chain(), "p"), matchWork(db, u, chain(), "p"), matchWork(db, u, chain(), "p")]); expect(new Set(rs.map((r) => r.workId)).size).toBe(1); expect(await db.k.selectFrom("works").select("id").execute()).toHaveLength(3); }); test("concurrent matches of new episodes under an existing show create one season", async () => { const db = getDb(); const u = await addUser(db); await createWork(db, { hint: { title: "Lost", mediaType: "show", externalIds: { tmdb_show: "4607" } } }, "t"); const rs = await Promise.all([matchWork(db, u, chain(), "p"), matchWork(db, u, chain(), "p")]); expect(rs[0]!.workId).toBe(rs[1]!.workId); expect(await db.k.selectFrom("works").select("id").execute()).toHaveLength(3); }); test("concurrent updates adding the same external id do not fail", async () => { const db = getDb(); const u = await addUser(db); const id = await createWork(db, { hint: { title: "Dune", mediaType: "book", externalIds: { isbn13: "1" } } }, "t"); const hint = () => ({ title: "Dune", mediaType: "book" as const, externalIds: { isbn13: "1", asin: "B1" } }); const rs = await Promise.all([matchWork(db, u, hint(), "p"), matchWork(db, u, hint(), "p"), matchWork(db, u, hint(), "p")]); expect(rs.map((r) => r.workId)).toEqual([id, id, id]); expect(await db.k.selectFrom("external_ids").select("id").where("namespace", "=", "asin").execute()).toHaveLength(1); }); test("draft reflects the aliased work, not the matched one", async () => { const db = getDb(); const u = await addUser(db); const shared = await createWork(db, { hint: { title: "Dune", mediaType: "book", externalIds: { isbn13: "1" } } }, "t"); const priv = await createWork(db, { hint: { title: "My Dune", mediaType: "book", externalIds: {} }, ownerUserId: u }, "t"); await db.k.insertInto("work_aliases").values({ id: newId(), user_id: u, from_work_id: shared, to_work_id: priv }).execute(); expect(await matchWork(db, u, { title: "Dune", mediaType: "book", externalIds: { isbn13: "1" } }, "p")).toEqual({ workId: priv, created: false, draft: true }); }); test("position lookup follows a merged tombstone instead of colliding with it", async () => { const db = getDb(); const u = await addUser(db); await db.k.updateTable("users").set({ role: "admin" }).where("id", "=", u).execute(); const e1 = await matchWork(db, u, chain(), "p"); const h2 = chain(); h2.title = "Episode 2"; h2.position = { season: 1, episode: 2 }; const e2 = await matchWork(db, u, h2, "p"); await adminMergeWorks(db, u, e1.workId, e2.workId); const before = (await db.k.selectFrom("works").select("id").execute()).length; expect(await matchWork(db, u, chain(), "p")).toEqual({ workId: e2.workId, created: false, draft: false }); expect(await db.k.selectFrom("works").select("id").execute()).toHaveLength(before); }); });