import { existsSync, readdirSync, statSync } from "node:fs"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createMockQb, type MockQb } from "../src/index.ts"; let mock: MockQb | null = null; let dir: string | null = null; afterEach(async () => { await mock?.close(); mock = null; if (dir) await rm(dir, { recursive: true, force: true }); dir = null; }); async function login(m: MockQb): Promise { const res = await fetch(`${m.url}/api/v2/auth/login`, { method: "POST", body: new URLSearchParams({ username: "admin", password: "adminadmin" }), }); return /SID=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")![1]!; } async function del(m: MockQb, hash: string, deleteFiles: boolean) { const sid = await login(m); const res = await fetch(`${m.url}/api/v2/torrents/delete`, { method: "POST", headers: { cookie: `SID=${sid}` }, body: new URLSearchParams({ hashes: hash, deleteFiles: String(deleteFiles) }), }); expect(res.status).toBe(200); } const H = "c".repeat(40); describe("mock qB 落盘(B8)", () => { it("complete({ files }) 在 save_path 下写出随机内容文件,并更新文件清单与 content_path", async () => { dir = await mkdtemp(join(tmpdir(), "umt-mockqb-")); mock = await createMockQb(); mock.seed({ hash: H, name: "Show.S01", save_path: dir }); const written = await mock.complete(H, { files: [ { path: "Show.S01/Show.S01E01.mkv", size: 3 * 1024 * 1024 + 7 }, { path: "Show.S01/Subs/Show.S01E01.chs.srt", size: 100 }, ], }); expect(written).toEqual([join(dir, "Show.S01/Show.S01E01.mkv"), join(dir, "Show.S01/Subs/Show.S01E01.chs.srt")]); expect(statSync(written[0]!).size).toBe(3 * 1024 * 1024 + 7); expect(statSync(written[1]!).size).toBe(100); // 随机内容:不是全零 expect((await readFile(written[1]!)).some((b) => b !== 0)).toBe(true); const t = mock.get(H)!; expect(t).toMatchObject({ progress: 1, size: 3 * 1024 * 1024 + 107, content_path: `${dir}/Show.S01` }); expect(t.files.map((f) => f.name)).toEqual(["Show.S01/Show.S01E01.mkv", "Show.S01/Subs/Show.S01E01.chs.srt"]); // deleteFiles=true 同步删除落盘文件和因此变空的目录,save_path 本身保留 await del(mock, H, true); expect(written.some((p) => existsSync(p))).toBe(false); expect(readdirSync(dir)).toEqual([]); }); it("deleteFiles=false 只删任务不删文件;默认不落盘,materializeFiles 打开后按现有清单写", async () => { dir = await mkdtemp(join(tmpdir(), "umt-mockqb-")); mock = await createMockQb({ materializeFiles: true }); mock.seed({ hash: H, name: "Movie.2024.mkv", save_path: dir, size: 2048 }); const written = await mock.complete(H); expect(written).toEqual([join(dir, "Movie.2024.mkv")]); await del(mock, H, false); expect(existsSync(written[0]!)).toBe(true); const plain = await createMockQb(); try { plain.seed({ hash: H, name: "x.mkv", save_path: "/downloads" }); expect(await plain.complete(H)).toEqual([]); expect(plain.get(H)!.progress).toBe(1); } finally { await plain.close(); } }); it("超出落盘上限、路径越界时抛错", async () => { dir = await mkdtemp(join(tmpdir(), "umt-mockqb-")); mock = await createMockQb({ maxMaterializeBytes: 1024 }); mock.seed({ hash: H, name: "big.mkv", save_path: dir, size: 4096 }); await expect(mock.complete(H, { writeFiles: true })).rejects.toThrow(/超过上限/); await expect(mock.complete(H, { files: [{ path: "../escape.mkv", size: 1 }] })).rejects.toThrow(/非法文件路径/); expect(readdirSync(dir)).toEqual([]); }); });