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

198 lines
8.4 KiB
TypeScript

import { createMockQb, type MockQb } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
import { createHarness, type Harness, pgAvailable, seedEntity } from "./harness.ts";
// DL-2 下载面板:进行中 / 已完成分组与计数、每个种子的实时速度 / 剩余时间 / 连接数、下载器总速度、
// 实时数据短 TTL 缓存(轮询不直打 qB)、下载器不可达时退化为只看数据库。
const hasPg = await pgAvailable();
const TTL = 400;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const h40 = (n: number) => n.toString(16).padStart(40, "b");
describe.skipIf(!hasPg)("DL-2 下载面板(真实 PG + mock qB)", () => {
let h: Harness;
let qb: MockQb;
let instId: string;
beforeAll(async () => {
qb = await createMockQb({ addVisibleAfterPolls: 0 });
h = await createHarness("dlboard", { options: { liveTtlMs: TTL } });
instId = (
await h.db
.insertInto("downloader_instance")
.values({ name: "qb-main", base_url: qb.url, username: "admin", settings: JSON.stringify({}) })
.returning("id")
.executeTakeFirstOrThrow()
).id;
// 进行中:下载中(有速度与 eta)、暂停、排队、出错;已完成:做种、完成暂停;管理标签外的任务不纳入
const dl = qb.seed({ hash: h40(1), name: "Dune.Part.Two.2024.2160p", tags: ["umt"], progress: 0.4 });
Object.assign(dl, {
dlspeed: 12_900_000,
upspeed: 1_200_000,
eta: 620,
num_seeds: 12,
num_leechs: 3,
num_complete: 80,
num_incomplete: 9,
});
qb.seed({ hash: h40(2), name: "Paused.Show.S01", tags: ["umt"], progress: 0.2, state: "pausedDL" });
qb.seed({ hash: h40(3), name: "Queued.Movie.2020", tags: ["umt"], progress: 0, state: "queuedDL" });
qb.seed({ hash: h40(4), name: "Broken.Movie.2019", tags: ["umt"], progress: 0.1, state: "error" });
const seeding = qb.seed({ hash: h40(5), name: "Heat.1995.1080p", tags: ["umt"], progress: 1 });
Object.assign(seeding, { upspeed: 300_000, ratio: 1.25 });
qb.seed({ hash: h40(6), name: "Done.Paused.2018", tags: ["umt"], progress: 1, state: "pausedUP" });
const other = qb.seed({ hash: h40(7), name: "Not.Managed", tags: ["other"], progress: 0.5 });
other.dlspeed = 100_000;
await syncDownloads(h.d);
const heat = await seedEntity(h.db, { kind: "movie", title: "盗火线", year: 1995 });
const t5 = await h.db
.updateTable("download_task")
.set({ media_entity_id: heat })
.where("torrent_hash", "=", h40(5))
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("organization_record")
.values({
trigger: "auto",
actor_type: "system",
action: "copy",
status: "succeeded",
download_task_id: t5.id,
media_entity_id: heat,
})
.execute();
// 完成暂停的那个:最近一次整理失败 + 未关闭的待处理事项(下载任务维度)
const t6 = await h.db
.selectFrom("download_task")
.select("id")
.where("torrent_hash", "=", h40(6))
.executeTakeFirstOrThrow();
await h.db
.insertInto("organization_record")
.values({
trigger: "auto",
actor_type: "system",
action: "copy",
status: "failed",
reason: "identity_unknown",
download_task_id: t6.id,
})
.execute();
await h.db
.insertInto("decision")
.values({
kind: "identity_unresolved",
title: "没识别出作品",
subject_type: "download_task",
subject_id: t6.id,
dedupe_key: `test:${t6.id}`,
})
.execute();
}, 60_000);
afterAll(async () => {
await h?.close();
await qb?.close();
});
it("默认进行中:只含未完成任务,两组各带计数,附实时速度与剩余时间", async () => {
await sleep(TTL + 50);
const r = await h.api("GET", "/download-tasks/board");
expect(r.status).toBe(200);
const b = r.body.data;
expect(b.counts).toEqual({ active: 4, completed: 2 });
const names = b.items.map((i: { name: string }) => i.name).sort();
expect(names).toEqual(["Broken.Movie.2019", "Dune.Part.Two.2024.2160p", "Paused.Show.S01", "Queued.Movie.2020"]);
const dune = b.items.find((i: { torrentHash: string }) => i.torrentHash === h40(1));
expect(dune.live).toMatchObject({
dlSpeed: 12_900_000,
upSpeed: 1_200_000,
etaSec: 620,
seeds: 12,
leechs: 3,
seedsTotal: 80,
leechsTotal: 9,
rawState: "downloading",
});
expect(dune.live.progress).toBeCloseTo(0.4);
// eta 8640000(qB 的无穷)记为 null;num_complete -1 记为未知
const queued = b.items.find((i: { torrentHash: string }) => i.torrentHash === h40(3));
expect(queued.live.etaSec).toBeNull();
expect(queued.live.seedsTotal).toBeNull();
expect(queued.live.dlSpeed).toBe(0);
// 总速度来自 transfer/info:整个下载器(含管理标签外的任务)
expect(b.transfer.dlSpeed).toBe(12_900_000 + 100_000);
expect(b.transfer.upSpeed).toBe(1_200_000 + 300_000);
expect(b.transfer.instances).toEqual([
{ instanceId: instId, name: "qb-main", reachable: true, dlSpeed: 13_000_000, upSpeed: 1_500_000 },
]);
expect(b.liveAt).toBeTruthy();
});
it("已完成:含做种与完成后暂停,带作品与最近整理记录", async () => {
const b = (await h.api("GET", "/download-tasks/board?state=completed")).body.data;
expect(b.counts).toEqual({ active: 4, completed: 2 });
expect(b.items).toHaveLength(2);
const heat = b.items.find((i: { torrentHash: string }) => i.torrentHash === h40(5));
expect(heat.status).toBe("completed");
expect(heat.media).toMatchObject({ title: "盗火线", year: 1995 });
expect(heat.organize).toMatchObject({ status: "succeeded", reason: null, decision: null });
expect(heat.live).toMatchObject({ upSpeed: 300_000, ratio: 1.25, etaSec: null });
expect(heat.live.completedAt).toBeTruthy();
const paused = b.items.find((i: { torrentHash: string }) => i.torrentHash === h40(6));
expect(paused.organize).toMatchObject({
status: "failed",
reason: "identity_unknown",
decision: { kind: "identity_unresolved" },
});
expect(paused.media).toBeNull();
});
it("TTL 内的轮询共用一次 qB 查询,并发请求合并;过期后重新查询", async () => {
await sleep(TTL + 50);
const before = qb.callsTo("torrents/info").length;
const beforeT = qb.callsTo("transfer/info").length;
await Promise.all([
h.api("GET", "/download-tasks/board"),
h.api("GET", "/download-tasks/board?state=completed"),
h.api("GET", "/download-tasks/board"),
]);
await h.api("GET", "/download-tasks/board");
expect(qb.callsTo("torrents/info").length - before).toBe(1);
expect(qb.callsTo("transfer/info").length - beforeT).toBe(1);
// 缓存期间速度变化不可见,过期后可见
const dl = qb.get(h40(1));
if (dl) dl.dlspeed = 5_000_000;
const cached = (await h.api("GET", "/download-tasks/board")).body.data;
expect(cached.items.find((i: { torrentHash: string }) => i.torrentHash === h40(1)).live.dlSpeed).toBe(12_900_000);
await sleep(TTL + 50);
const fresh = (await h.api("GET", "/download-tasks/board")).body.data;
expect(fresh.items.find((i: { torrentHash: string }) => i.torrentHash === h40(1)).live.dlSpeed).toBe(5_000_000);
expect(qb.callsTo("torrents/info").length - before).toBe(2);
});
it("下载器不可达:列表照常返回,实时数据为空,实例标记不可达", async () => {
await sleep(TTL + 50);
qb.fault("torrents/info", { status: 500, times: 10 });
const b = (await h.api("GET", "/download-tasks/board")).body.data;
qb.clearFaults();
expect(b.counts.active).toBe(4);
expect(b.items.every((i: { live: unknown }) => i.live === null)).toBe(true);
expect(b.transfer).toMatchObject({ dlSpeed: 0, upSpeed: 0 });
expect(b.transfer.instances[0].reachable).toBe(false);
expect(b.liveAt).toBeNull();
});
it("按实例过滤;非法 state 返回 422", async () => {
const other = await h.api("GET", `/download-tasks/board?instanceId=${crypto.randomUUID()}`);
expect(other.body.data).toMatchObject({ items: [], counts: { active: 0, completed: 0 } });
expect(other.body.data.transfer.instances).toEqual([]);
expect((await h.api("GET", "/download-tasks/board?state=done")).status).toBe(422);
});
});