- 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]>
441 lines
16 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|