- 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]>
315 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|