- Login success = HTTP 200 "Ok." or 204 (5.2+); session cookie taken from
Set-Cookie by name (SID / QBT_SID_<port> / custom) and sent back as-is.
- Failure classes: "Fails." / 401 -> auth; 403 IP ban / 429 -> rate;
wrong API Key (403) -> auth with hint; 5xx -> protocol.
- Downloader instance auth kind: password / api_key (qB 5.2+ Bearer, no
/auth/login) / none (whitelisted subnet); stored in settings.authKind,
credential write-only in secret_ref, cleared when switching to none.
One qbAuthConfig() shared by sync, agent capability and connection test.
- Settings form: auth kind select, password / API Key fields (no echo).
- testkit: mock qB 5.2 login behaviour, IP ban, bypassAuth;
makeTorrent({ realPieces }) builds real piece hashes for real qB checks.
- Real-container matrix (QB_REAL, skipped by default): 5.1.2 and 5.2.4,
login / add / query / tags / pause-resume / delete keeping data.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
135 lines
5.3 KiB
TypeScript
135 lines
5.3 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, 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<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([]);
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|