Files
UHD_Media_Tool/packages/testkit/test/mock-qb.test.ts
T
rootandClaude Opus 5.5 fa9221086b feat(adapters,testkit): guarded qB data deletion (B7) and mock qB file materialization (B8)
adapters: assertSafeToDeleteData({contentPath, libraryRoots, importVerified}) rejects
paths inside/equal to/above any library root, relative or root paths, empty roots and
unverified imports; QbClient.delete({deleteFiles:true}) requires a per-hash
SafeDeleteCheck and throws DownloaderError('unsafe_delete') before sending anything.

testkit: mock qB complete(hash, {files, writeFiles}) writes random-content files under
save_path (opt-in, size-capped, traversal-safe); deleteFiles=true removes them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 19:22:56 +08:00

91 lines
3.7 KiB
TypeScript

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<string> {
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([]);
});
});