- 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]>
198 lines
8.4 KiB
TypeScript
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);
|
|
});
|
|
});
|