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; 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; 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 = {}) { 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); }); });