A merged child still holds its (parent_id, position) slot under the unique index, so skipping tombstones made every retry collide. Follow the tombstone to the surviving work instead. Co-Authored-By: Claude Opus 5.5 <[email protected]>
164 lines
8.3 KiB
TypeScript
164 lines
8.3 KiB
TypeScript
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<string> {
|
|
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);
|
|
});
|
|
});
|