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]>
This commit is contained in:
1 parent
b343dddbf6
commit
b61335eaf6
75 files changed
+159
-115
No files matched your search
@@ -0,0 +1,440 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user