Files
rootandClaude Opus 5.5 b61335eaf6 test: 测试按行为命名、server 测试统一到 test/、PG 测试分组并行
- server 的 src/modules/*/__tests__ 挪到 test/<模块>/;e2e 去掉 eN- 前缀,
  w5a2 / v2 / search2 / *-dh1 / *-md1 / *-md2、web 的 agui3 / search2、domain 的 id2 / id3 改为按行为命名
- vitest 分三组:unit、pg(真实 PG + 假引擎,文件并行,最多 6 个)、serial(真实引擎 / worker / e2e,串行);
  pg 组用例超时放宽到 60s(迁移类用例并行时排队等 schema 锁)。server 测试 615s → 约 350s
- CLAUDE.md 补测试约定;待办补 harness 去重与 continuous-series 偶发超时(疑似 Slot 评估竞争)
- ADR-004 补充:维持 Deno,记录实际只用到 --allow-run 白名单

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 16:54:04 +08:00

441 lines
16 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { DEFAULT_SETTINGS } from "@umt/contracts";
import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("one-time replacement (real PG and library files)", () => {
let h: Harness;
let lib: TempTree;
let src: TempTree;
let rootId: string;
let instanceId: string;
let siteId: string;
let qb: MockQb;
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true });
src = await createTempTree({
"Movie.2020.1080p.BluRay.x264-GRP.mkv": 200_000,
"Same.2020.1080p.BluRay.x264-GRP.mkv": 200_000,
...Object.fromEntries(
Array.from({ length: 6 }, (_, i) => [
`Show.S01E${String(i + 1).padStart(2, "0")}.1080p.WEB-DL.x264-GRP.mkv`,
200_000,
]),
),
});
qb = await createMockQb();
h = await createHarness("replacement");
({ rootId, instanceId, siteId } = await seedBase(h.db, {
libraryPath: lib.root,
cachePath: src.root,
qbUrl: qb.url,
}));
await h.setSettings("naming", {
tv: "{{title}} ({{year}})/Season {{season}}/{{title}} - {{season_episode}} - {{videoFormat}}{{fileExt}}",
});
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
await lib?.cleanup();
await src?.cleanup();
});
const old = async (entityId: string, path: string, parent?: string) => {
await mkdir(dirname(lib.path(path)), { recursive: true });
await writeFile(lib.path(path), Buffer.alloc(200_001, 7));
const asset = await h.db
.insertInto("media_asset")
.values({
media_entity_id: entityId,
kind: parent ? "subtitle" : "video",
parent_asset_id: parent ?? null,
status: "active",
size_bytes: 200_001,
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("asset_location")
.values({
media_asset_id: asset.id,
location_kind: "library",
library_root_id: rootId,
relative_path: path,
status: "available",
is_primary: true,
})
.execute();
return asset.id;
};
const request = (entityId: string, paths: string[], assetIds: string[] = []) => ({
mediaEntityId: entityId,
sourcePaths: paths.map((p) => src.path(p)),
trigger: "manual" as const,
actorType: "user" as const,
action: "copy" as const,
importMode: "replace" as const,
replacementAssetIds: assetIds,
});
const status = async (id: string) =>
(await h.db.selectFrom("media_asset").select("status").where("id", "=", id).executeTakeFirstOrThrow()).status;
it("new movie registers first, retires only selected old and its private subtitle, preserving unselected/shared/cache", async () => {
const e = await seedEntity(h.db, {
kind: "movie",
title: "Movie",
year: 2020,
});
const path = "Movie (2020)/Movie (2020) - 720p.mkv";
const selected = await old(e, path);
const subtitle = await old(e, path.replace(".mkv", ".en.srt"), selected);
const kept = await old(e, "Movie (2020)/Movie (2020) - 2160p.mkv");
await writeFile(lib.path("Movie (2020)/movie.nfo"), "shared");
const planned = await planImport(h.d, request(e, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [selected]));
expect(planned.status).toBe("running");
expect(existsSync(lib.path(path))).toBe(true);
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
expect(await status(selected)).toBe("retired");
expect(await status(subtitle)).toBe("retired");
expect(await status(kept)).toBe("active");
expect(existsSync(lib.path(path))).toBe(false);
expect(existsSync(lib.path("Movie (2020)/movie.nfo"))).toBe(true);
expect(existsSync(src.path("Movie.2020.1080p.BluRay.x264-GRP.mkv"))).toBe(true);
expect(await h.db.selectFrom("trash_item").selectAll().execute()).toHaveLength(2);
await executeRecord(h.d, planned.recordId!);
expect(await h.db.selectFrom("trash_item").selectAll().execute()).toHaveLength(2);
});
it("same-path replaces selected old asset with a fresh asset, but normal/unselected cannot overwrite", async () => {
const e = await seedEntity(h.db, {
kind: "movie",
title: "Same",
year: 2020,
});
const selected = await old(e, "Same (2020)/Same (2020) - 1080p.mkv");
const normal = await planImport(h.d, {
...request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"]),
importMode: "normal",
dryRun: true,
});
expect(normal.plan?.organize.files[0]?.action).toBe("hold");
const planned = await planImport(h.d, request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"], [selected]));
expect(planned.status).toBe("running");
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
expect(await status(selected)).toBe("retired");
const active = await h.db
.selectFrom("media_asset")
.select("id")
.where("media_entity_id", "=", e)
.where("status", "=", "active")
.execute();
expect(active).toHaveLength(1);
expect(active[0]!.id).not.toBe(selected);
});
it("selected grants cannot overwrite an unselected path, and changed old files survive cleanup", async () => {
const e = await seedEntity(h.db, {
kind: "movie",
title: "Protected",
year: 2020,
});
const lower = await old(e, "Protected (2020)/Protected (2020) - 720p.mkv");
const occupied = await old(e, "Protected (2020)/Protected (2020) - 1080p.mkv");
const blocked = await planImport(h.d, {
...request(e, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [lower]),
dryRun: true,
});
expect(blocked.plan?.organize.files[0]?.action).toBe("hold");
expect(await status(occupied)).toBe("active");
const other = await seedEntity(h.db, {
kind: "movie",
title: "Stale",
year: 2020,
});
const oldPath = "Stale (2020)/Stale (2020) - 720p.mkv";
const stale = await old(other, oldPath);
const planned = await planImport(h.d, request(other, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [stale]));
await writeFile(lib.path(oldPath), "changed-after-plan");
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
expect(await status(stale)).toBe("active");
expect((await readFile(lib.path(oldPath))).toString()).toBe("changed-after-plan");
});
it("same-path failed final probe restores old bytes and asset, even with delete policy", async () => {
const e = await seedEntity(h.db, {
kind: "movie",
title: "Same",
year: 2021,
});
const path = "Same (2021)/Same (2021) - 1080p.mkv";
const selected = await old(e, path);
const savedProbe = h.d.ports.probe;
const savedConfig = h.ctx.config;
h.ctx.config = { ...savedConfig, FS_REPLACE_OLD: "delete" };
h.d.ports.probe = async () => ({
status: "ok",
video: [],
audio: [],
subtitles: [],
});
try {
const planned = await planImport(h.d, request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"], [selected]));
expect(planned.status).toBe("running");
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("failed");
expect((await readFile(lib.path(path))).equals(Buffer.alloc(200_001, 7))).toBe(true);
expect(await status(selected)).toBe("active");
expect(
(
await h.db
.selectFrom("asset_location")
.select("status")
.where("media_asset_id", "=", selected)
.executeTakeFirstOrThrow()
).status,
).toBe("available");
await executeRecord(h.d, planned.recordId!);
expect((await readFile(lib.path(path))).length).toBe(200_001);
} finally {
h.d.ports.probe = savedProbe;
h.ctx.config = savedConfig;
}
});
it("old E01-E10 only loses successful new E01-E06; failed E04 and old merged range survive", async () => {
const e = await seedEntity(h.db, {
kind: "series",
title: "Show",
year: 2020,
});
const ids: string[] = [];
for (let i = 1; i <= 10; i++) {
ids.push(await old(e, `Show (2020)/Season 1/Show - S01E${String(i).padStart(2, "0")} - 720p.mkv`));
}
const merged = await old(e, "Show (2020)/Season 1/Show - S01E01-E10 - 480p.mkv");
const saved = h.d.ports.probe;
h.d.ports.probe = async (path) =>
path.includes("E04")
? { status: "ok", video: [], audio: [], subtitles: [] }
: { status: "unknown", reason: "ffprobe_unavailable" };
try {
const paths = Array.from(
{ length: 6 },
(_, i) => `Show.S01E${String(i + 1).padStart(2, "0")}.1080p.WEB-DL.x264-GRP.mkv`,
);
const planned = await planImport(h.d, request(e, paths, [...ids, merged]));
expect(planned.status).toBe("running");
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("partial");
for (let i = 1; i <= 10; i++) {
expect(await status(ids[i - 1]!)).toBe(i <= 6 && i !== 4 ? "retired" : "active");
}
expect(await status(merged)).toBe("active");
} finally {
h.d.ports.probe = saved;
}
});
it("unknown manual task resolves Local, classifies and registers in the same import, then explicit reused-hash replacement uses its new plan", async () => {
const name = "Unknown.2022.1080p.WEB-DL.x264-GRP.mkv";
const hash = "a".repeat(40);
qb.seed({ hash, name, tags: ["umt"], save_path: src.root });
await qb.complete(hash, {
writeFiles: true,
files: [{ path: name, size: 200_000 }],
});
const release = await h.db
.insertInto("source_release")
.values({
site_id: siteId,
site_release_id: randomUUID(),
title: name,
size_bytes: 200_000,
raw: JSON.stringify({}),
resolution_status: "unresolved",
})
.returning("id")
.executeTakeFirstOrThrow();
const makePlan = async (evaluation: object) =>
(
await h.db
.insertInto("acquisition_plan")
.values({
slot_id: null,
source_release_id: release.id,
status: "submitted",
payload_hash: randomUUID(),
idempotency_key: randomUUID(),
evaluation: JSON.stringify(evaluation),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
const initialPlan = await makePlan({
manual: true,
mediaEntityId: null,
importMode: "normal",
replacementAssetIds: [],
});
const dt = await h.db
.insertInto("download_task")
.values({
instance_id: instanceId,
torrent_hash: hash,
name,
save_path: src.root,
content_path: src.path(name),
managed: true,
tags: ["umt"],
status: "completed",
source_release_id: release.id,
acquisition_plan_id: initialPlan,
import_plan_id: initialPlan,
})
.returning("id")
.executeTakeFirstOrThrow();
const local = await seedEntity(h.db, {
kind: "movie",
title: "本地作品",
year: 2022,
});
await h.db
.updateTable("media_entity")
.set({
metadata: JSON.stringify({
title: {
value: "本地作品",
source: "web",
evidence: { sourceUrl: "https://example.test/detail" },
},
originalLanguage: { value: "zh", source: "web" },
genres: { value: ["剧情"], source: "web" },
}),
})
.where("id", "=", local)
.execute();
const sourceId = randomUUID();
await h.setSettings("organize", {
...DEFAULT_SETTINGS.organize,
minSizeBytes: 100_000,
scrapeAfterImport: false,
sources: [
{
id: sourceId,
name: "Local来源",
enabled: true,
source: { root: `dl:${instanceId}:0`, path: "" },
target: { root: `lib:${rootId}`, path: "" },
mediaType: "movie",
category: null,
action: "copy",
trigger: "manual",
downloaderInstanceId: null,
tag: null,
intervalSec: 300,
},
],
});
const has = vi.spyOn(h.ctx.capabilities, "has").mockReturnValue(true);
const invoke = vi.spyOn(h.ctx.capabilities, "invoke").mockResolvedValue({
mediaEntityId: local,
mode: "local",
enrichmentId: randomUUID(),
reason: null,
});
try {
const planned = await planImport(h.d, {
downloadTaskId: dt.id,
sourceConfigId: sourceId,
trigger: "auto",
actorType: "system",
});
expect(planned.status).toBe("running");
expect(
(
await h.db
.selectFrom("organization_record")
.select("plan")
.where("id", "=", planned.recordId!)
.executeTakeFirstOrThrow()
).plan,
).toMatchObject({ metadataMode: "local" });
expect(invoke).toHaveBeenCalledWith(
"metadata.resolve_organize",
{ sourceReleaseId: release.id },
expect.objectContaining({ source: "task" }),
);
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
expect(
await h.db
.selectFrom("media_asset")
.select("id")
.where("media_entity_id", "=", local)
.where("status", "=", "active")
.execute(),
).toHaveLength(1);
expect(
(
await h.db
.selectFrom("path_binding")
.select("relative_path")
.where("media_entity_id", "=", local)
.executeTakeFirstOrThrow()
).relative_path,
).toBe("华语电影/本地作品 (2022)");
expect(h.engine.enqueued.some((j) => j.task === "scrapeMedia")).toBe(true);
const selected = await old(local, "华语电影/本地作品 (2022)/本地作品 (2022) - 720p.mkv");
const secondPlan = await makePlan({
manual: true,
mediaEntityId: local,
importMode: "replace",
replacementAssetIds: [selected],
});
await h.db
.updateTable("download_task")
.set({ import_plan_id: secondPlan, status: "completed" })
.where("id", "=", dt.id)
.execute();
const rerun = await planImport(h.d, {
downloadTaskId: dt.id,
sourceConfigId: sourceId,
trigger: "auto",
actorType: "system",
});
expect(rerun.status).toBe("running");
expect(
(
await h.db
.selectFrom("organization_record")
.select("plan")
.where("id", "=", rerun.recordId!)
.executeTakeFirstOrThrow()
).plan,
).toMatchObject({ metadataMode: "local" });
expect(invoke).toHaveBeenCalledTimes(1);
await executeRecord(h.d, rerun.recordId!);
expect(await status(selected)).toBe("retired");
expect(
(
await h.db
.selectFrom("acquisition_plan")
.select("status")
.where("id", "=", secondPlan)
.executeTakeFirstOrThrow()
).status,
).toBe("completed");
expect(
(
await h.db
.selectFrom("download_task")
.select("acquisition_plan_id")
.where("id", "=", dt.id)
.executeTakeFirstOrThrow()
).acquisition_plan_id,
).toBe(initialPlan);
} finally {
has.mockRestore();
invoke.mockRestore();
}
});
});