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