164 lines
6.4 KiB
TypeScript
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",
|
|
]);
|
|
});
|
|
});
|