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, makeTorrent } from "../src/index.ts"; import { bdecodeLoose } from "../src/qbittorrent/bencode.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([]); }); }); describe("makeTorrent realPieces", () => { it("按内容计算真实 piece hash:跨文件切片,片数正确,内容确定", async () => { const { createHash } = await import("node:crypto"); const t = makeTorrent({ name: "Real.Pieces", pieceLength: 16384, files: [ { path: "a.mkv", length: 20000 }, { path: "Subs/a.ass", length: 30000 }, ], realPieces: true, }); expect(t.contents?.map((c) => [c.path, c.bytes.length])).toEqual([ ["Real.Pieces/a.mkv", 20000], ["Real.Pieces/Subs/a.ass", 30000], ]); const all = Buffer.concat(t.contents!.map((c) => Buffer.from(c.bytes))); const want = Buffer.concat( [0, 1, 2, 3].map((i) => createHash("sha1") .update(all.subarray(i * 16384, (i + 1) * 16384)) .digest(), ), ); const info = (bdecodeLoose(t.bytes) as { info: { pieces: Buffer } }).info; expect(info.pieces.equals(want)).toBe(true); const again = makeTorrent({ name: "Real.Pieces", pieceLength: 16384, files: [ { path: "a.mkv", length: 20000 }, { path: "Subs/a.ass", length: 30000 }, ], realPieces: true, }); expect(again.hash).toBe(t.hash); expect( makeTorrent({ name: "x", length: 10, realPieces: () => new Uint8Array(10) }).contents?.[0]?.bytes, ).toHaveLength(10); expect(() => makeTorrent({ name: "x", length: 10, realPieces: () => new Uint8Array(3) })).toThrow(); }); });