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,362 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { createTmdbClient } from "@umt/adapters";
|
||||
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createFmHarness,
|
||||
EXEC_KEY,
|
||||
type FmHarness,
|
||||
key,
|
||||
pgAvailable,
|
||||
READ_KEY,
|
||||
seedAsset,
|
||||
seedEntity,
|
||||
} from "./harness.ts";
|
||||
|
||||
// W9-H 文件管理 v2 后端缺口:G1 TMDB 候选建作品 / G2 最小体积 / G3 目标已存在策略 / G4 命名方式与来源 /
|
||||
// G5 整理记录提示 + 重新整理源 + 整理后刷新元数据 + 批量刮削 / G8 目录摘要 / G9 推荐名。
|
||||
|
||||
const skip = !(await pgAvailable());
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读响应 JSON
|
||||
type Loose = any;
|
||||
|
||||
describe.skipIf(skip)("files v2(W9-H)", () => {
|
||||
let h: FmHarness;
|
||||
let tmdb: MockTmdb;
|
||||
let film: string;
|
||||
const big = "x".repeat(64);
|
||||
|
||||
beforeAll(async () => {
|
||||
tmdb = await createMockTmdb();
|
||||
h = await createFmHarness("v2", {
|
||||
tmdb: () => createTmdbClient({ apiKey: "test-tmdb-key", baseUrl: `${tmdb.url}/3`, retries: 0 }),
|
||||
});
|
||||
film = await seedEntity(h.db, { kind: "movie", title: "Blue Film", year: 2021 });
|
||||
await writeFile(join(h.dl, "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"), big);
|
||||
await writeFile(join(h.dl, "Blue.Film.2021.sample.tiny.mkv"), "tiny");
|
||||
await mkdir(join(h.dl, "The.Bear.S01.1080p"), { recursive: true });
|
||||
await writeFile(join(h.dl, "The.Bear.S01.1080p", "The.Bear.S01E01.1080p.WEB-DL-GRP.mkv"), big);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
await tmdb?.close();
|
||||
});
|
||||
|
||||
const organize = (body: Record<string, unknown>, k = EXEC_KEY) =>
|
||||
h.api("POST", "/files/organize", { idempotencyKey: key(), action: "copy", dryRun: true, ...body }, k);
|
||||
|
||||
it("G1:按 TMDB 候选建作品 + 外部映射(accepted/manual)+ 季集单元;同 TMDB id 幂等", async () => {
|
||||
const r = await h.api(
|
||||
"POST",
|
||||
"/files/entity-from-candidate",
|
||||
{ provider: "tmdb", externalId: "136315", kind: "tv" },
|
||||
EXEC_KEY,
|
||||
);
|
||||
expect(r.status).toBe(200);
|
||||
const d = r.body.data;
|
||||
expect(d).toMatchObject({ created: true, kind: "series", year: 2022 });
|
||||
expect(d.contentUnits).toBe(31); // 3 季 + 8/10/10 集
|
||||
const ident = await h.db
|
||||
.selectFrom("external_identity")
|
||||
.select(["provider", "external_id", "status", "evidence_level", "media_entity_id"])
|
||||
.where("media_entity_id", "=", d.mediaEntityId)
|
||||
.where("provider", "=", "tmdb")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(ident).toMatchObject({ external_id: "136315", status: "accepted", evidence_level: "manual" });
|
||||
const units = await h.db
|
||||
.selectFrom("content_unit")
|
||||
.select(["kind"])
|
||||
.where("media_entity_id", "=", d.mediaEntityId)
|
||||
.execute();
|
||||
expect(units.filter((u) => u.kind === "season")).toHaveLength(3);
|
||||
expect(units.filter((u) => u.kind === "episode")).toHaveLength(28);
|
||||
// INT-3:建档即入队 full 刷新(每集信息、富字段)
|
||||
expect(
|
||||
h.engine.enqueued.filter(
|
||||
(e) =>
|
||||
e.task === "refreshMetadata" &&
|
||||
(e.input as Loose).mediaEntityId === d.mediaEntityId &&
|
||||
(e.input as Loose).full === true,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
const again = await h.api(
|
||||
"POST",
|
||||
"/files/entity-from-candidate",
|
||||
{ provider: "tmdb", externalId: "136315", kind: "tv" },
|
||||
EXEC_KEY,
|
||||
);
|
||||
expect(again.body.data).toMatchObject({ mediaEntityId: d.mediaEntityId, created: false });
|
||||
const count = await h.db
|
||||
.selectFrom("external_identity")
|
||||
.select(["id"])
|
||||
.where("provider", "=", "tmdb")
|
||||
.where("external_id", "=", "136315")
|
||||
.execute();
|
||||
expect(count).toHaveLength(1);
|
||||
// 只读 key 不能建
|
||||
const ro = await h.api(
|
||||
"POST",
|
||||
"/files/entity-from-candidate",
|
||||
{ provider: "tmdb", externalId: "1", kind: "movie" },
|
||||
READ_KEY,
|
||||
);
|
||||
expect(ro.status).toBe(403);
|
||||
// TMDB 无此条目 → 404
|
||||
const nf = await h.api("POST", "/files/entity-from-candidate", {
|
||||
provider: "tmdb",
|
||||
externalId: "424242",
|
||||
kind: "movie",
|
||||
});
|
||||
expect(nf.status).toBe(404);
|
||||
});
|
||||
|
||||
it("G1:整理项带 external → 预览就地建(或复用)作品,组标 entity;与 mediaEntityId 二选一", async () => {
|
||||
const r = await organize({
|
||||
items: [
|
||||
{
|
||||
root: h.dlRoot,
|
||||
path: "The.Bear.S01.1080p/The.Bear.S01E01.1080p.WEB-DL-GRP.mkv",
|
||||
external: { provider: "tmdb", externalId: "136315", kind: "tv" },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
const g = r.body.data.groups[0];
|
||||
expect(g.error).toBeNull();
|
||||
expect(g.entity).toMatchObject({ provider: "tmdb", externalId: "136315", created: false });
|
||||
expect(g.files[0].target).toMatch(/Season 1/);
|
||||
|
||||
const movie = await organize({
|
||||
items: [
|
||||
{
|
||||
root: h.dlRoot,
|
||||
path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv",
|
||||
external: { provider: "tmdb", externalId: "872585", kind: "movie" },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(movie.body.data.groups[0].entity.created).toBe(true);
|
||||
|
||||
const both = await organize({
|
||||
items: [
|
||||
{
|
||||
root: h.dlRoot,
|
||||
path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv",
|
||||
mediaEntityId: film,
|
||||
external: { provider: "tmdb", externalId: "872585", kind: "movie" },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(both.status).toBe(400);
|
||||
const none = await organize({ items: [{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" }] });
|
||||
expect(none.status).toBe(400);
|
||||
});
|
||||
|
||||
it("G2:整理按最小体积排除(below_min_size);识别按最小体积跳过并计数", async () => {
|
||||
const items = [
|
||||
{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv", mediaEntityId: film },
|
||||
{ root: h.dlRoot, path: "Blue.Film.2021.sample.tiny.mkv", mediaEntityId: film },
|
||||
];
|
||||
const r = await organize({ items, minSizeBytes: 32 });
|
||||
const g = r.body.data.groups[0];
|
||||
expect(g.minSizeBytes).toBe(32);
|
||||
expect(g.files.map((f: Loose) => basename(f.source))).toEqual(["Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"]);
|
||||
expect(g.excluded).toContainEqual(
|
||||
expect.objectContaining({ source: join(h.dl, "Blue.Film.2021.sample.tiny.mkv"), reason: expect.any(String) }),
|
||||
);
|
||||
// 非 sample 命名的小文件:明确 below_min_size + 体积
|
||||
await writeFile(join(h.dl, "Blue.Film.2021.1080p.small.mkv"), "0123456789ab");
|
||||
const r2 = await organize({
|
||||
items: [{ root: h.dlRoot, path: "Blue.Film.2021.1080p.small.mkv", mediaEntityId: film }],
|
||||
minSizeBytes: 32,
|
||||
});
|
||||
expect(r2.body.data.groups[0].excluded).toEqual([
|
||||
{ source: join(h.dl, "Blue.Film.2021.1080p.small.mkv"), kind: "video", size: 12, reason: "below_min_size" },
|
||||
]);
|
||||
|
||||
const id = await h.api(
|
||||
"POST",
|
||||
"/files/identify",
|
||||
{
|
||||
items: [
|
||||
{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" },
|
||||
{ root: h.dlRoot, path: "Blue.Film.2021.1080p.small.mkv" },
|
||||
],
|
||||
minSizeBytes: 32,
|
||||
},
|
||||
READ_KEY,
|
||||
);
|
||||
expect(id.status).toBe(200);
|
||||
expect(id.body.data.excludedBySize).toBe(1);
|
||||
const [a, b] = id.body.data.items;
|
||||
expect(a).toMatchObject({ size: 64, skipped: null });
|
||||
expect(b).toMatchObject({ size: 12, skipped: "below_min_size", candidates: [] });
|
||||
});
|
||||
|
||||
it("G3:目标已存在策略 skip / conflict / replace_with_verify 逐文件标注", async () => {
|
||||
const other = await seedEntity(h.db, { kind: "movie", title: "Clash Film", year: 2020 });
|
||||
await writeFile(join(h.dl, "Clash.Film.2020.1080p.WEB-DL-GRP.mkv"), big);
|
||||
const items = [{ root: h.dlRoot, path: "Clash.Film.2020.1080p.WEB-DL-GRP.mkv", mediaEntityId: other }];
|
||||
const first = await organize({ items });
|
||||
const target = first.body.data.groups[0].files[0].target as string;
|
||||
expect(first.body.data.groups[0].files[0].existing).toBeNull();
|
||||
expect(first.body.data.groups[0].onExisting).toBe("conflict");
|
||||
await mkdir(join(target, ".."), { recursive: true });
|
||||
await writeFile(target, "different-content-of-another-size");
|
||||
|
||||
const conflict = await organize({ items });
|
||||
const gc = conflict.body.data.groups[0];
|
||||
expect(gc.files[0]).toMatchObject({ existing: "conflict", action: "hold" });
|
||||
expect(gc.error.code).toBe("plan_blocked");
|
||||
|
||||
const skipped = await organize({ items, onExisting: "skip" });
|
||||
const gs = skipped.body.data.groups[0];
|
||||
expect(gs.files[0]).toMatchObject({ existing: "skip", action: "skip", skipReason: "target_exists" });
|
||||
expect(gs.error).toBeNull();
|
||||
expect(gs.onExisting).toBe("skip");
|
||||
|
||||
const replace = await organize({ items, onExisting: "replace_with_verify" });
|
||||
const gr = replace.body.data.groups[0];
|
||||
expect(gr.files[0]).toMatchObject({ existing: "replace", action: "replace" });
|
||||
expect(gr.error).toBeNull();
|
||||
|
||||
// 策略进请求指纹:skip 的 planId 不能拿去执行 replace
|
||||
const exec = await organize({ items, onExisting: "replace_with_verify", dryRun: false, planIds: [gs.planId] });
|
||||
expect(exec.body.data.groups[0].error.code).toBe("plan_stale");
|
||||
});
|
||||
|
||||
it("G4:命名方式 keep 保留原文件名(来源 override);template 记录模板来源/版本;执行后冻结进整理记录", async () => {
|
||||
const items = [{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv", mediaEntityId: film }];
|
||||
const tpl = await organize({ items });
|
||||
const gt = tpl.body.data.groups[0];
|
||||
expect(gt.naming).toMatchObject({ mode: "template", templateSource: "global" });
|
||||
expect(typeof gt.naming.templateVersion).toBe("number");
|
||||
expect(gt.targetSegments).toEqual([{ text: "Blue Film (2021)", role: "root", bound: false }]);
|
||||
|
||||
const keep = await organize({ items, naming: "keep" });
|
||||
const gk = keep.body.data.groups[0];
|
||||
expect(gk.naming).toMatchObject({ mode: "keep", templateSource: "override" });
|
||||
expect(basename(gk.files[0].target)).toBe("Blue.Film.2021.2160p.BluRay.x265-GRP.mkv");
|
||||
expect(gk.files[0].target).toContain("Blue Film (2021)");
|
||||
|
||||
const exec = await organize({ items, naming: "keep", dryRun: false, planIds: [gk.planId], refreshMetadata: true });
|
||||
expect(exec.status).toBe(200);
|
||||
const g = exec.body.data.groups[0];
|
||||
expect(g.error).toBeNull();
|
||||
const rec = await h.db
|
||||
.selectFrom("organization_record")
|
||||
.select(["plan"])
|
||||
.where("id", "=", g.recordId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect((rec.plan as Loose).naming).toMatchObject({ mode: "keep", templateSource: "override" });
|
||||
// G5:整理后刷新元数据
|
||||
expect(g.refreshTaskId).toBeTruthy();
|
||||
expect(
|
||||
h.engine.enqueued.some((e) => e.task === "refreshMetadata" && (e.input as Loose).mediaEntityId === film),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("G5:识别标已有整理记录;整理记录 → 源 FileRef(重新整理)", async () => {
|
||||
const id = await h.api(
|
||||
"POST",
|
||||
"/files/identify",
|
||||
{ items: [{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" }] },
|
||||
READ_KEY,
|
||||
);
|
||||
const recs = id.body.data.items[0].records;
|
||||
expect(recs.count).toBeGreaterThanOrEqual(1);
|
||||
expect(recs.lastRecordId).toBeTruthy();
|
||||
|
||||
const src = await h.api("GET", `/files/record-sources?recordId=${recs.lastRecordId}`, undefined, READ_KEY);
|
||||
expect(src.status).toBe(200);
|
||||
expect(src.body.data.items).toEqual([{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" }]);
|
||||
expect(src.body.data.unresolved).toEqual([]);
|
||||
expect(src.body.data.mediaEntityId).toBe(film);
|
||||
});
|
||||
|
||||
it("G5:批量刷新元数据按作品去重(不要求有已入库文件)", async () => {
|
||||
const reg = await seedEntity(h.db, { kind: "movie", title: "Registered Film", year: 2018 });
|
||||
await mkdir(join(h.lib, "Registered Film (2018)"), { recursive: true });
|
||||
await writeFile(join(h.lib, "Registered Film (2018)", "a.mkv"), big);
|
||||
await writeFile(join(h.lib, "Registered Film (2018)", "b.mkv"), big);
|
||||
await seedAsset(h.db, h, { entityId: reg, rel: "Registered Film (2018)/a.mkv" });
|
||||
await seedAsset(h.db, h, { entityId: reg, rel: "Registered Film (2018)/b.mkv" });
|
||||
const bare = await seedEntity(h.db, { kind: "movie", title: "Bare Film", year: 2017 });
|
||||
const r = await h.api(
|
||||
"POST",
|
||||
"/files/scrape-batch",
|
||||
{
|
||||
idempotencyKey: key(),
|
||||
items: [
|
||||
{ root: h.libRoot, path: "Registered Film (2018)/a.mkv" },
|
||||
{ root: h.libRoot, path: "Registered Film (2018)/b.mkv" },
|
||||
],
|
||||
mediaEntityIds: [reg, bare],
|
||||
},
|
||||
EXEC_KEY,
|
||||
);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.submitted.map((s: Loose) => s.mediaEntityId)).toEqual([reg, bare]);
|
||||
expect(r.body.data.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it("G8:目录摘要 — 上一级、子目录是否有下级、视频/已登记计数", async () => {
|
||||
await mkdir(join(h.lib, "Tree", "A", "Deep"), { recursive: true });
|
||||
await mkdir(join(h.lib, "Tree", "B"), { recursive: true });
|
||||
await writeFile(join(h.lib, "Tree", "A", "x.mkv"), big);
|
||||
const r = await h.api("GET", `/files/tree?root=${h.libRoot}&path=Tree`, undefined, READ_KEY);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data).toMatchObject({
|
||||
path: "Tree",
|
||||
name: "Tree",
|
||||
parent: "",
|
||||
dirs: [
|
||||
{ name: "A", path: "Tree/A", hasChildren: true },
|
||||
{ name: "B", path: "Tree/B", hasChildren: false },
|
||||
],
|
||||
summary: { videoCount: 1, registeredCount: 0, partial: false },
|
||||
});
|
||||
const top = await h.api("GET", `/files/tree?root=${h.libRoot}&path=`, undefined, READ_KEY);
|
||||
expect(top.body.data.parent).toBeNull();
|
||||
expect(top.body.data.summary.registeredCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("G9:推荐名按命名模板渲染(文件名 / 作品目录);识别不可信 → null + 原因", async () => {
|
||||
const f = await h.api(
|
||||
"POST",
|
||||
"/files/suggest-name",
|
||||
{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv" },
|
||||
READ_KEY,
|
||||
);
|
||||
expect(f.status).toBe(200);
|
||||
expect(f.body.data).toMatchObject({
|
||||
mediaEntityId: film,
|
||||
templateSource: "global",
|
||||
reason: null,
|
||||
name: "Blue Film (2021) - 2160p.mkv",
|
||||
relativePath: "Blue Film (2021)/Blue Film (2021) - 2160p.mkv",
|
||||
});
|
||||
expect(f.body.data.segments.map((s: Loose) => s.role)).toEqual(["root", "file"]);
|
||||
|
||||
await mkdir(join(h.dl, "Blue.Film.2021.Pack"), { recursive: true });
|
||||
const dir = await h.api(
|
||||
"POST",
|
||||
"/files/suggest-name",
|
||||
{ root: h.dlRoot, path: "Blue.Film.2021.Pack", mediaEntityId: film },
|
||||
READ_KEY,
|
||||
);
|
||||
expect(dir.body.data.name).toBe("Blue Film (2021)");
|
||||
|
||||
await writeFile(join(h.dl, "Nobody.Knows.This.2011.mkv"), big);
|
||||
const unknown = await h.api(
|
||||
"POST",
|
||||
"/files/suggest-name",
|
||||
{ root: h.dlRoot, path: "Nobody.Knows.This.2011.mkv" },
|
||||
READ_KEY,
|
||||
);
|
||||
expect(unknown.body.data).toMatchObject({ name: null, reason: "identity_uncertain" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user