Files
UHD_Media_Tool/apps/server/test/acquisition-organize/download-board-plans.test.ts
T
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

315 lines
13 KiB
TypeScript

import { sql } from "@umt/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createHarness, type Harness, pgAvailable, seedEntity, seedRelease, seedSlot } from "./harness.ts";
// DL-3 下载计划并入下载面板:等批准 / 等磁盘空间的计划作为「进行中」行与下载任务按时间一起排、计入计数;
// 深链按计划 id 取行;「为什么下这个」;取消还没提交的下载;没提交就结束的进作品下载历史;
// 审批被拒 / 过期时草案跟着结束。
const hasPg = await pgAvailable();
const h40 = (n: number) => n.toString(16).padStart(40, "c");
type Row = { kind: "task" | "plan"; id: string; name: string; wait?: string; waitReason?: string | null };
describe.skipIf(!hasPg)("DL-3 下载计划并入下载面板(真实 PG)", () => {
let h: Harness;
let entity: string;
let siteId: string;
const ids = {} as Record<"reserved" | "draft" | "orphan" | "submitted" | "failed" | "agentDraft", string>;
const slots = {} as Record<string, string>;
let taskId: string;
let instId: string;
const at = (min: number) => new Date(Date.UTC(2026, 8, 30, 8, min));
async function plan(
key: keyof typeof ids,
p: { status: string; min: number; purpose?: string; evaluation?: Record<string, unknown>; reason?: string },
) {
const slot = await seedSlot(h.db, { entityId: entity, name: key === "submitted" ? "4K" : `slot-${key}` });
slots[key] = slot;
const rel = await seedRelease(h.db, {
siteId,
entityId: entity,
title: `Severance.S02.${key}.2160p`,
size: 10_000_000_000,
hash: h40(Object.keys(ids).length + 1),
});
await h.db
.updateTable("source_release")
.set({ promotion: JSON.stringify({ kind: "free" }), seeders: 41 })
.where("id", "=", rel)
.execute();
const row = await h.db
.insertInto("acquisition_plan")
.values({
slot_id: slot,
source_release_id: rel,
purpose: (p.purpose ?? "fill") as "fill",
status: p.status as "draft",
evaluation: JSON.stringify(p.evaluation ?? { reasons: ["hard_conditions_met", "pri:90"], instanceId: instId }),
reserved_bytes: 10_000_000_000,
payload_hash: `hash-${key}`,
idempotency_key: `test:${key}`,
status_reason: p.reason ?? null,
created_at: at(p.min),
})
.returning("id")
.executeTakeFirstOrThrow();
ids[key] = row.id;
if (p.status === "reserved")
await h.db
.updateTable("acquisition_slot")
.set({ status: "reserved", reserved_plan_id: row.id })
.where("id", "=", slot)
.execute();
return row.id;
}
async function approval(planId: string, status = "pending", scope: Record<string, unknown> = {}) {
return (
await h.db
.insertInto("approval")
.values({
plan_type: "acquisition_plan",
plan_id: planId,
payload_hash: "hash-x",
expected_revision: 1,
scope: JSON.stringify(scope),
status: status as "pending",
requested_by: "agent:test",
expires_at: new Date(Date.now() + 3600_000),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
beforeAll(async () => {
h = await createHarness("dl3plans");
entity = await seedEntity(h.db, { kind: "series", title: "人生切割术", year: 2022 });
siteId = (
await h.db
.insertInto("site")
.values({ key: "dl3site", name: "测试站", adapter: "nexusphp", base_url: "https://pt.example.test" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
// 下载器停用:面板不去连 qB,只看数据库
instId = (
await h.db
.insertInto("downloader_instance")
.values({ name: "qb", base_url: "http://127.0.0.1:9", username: "a", enabled: false })
.returning("id")
.executeTakeFirstOrThrow()
).id;
// 时间线(新 → 旧):已预留等空间(50)> 下载中的任务(40)> 等批准(30)> 智能体草案(25)> 孤立草案(20)> 失败(10)
await plan("failed", { status: "failed", min: 10, reason: "rejected: torrent is invalid" });
await plan("orphan", { status: "draft", min: 20 });
await plan("agentDraft", { status: "draft", min: 25 });
await plan("draft", { status: "draft", min: 30 });
await plan("submitted", { status: "submitted", min: 35 });
await plan("reserved", { status: "reserved", min: 50 });
await approval(ids.draft);
await approval(ids.agentDraft, "pending", { agentRunId: "x" });
taskId = (
await h.db
.insertInto("download_task")
.values({
instance_id: instId,
torrent_hash: h40(99),
acquisition_plan_id: ids.submitted,
name: "Severance.S02.submitted.2160p",
status: "downloading",
managed: true,
media_entity_id: entity,
created_at: at(40),
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
// 已预留的提交任务被下载盘阈值延后
await h.db
.insertInto("task")
.values({
kind: "submitDownload",
idempotency_key: `plan:${ids.reserved}`,
status: "waiting",
subject_type: "acquisition_plan",
subject_id: ids.reserved,
output: JSON.stringify({ status: "deferred", reason: "cache:projected_over_threshold" }),
})
.execute();
// 智能体会话准备的草案
const run = await h.db
.insertInto("agent_run")
.values({ trigger: "user", goal: "补齐人生切割术第二季的 4K 版本" })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("capability_invocation")
.values({
idempotency_key: "inv-1",
capability_id: "download.plan",
capability_version: 1,
agent_run_id: run.id,
actor_id: "agent",
source: "agent",
status: "succeeded",
input: JSON.stringify({}),
output: JSON.stringify({ plan: { id: ids.agentDraft } }),
})
.execute();
}, 60_000);
afterAll(async () => {
await h?.close();
});
it("进行中:等批准、等磁盘空间的计划与下载任务按加入时间一起排,计数包含它们;孤立草案不出现", async () => {
const r = await h.api("GET", "/download-tasks/board");
expect(r.status).toBe(200);
const b = r.body.data;
const rows = b.items as Row[];
expect(rows.map((x) => [x.kind, x.id])).toEqual([
["plan", ids.reserved],
["task", taskId],
["plan", ids.draft],
["plan", ids.agentDraft],
]);
expect(b.counts).toEqual({ active: 4, completed: 0 });
const reserved = b.items[0];
expect(reserved).toMatchObject({
wait: "space",
waitReason: "cache:projected_over_threshold",
status: "reserved",
purpose: "fill",
name: "Severance.S02.reserved.2160p",
sizeBytes: 10_000_000_000,
media: { id: entity, title: "人生切割术", year: 2022 },
approval: null,
});
const draft = b.items[2];
expect(draft).toMatchObject({ wait: "approval", status: "draft", approval: { payloadHash: "hash-x" } });
// 已完成分组不含计划
const done = (await h.api("GET", "/download-tasks/board?state=completed")).body.data;
expect(done.items).toEqual([]);
});
it("翻页:两路按同一游标归并,不重不漏", async () => {
const p1 = (await h.api("GET", "/download-tasks/board?limit=3")).body.data;
expect(p1.items.map((x: Row) => x.id)).toEqual([ids.reserved, taskId, ids.draft]);
expect(p1.nextCursor).toBeTruthy();
const p2 = (await h.api("GET", `/download-tasks/board?limit=3&cursor=${encodeURIComponent(p1.nextCursor)}`)).body
.data;
expect(p2.items.map((x: Row) => x.id)).toEqual([ids.agentDraft]);
expect(p2.nextCursor).toBeNull();
});
it("深链 planId:等待中的计划给计划行,已提交的给它的下载任务,结束的没有行", async () => {
const a = (await h.api("GET", `/download-tasks/board?planId=${ids.reserved}`)).body.data;
expect(a.items.map((x: Row) => [x.kind, x.id])).toEqual([["plan", ids.reserved]]);
const b = (await h.api("GET", `/download-tasks/board?planId=${ids.submitted}&state=completed`)).body.data;
expect(b.items.map((x: Row) => [x.kind, x.id])).toEqual([["task", taskId]]);
const c = (await h.api("GET", `/download-tasks/board?planId=${ids.failed}`)).body.data;
expect(c.items).toEqual([]);
});
it("为什么下这个:订阅 / 智能体会话来源、目的、选中理由原始要点", async () => {
const sub = (await h.api("GET", `/acquisition-plans/${ids.submitted}/why`)).body.data;
expect(sub).toMatchObject({
planId: ids.submitted,
source: { kind: "subscription", name: "人生切割术" },
purpose: "fill",
slotName: "4K",
replaces: null,
reasons: ["hard_conditions_met", "pri:90"],
promotion: "free",
seeders: 41,
siteName: "测试站",
});
expect(sub.evaluation.reasons).toEqual(["hard_conditions_met", "pri:90"]);
const agent = (await h.api("GET", `/acquisition-plans/${ids.agentDraft}/why`)).body.data;
expect(agent.source).toMatchObject({ kind: "agent", name: "补齐人生切割术第二季的 4K 版本" });
expect((await h.api("GET", `/acquisition-plans/${crypto.randomUUID()}/why`)).status).toBe(404);
});
it("升级:被替换的版本取评估时记下的文件规格", async () => {
const asset = await h.db
.insertInto("media_asset")
.values({
media_entity_id: entity,
kind: "video",
delivery: JSON.stringify({ resolution: "1080p", source: "BluRay", hdr: [], releaseGroup: "CMCT" }),
})
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.updateTable("acquisition_plan")
.set({
purpose: "upgrade",
evaluation: JSON.stringify({ reasons: ["source_tier_improved"], replacesAssetId: asset.id }),
})
.where("id", "=", ids.submitted)
.execute();
const w = (await h.api("GET", `/acquisition-plans/${ids.submitted}/why`)).body.data;
expect(w.purpose).toBe("upgrade");
expect(w.replaces).toEqual({ assetId: asset.id, label: "1080p 蓝光压制 · CMCT" });
});
it("取消:已预留的释放收录版本并进下载历史;已提交的 409", async () => {
const r = await h.api("POST", `/acquisition-plans/${ids.reserved}/cancel`);
expect(r.status).toBe(200);
expect(r.body.data).toMatchObject({ status: "cancelled", statusReason: "cancelled_by_user" });
const slot = await h.db
.selectFrom("acquisition_slot")
.select(["status", "reserved_plan_id"])
.where("id", "=", slots.reserved!)
.executeTakeFirstOrThrow();
expect(slot.status).not.toBe("reserved");
expect((await h.api("POST", `/acquisition-plans/${ids.submitted}/cancel`)).status).toBe(409);
expect((await h.api("POST", `/acquisition-plans/${ids.reserved}/cancel`)).status).toBe(409);
const b = (await h.api("GET", "/download-tasks/board")).body.data;
expect(b.counts.active).toBe(3);
expect(b.items.some((x: Row) => x.id === ids.reserved)).toBe(false);
});
it("作品下载历史:没提交就结束的下载带原因码;孤立草案与进行中的不在里面", async () => {
const d = (await h.api("GET", `/library/${entity}`)).body.data;
const dropped = d.droppedPlans.map((x: { plan: { id: string; statusReason: string } }) => [
x.plan.id,
x.plan.statusReason,
]);
expect(dropped).toEqual(
expect.arrayContaining([
[ids.failed, "rejected: torrent is invalid"],
[ids.reserved, "cancelled_by_user"],
]),
);
expect(dropped).toHaveLength(2);
expect(d.droppedPlans.find((x: { plan: { id: string } }) => x.plan.id === ids.failed).releaseTitle).toBe(
"Severance.S02.failed.2160p",
);
});
it("审批被拒 / 过期:草案跟着结束,不再算进行中", async () => {
const { closeDraftPlan } = await import("../../src/modules/agent/interaction.ts");
await closeDraftPlan(h.db, { plan_type: "acquisition_plan", plan_id: ids.draft }, "rejected");
await closeDraftPlan(h.db, { plan_type: "acquisition_plan", plan_id: ids.agentDraft }, "expired");
// 不是草案的不动
await closeDraftPlan(h.db, { plan_type: "acquisition_plan", plan_id: ids.submitted }, "rejected");
const rows = await sql<{ id: string; status: string; status_reason: string | null }>`
select id, status, status_reason from acquisition_plan where id in (${sql.join([ids.draft, ids.agentDraft, ids.submitted])})`.execute(
h.db,
);
const by = new Map(rows.rows.map((r) => [r.id, [r.status, r.status_reason]]));
expect(by.get(ids.draft)).toEqual(["cancelled", "approval_rejected"]);
expect(by.get(ids.agentDraft)).toEqual(["expired", "approval_expired"]);
expect(by.get(ids.submitted)?.[0]).toBe("submitted");
const b = (await h.api("GET", "/download-tasks/board")).body.data;
expect(b.items.map((x: Row) => x.kind)).toEqual(["task"]);
expect(b.counts.active).toBe(1);
});
});