Files

164 lines
6.4 KiB
TypeScript

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(process.execPath, [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<string, unknown> = {},
) =>
(
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",
]);
});
});