Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
113 lines
5.6 KiB
TypeScript
113 lines
5.6 KiB
TypeScript
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 } from "../src/errors.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);
|
|
});
|
|
});
|