Files
Lucas WintherandClaude Opus 5.5 50c115b4c7 fix(core): follow merged tombstones in the position lookup
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]>
2026-10-30 19:32:27 +01:00

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