fix(core): race-safe matching, visibility-checked ID matches, child-preserving merges
- migration 0002: unique index on works (parent_id, position) - matchWork retries the whole transaction on a unique violation (3 attempts) - addMissingExternalIds uses ON CONFLICT DO NOTHING - ID matches only return shared works or the user's own; private works cannot carry external ids - adminMergeWorks requires shared works and repoints non-colliding children - draft is computed for the aliased work id Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a46fb60eee
commit
437ca143e5
9 files changed
+270
-18
No files matched your search
@@ -3,7 +3,8 @@ 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 } from "../src/errors.ts";
|
||||
import { AuthError, ValidationError } from "../src/errors.ts";
|
||||
import { matchWork } from "../src/matching.ts";
|
||||
import {
|
||||
adminMergeWorks,
|
||||
createWork,
|
||||
@@ -109,4 +110,87 @@ forEachDialect("works", (getDb) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user