merge: task 5 works and matching

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 19:35:57 +01:00
commit a13d9cd476
9 files changed
+946 -2

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
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);
});
});
+196
View File
@@ -0,0 +1,196 @@
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<string> {
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<string> {
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<Record<string, unknown>>(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);
});
});