import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { sql } from "kysely"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createHarness, type Harness, PG_URL, pgAvailable } from "./harness.ts"; // FIX-6 scripts/identity-cleanup.ts:孤立单元只删资源解析建出的临时单元,TMDB / 元数据来源的季集账本单元不删。 const up = await pgAvailable(); const ROOT = fileURLToPath(new URL("../../../../", import.meta.url)); const SCRIPT = fileURLToPath(new URL("../../../../scripts/identity-cleanup.ts", import.meta.url)); describe.skipIf(!up)("identity-cleanup 孤立单元判定", () => { let h: Harness; let url: string; let tmdbShow: string; let localShow: string; let mappedNoLayout: string; const run = (...args: string[]) => execFileSync(Deno.execPath(), ["run", "-A", SCRIPT, ...args], { cwd: ROOT, env: { ...process.env, DATABASE_URL: url, NO_COLOR: "1" }, encoding: "utf8", }); const unit = async ( entityId: string, kind: "season" | "episode", season: number, episode: number | null, extra: Record = {}, ) => ( await h.db .insertInto("content_unit") .values({ media_entity_id: entityId, kind, season_number: season, episode_number: episode, status: "provisional", ...extra, } as never) .returning("id") .executeTakeFirstOrThrow() ).id; const entity = async (title: string, tmdbId: string | null, seasonCount: number | null) => { const id = ( await h.db .insertInto("media_entity") .values({ kind: "series", title, status: "active", metadata: JSON.stringify( seasonCount === null ? {} : { seasonCount: { value: seasonCount, source: "tmdb", locked: false } }, ), } as never) .returning("id") .executeTakeFirstOrThrow() ).id; if (tmdbId) await h.db .insertInto("external_identity") .values({ media_entity_id: id, provider: "tmdb", entity_type: "tv", external_id: tmdbId, status: "accepted", } as never) .execute(); return id; }; const labels = async (entityId: string) => ( await h.db .selectFrom("content_unit") .select(["kind", "season_number", "episode_number"]) .where("media_entity_id", "=", entityId) .execute() ) .map((u) => (u.kind === "season" ? `S${u.season_number}` : `S${u.season_number}E${u.episode_number}`)) .sort(); beforeAll(async () => { h = await createHarness("idcleanup"); const schema = (await sql<{ s: string }>`select current_schema() as s`.execute(h.db)).rows[0]!.s; const u = new URL(PG_URL); u.searchParams.set("options", `-c search_path=${schema},public`); url = u.toString(); // TMDB 映射的剧(季数 1):S01 带 TMDB 总集数 3,集单元都还是 provisional、没有字段来源(旧版本建的账本单元) tmdbShow = await entity("人生切割术", "95396", 1); const s1 = await unit(tmdbShow, "season", 1, null, { episode_total: 3, episode_total_source: "tmdb" }); for (const e of [1, 2, 3]) await unit(tmdbShow, "episode", 1, e, { parent_unit_id: s1 }); // 带 TMDB 字段来源的集 const e4 = await unit(tmdbShow, "episode", 1, 4, { parent_unit_id: s1, status: "confirmed" }); await h.db .insertInto("metadata_field") .values({ content_unit_id: e4, field: "name", value: JSON.stringify("第 4 集"), source: "tmdb" } as never) .execute(); // 特别篇季(季号 0)在外部季集结构内 await unit(tmdbShow, "season", 0, null); // 错挂资源建出的:超出 TMDB 季数 / 超出该季总集数 const s5 = await unit(tmdbShow, "season", 5, null); await unit(tmdbShow, "episode", 5, 1, { parent_unit_id: s5 }); await unit(tmdbShow, "episode", 1, 9, { parent_unit_id: s1 }); // 没有外部映射的作品:临时孤立单元删除;带元数据标题的保留(连同季) localShow = await entity("东北有个周东北", null, null); const ls1 = await unit(localShow, "season", 1, null); await unit(localShow, "episode", 1, 1, { parent_unit_id: ls1 }); await unit(localShow, "episode", 1, 2, { parent_unit_id: ls1, title: "手工命名" }); const ls2 = await unit(localShow, "season", 2, null); await unit(localShow, "episode", 2, 1, { parent_unit_id: ls2 }); // 有外部映射但季数未知:整部作品都算在外部结构内,不删 mappedNoLayout = await entity("沙丘:预言", "90228", null); const m0 = await unit(mappedNoLayout, "season", 0, null); for (let e = 1; e <= 3; e++) await unit(mappedNoLayout, "episode", 0, e, { parent_unit_id: m0 }); }); afterAll(async () => { await h?.close(); }); it("--dry-run 按类别列出删除 / 保留,不改库", async () => { const before = await labels(tmdbShow); const out = run("--dry-run"); expect(out).toContain("DRY-RUN"); expect(out).toContain("## 孤立的临时内容单元:删除 6 条"); expect(out).toContain("删除·作品没有外部映射:3 条\n - 《东北有个周东北》:S02 S01E01 S02E01"); expect(out).toContain("删除·超出外部季集结构:3 条\n - 《人生切割术》:S05 S01E09 S05E01"); expect(out).toContain("保留·在作品的外部季集结构内"); expect(out).toContain("保留·TMDB / 元数据 / 人工来源"); expect(await labels(tmdbShow)).toEqual(before); expect(() => run("--dry-run", "--apply")).toThrow(); }); it("--apply:TMDB 季集账本单元不删,临时孤立单元删除", async () => { run("--apply"); expect(await labels(tmdbShow)).toEqual(["S0", "S1", "S1E1", "S1E2", "S1E3", "S1E4"]); expect(await labels(localShow)).toEqual(["S1", "S1E2"]); expect(await labels(mappedNoLayout)).toEqual(["S0", "S0E1", "S0E2", "S0E3"]); const audit = await h.db .selectFrom("audit_event") .select("payload") .where("action", "=", "identity.cleanup") .executeTakeFirstOrThrow(); const orphans = (audit.payload as { orphanUnits: { reason: string }[] }).orphanUnits; expect(orphans.map((o) => o.reason).sort()).toEqual([ "no_external", "no_external", "no_external", "outside_external", "outside_external", "outside_external", ]); }); });