Files
UHD_Media_Tool/apps/server/test/e2e/plan-and-inventory-endpoints.test.ts
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

234 lines
10 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
completeDownload,
createE2E,
type E2E,
pgAvailable,
seedBase,
seedMovieTarget,
sql,
waitFor,
} from "./harness.ts";
// E10(原编号与 e6-auto-recover 重复,W6-B 改名) W4-A 前后端集成端点:独立手动下载与自动订阅下载分别验证、候选评估去重、库存叠加补字段、
// 整理记录 downloadTaskId、只读资产/产物端点、偏好标签修订历史、列表带作品标题、事件 JSON 补读。
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("E10 W4-A 集成端点", () => {
let e: E2E;
let target: Awaited<ReturnType<typeof seedMovieTarget>>;
beforeAll(async () => {
e = await createE2E("e10");
const base = await seedBase(e);
target = await seedMovieTarget(e, base.siteId);
}, 120_000);
afterAll(async () => {
await e?.close();
}, 60_000);
it("download.plan:自动订阅硬条件不满足 → 无计划并给出原因;重复评估只留一条候选记录", async () => {
for (let i = 0; i < 2; i++) {
const r = await e.api("POST", "/capabilities/download.plan/invoke", {
input: { sourceReleaseId: target.r720, slotId: target.slotId },
});
expect(r.status).toBe(200);
const out = r.body.data.output ?? r.body.data;
expect(out.plan).toBeNull();
expect(out.slot.id).toBe(target.slotId);
expect(out.rejected.length).toBeGreaterThan(0);
expect(await e.db.selectFrom("download_task").select("id").execute()).toHaveLength(0);
}
const n = await sql<{ n: number }>`select count(*)::int as n from artifact
where title = 'candidate_evaluation' and subject_id = ${target.slotId}
and content->>'sourceReleaseId' = ${target.r720}`.execute(e.db);
expect(n.rows[0]?.n).toBe(1);
});
it("GET /inventory:subscribed 与 candidateReasons", async () => {
const r = await e.api("GET", `/inventory?entityIds=${target.entityId}`);
expect(r.status).toBe(200);
const entry = r.body.data.items[0];
expect(entry.subscribed).toBe(true);
const c = entry.candidateReasons.find((x: { sourceReleaseId: string }) => x.sourceReleaseId === target.r720);
expect(c).toMatchObject({ slotId: target.slotId, slotName: "1080p", decision: "reject" });
expect(c.reasons.length).toBeGreaterThan(0);
});
it("自动订阅 download.plan → download.submit 推进 Slot,qB 收到种子;整理记录带 downloadTaskId", async () => {
const planned = await e.api("POST", "/capabilities/download.plan/invoke", {
input: { sourceReleaseId: target.r1080, slotId: target.slotId },
});
expect(planned.status).toBe(200);
const out = planned.body.data.output ?? planned.body.data;
expect(out.plan).toMatchObject({ slotId: target.slotId, sourceReleaseId: target.r1080 });
const grant = await e.api("POST", "/automation-grants", {
action: "download.submit",
scope: { mediaEntityIds: [target.entityId] },
reason: "e10 自动订阅收录验证",
});
expect(grant.status).toBe(201);
const submitted = await e.api("POST", "/capabilities/download.submit/invoke", { input: { planId: out.plan.id } });
expect(submitted.status).toBe(200);
const accepted = submitted.body.data.output ?? submitted.body.data;
expect(accepted.planId).toBe(out.plan.id);
expect(accepted.taskId).toBeTruthy();
const audits = await e.db
.selectFrom("audit_event")
.select(["action", "target_id"])
.where("action", "=", "capability.invoke")
.execute();
expect(audits.filter((a) => a.target_id === "download.submit")).toHaveLength(1);
await completeDownload(e, "Dune.Part.Two.2024.1080p.BluRay.x264-GRP");
const rec = await waitFor(
async () => {
const l = await e.api("GET", "/organization-records");
return l.body.data.items.find((x: { status: string }) => x.status === "succeeded");
},
60_000,
"整理记录成功",
);
const dt = await e.db.selectFrom("download_task").select("id").executeTakeFirstOrThrow();
expect(rec.downloadTaskId).toBe(dt.id);
const filtered = await e.api("GET", `/organization-records?downloadTaskId=${dt.id}`);
expect(filtered.body.data.items.map((x: { id: string }) => x.id)).toEqual([rec.id]);
}, 120_000);
it("只读资产端点与 Slot 当前资产摘要", async () => {
const assets = await e.api("GET", `/media-assets?mediaEntityId=${target.entityId}`);
expect(assets.status).toBe(200);
expect(assets.body.data.items.length).toBeGreaterThan(0);
const a = assets.body.data.items[0];
expect((await e.api("GET", `/media-assets/${a.id}`)).body.data.id).toBe(a.id);
const locs = await e.api("GET", `/asset-locations?mediaAssetId=${a.id}`);
expect(locs.body.data.items.length).toBeGreaterThan(0);
expect((await e.api("GET", `/path-bindings?mediaEntityId=${target.entityId}`)).body.data.items.length).toBe(1);
expect((await e.api("GET", "/media-assets/00000000-0000-4000-8000-000000000000")).status).toBe(404);
expect((await e.api("PATCH", `/media-assets/${a.id}`, { status: "missing" })).status).toBe(404);
const sub = await e.db.selectFrom("subscription").select("id").executeTakeFirstOrThrow();
const slots = await e.api("GET", `/acquisition-slots?subscriptionId=${sub.id}`);
expect(slots.body.data.items).toHaveLength(1);
expect(slots.body.data.items[0].currentAsset).toMatchObject({ resolution: "1080p" });
});
it("POST /acquisition-plans:没有订阅和 Slot 的作品也能独立手动下载", async () => {
const standalone = await e.db
.insertInto("media_entity")
.values({ kind: "movie", title: "独立手动电影", year: 2025, status: "active" })
.returning("id")
.executeTakeFirstOrThrow();
const site = await e.db
.selectFrom("source_release")
.select("site_id")
.where("id", "=", target.r720)
.executeTakeFirstOrThrow();
const release = await e.db
.insertInto("source_release")
.values({
site_id: site.site_id,
site_release_id: "880003",
title: "Manual.Standalone.2025.720p.WEB-DL.x264-GRP",
size_bytes: 300000,
media_entity_id: standalone.id,
resolution_status: "resolved",
raw: JSON.stringify({ _site: { hr: false } }),
})
.returning("id")
.executeTakeFirstOrThrow();
const before = await e.db.selectFrom("subscription").select("id").execute();
const slotBefore = await e.db
.selectFrom("acquisition_slot")
.select(["current_asset_id", "revision"])
.where("id", "=", target.slotId)
.executeTakeFirstOrThrow();
const r = await e.api("POST", "/acquisition-plans", {
sourceReleaseId: release.id,
entityId: standalone.id,
note: "工作台独立手动",
});
expect(r.status).toBe(200);
expect(r.body.data).toMatchObject({
submitted: true,
slotId: null,
rejected: [],
plan: { slotId: null, evaluation: { manual: true, mediaEntityId: standalone.id } },
});
expect(r.body.data.taskId).toBeTruthy();
await waitFor(
async () =>
e.db
.selectFrom("download_task")
.selectAll()
.where("import_plan_id", "=", r.body.data.plan.id)
.executeTakeFirst(),
60000,
"独立手动下载已提交",
);
expect(await e.db.selectFrom("subscription").select("id").execute()).toEqual(before);
expect(
await e.db.selectFrom("acquisition_slot").select("id").where("media_entity_id", "=", standalone.id).execute(),
).toHaveLength(0);
expect(
await e.db
.selectFrom("acquisition_slot")
.select(["current_asset_id", "revision"])
.where("id", "=", target.slotId)
.executeTakeFirstOrThrow(),
).toEqual(slotBefore);
expect(
await e.db
.selectFrom("audit_event")
.select("id")
.where("action", "=", "acquisition_plan.manual_submit")
.where("target_id", "=", r.body.data.plan.id)
.executeTakeFirst(),
).toBeTruthy();
}, 90000);
it("GET /artifacts:列表与详情", async () => {
const list = await e.api("GET", "/artifacts?kind=evidence");
expect(list.status).toBe(200);
expect(list.body.data.items.length).toBeGreaterThan(0);
const id = list.body.data.items[0].id;
const one = await e.api("GET", `/artifacts/${id}`);
expect(one.body.data).toMatchObject({ id, kind: "evidence" });
});
it("列表带作品标题;订阅按标题 / 类型搜索", async () => {
const subs = await e.api("GET", "/subscriptions?q=Dune&mediaKind=movie");
expect(subs.body.data.items).toHaveLength(1);
expect(subs.body.data.items[0].mediaEntityTitle).toBe("沙丘2");
expect((await e.api("GET", "/subscriptions?mediaKind=series")).body.data.items).toHaveLength(0);
await e.api("POST", "/title-aliases", { mediaEntityId: target.entityId, alias: "沙丘:第二部", source: "user" });
const aliases = await e.api("GET", `/title-aliases?mediaEntityId=${target.entityId}`);
expect(aliases.body.data.items[0].mediaEntityTitle).toBe("沙丘2");
const one = await e.api("GET", `/title-aliases/${aliases.body.data.items[0].id}`);
expect(one.status).toBe(200);
});
it("偏好标签修订历史:创建 / 修改 / 删除各一条(新→旧)", async () => {
const c = await e.api("POST", "/preference-tags", { label: "偏好 FLAC", kind: "soft", rule: { audio: "flac" } });
expect(c.status).toBe(201);
const id = c.body.data.id;
await e.api("PATCH", `/preference-tags/${id}`, { label: "偏好无损音轨" });
await e.api("DELETE", `/preference-tags/${id}`);
const r = await e.api("GET", `/preference-tags/${id}/revisions`);
expect(r.status).toBe(200);
expect(r.body.data.items.map((x: { action: string }) => x.action)).toEqual(["deleted", "updated", "created"]);
expect(r.body.data.items[1]).toMatchObject({ changedFields: ["label"], revision: 2 });
expect(r.body.data.items[1].snapshot.label).toBe("偏好无损音轨");
});
it("GET /events?after=&limit= 返回 JSON 补读页", async () => {
e.bus.publishSync("test.ping", { n: 1 });
const r = await e.api("GET", "/events?after=0&limit=5");
expect(r.status).toBe(200);
expect(r.body.data.items.length).toBeGreaterThan(0);
expect(r.body.data.nextAfter).toBe(r.body.data.items.at(-1).seq);
});
});