Initial public release v1.7.233
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check";
|
||||
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
|
||||
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
|
||||
import type { AppSettings } from "../src/shared/types";
|
||||
|
||||
function megaAccount(login = "user@example.com"): MegaDebridAccountEntry {
|
||||
return { id: "mda_test", login, password: "pw", index: 0, label: "Account 1", maskedLogin: "us**le" };
|
||||
}
|
||||
|
||||
function debridLinkKey(token = "tok_abcdef"): DebridLinkApiKeyEntry {
|
||||
return { id: "dlk_test", token, index: 0, label: "Key 1", masked: "tok***def" };
|
||||
}
|
||||
|
||||
function mockFetchOnce(status: number, body: unknown): void {
|
||||
const text = typeof body === "string" ? body : JSON.stringify(body);
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: async () => text
|
||||
})) as unknown as typeof fetch);
|
||||
}
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("checkMegaDebridAccount", () => {
|
||||
it("reports valid + premium from vip_end (future Unix ts)", async () => {
|
||||
const futureSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60;
|
||||
mockFetchOnce(200, { response_code: "ok", response_text: "User logged", token: "t", vip_end: String(futureSec), email: "a@b.de" });
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
expect(st.isPremium).toBe(true);
|
||||
expect(st.premiumUntilMs).toBe(futureSec * 1000);
|
||||
expect(st.email).toBe("a@b.de");
|
||||
expect(st.message).toMatch(/Premium noch/);
|
||||
});
|
||||
|
||||
it("reports valid but NOT premium when vip_end is in the past", async () => {
|
||||
const pastSec = Math.floor(NOW / 1000) - 1000;
|
||||
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: String(pastSec) });
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
expect(st.isPremium).toBe(false);
|
||||
});
|
||||
|
||||
it("reports valid but no premium when vip_end is 0/missing", async () => {
|
||||
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: "0" });
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
expect(st.isPremium).toBe(false);
|
||||
expect(st.premiumUntilMs).toBe(0);
|
||||
expect(st.message).toMatch(/Kein Premium/);
|
||||
});
|
||||
|
||||
it("reports invalid login when response_code != ok", async () => {
|
||||
mockFetchOnce(200, { response_code: "error", response_text: "bad login" });
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(false);
|
||||
expect(st.isPremium).toBe(false);
|
||||
expect(st.message).toMatch(/Ungueltiger Login/);
|
||||
});
|
||||
|
||||
it("reports invalid on HTTP error", async () => {
|
||||
mockFetchOnce(500, "server error");
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("never throws on network error — returns a failed status", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNRESET"); }) as unknown as typeof fetch);
|
||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||
expect(st.valid).toBe(false);
|
||||
expect(st.message).toMatch(/Pruefung fehlgeschlagen/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkDebridLinkKey", () => {
|
||||
it("reports valid + premium from premiumLeft seconds", async () => {
|
||||
const premiumLeft = 60 * 24 * 60 * 60;
|
||||
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } });
|
||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
expect(st.isPremium).toBe(true);
|
||||
expect(st.premiumUntilMs).toBe(NOW + premiumLeft * 1000);
|
||||
});
|
||||
|
||||
it("reports valid but free (premiumLeft 0, accountType 0)", async () => {
|
||||
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 0, premiumLeft: 0 } });
|
||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||
expect(st.valid).toBe(true);
|
||||
expect(st.isPremium).toBe(false);
|
||||
expect(st.message).toMatch(/Free/);
|
||||
});
|
||||
|
||||
it("reports invalid key on HTTP 401", async () => {
|
||||
mockFetchOnce(401, { success: false, error: "badToken" });
|
||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||
expect(st.valid).toBe(false);
|
||||
expect(st.message).toMatch(/Ungueltiger API-Key/);
|
||||
});
|
||||
|
||||
it("reports invalid key when success=false", async () => {
|
||||
mockFetchOnce(200, { success: false, error: "badToken" });
|
||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||
expect(st.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAllDebridAccounts", () => {
|
||||
it("returns empty array when nothing configured", async () => {
|
||||
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings;
|
||||
const result = await checkAllDebridAccounts(settings);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("checks every configured mega account + debrid-link key", async () => {
|
||||
const futureSec = Math.floor(Date.now() / 1000) + 1000;
|
||||
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
||||
if (String(url).includes("mega-debrid")) {
|
||||
return { ok: true, status: 200, text: async () => JSON.stringify({ response_code: "ok", token: "t", vip_end: String(futureSec) }) };
|
||||
}
|
||||
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
|
||||
}) as unknown as typeof fetch);
|
||||
|
||||
const settings = {
|
||||
megaCredentials: "a@b.de:pw1\nc@d.de:pw2",
|
||||
megaPassword: "",
|
||||
debridLinkApiKeys: "key1\nkey2\nkey3"
|
||||
} as unknown as AppSettings;
|
||||
|
||||
const result = await checkAllDebridAccounts(settings);
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2);
|
||||
expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3);
|
||||
expect(result.every((r) => r.valid)).toBe(true);
|
||||
});
|
||||
|
||||
it("caps concurrency (never more than 4 in flight) and preserves result order", async () => {
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
vi.stubGlobal("fetch", vi.fn(async () => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
inFlight -= 1;
|
||||
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
|
||||
}) as unknown as typeof fetch);
|
||||
|
||||
const keys = Array.from({ length: 9 }, (_, i) => `key_${i}`).join("\n");
|
||||
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: keys } as unknown as AppSettings;
|
||||
|
||||
const result = await checkAllDebridAccounts(settings);
|
||||
expect(result).toHaveLength(9);
|
||||
expect(maxInFlight).toBeLessThanOrEqual(4);
|
||||
result.forEach((r, i) => expect(r.label).toBe(`Key ${i + 1}`));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyAccountDialogToSettings, AccountDialogState } from "../src/renderer/App";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
|
||||
function megaDialog(kind: "megadebrid-api" | "megadebrid-web"): AccountDialogState {
|
||||
return {
|
||||
mode: "edit",
|
||||
kind,
|
||||
token: "",
|
||||
login: "",
|
||||
password: "",
|
||||
dailyLimitGb: "",
|
||||
keyDailyLimitGbById: {},
|
||||
megaAccounts: [{ login: "user@x", password: "pw" }],
|
||||
megaNewLogin: "",
|
||||
megaNewPassword: "",
|
||||
megaDisabledIds: []
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyAccountDialogToSettings — keeps the user's Mega preferApi choice", () => {
|
||||
it("does not flip megaDebridPreferApi to true when editing the API account", () => {
|
||||
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: false };
|
||||
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-api"));
|
||||
expect(next.megaDebridApiEnabled).toBe(true);
|
||||
expect(next.megaDebridPreferApi).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flip megaDebridPreferApi to false when editing the Web account", () => {
|
||||
const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true };
|
||||
const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-web"));
|
||||
expect(next.megaDebridWebEnabled).toBe(true);
|
||||
expect(next.megaDebridPreferApi).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { logAccountRotation, runWithRotationItemSink, getRecentRotationEvents } from "../src/main/account-rotation-log";
|
||||
import type { RotationEvent } from "../src/shared/types";
|
||||
|
||||
describe("rotation item-sink (AsyncLocalStorage)", () => {
|
||||
it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => {
|
||||
const captured: RotationEvent[] = [];
|
||||
await runWithRotationItemSink((ev) => captured.push(ev), async () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "TEST", { link: "x" });
|
||||
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "FAILED", { reason: "Timeout", cooldownSec: 30, next: "Account 2/3 (cd**zw)" });
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "TEST", { link: "x" });
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "OK", { fileName: "f.mkv" });
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const events = captured.map((e) => e.event);
|
||||
expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]);
|
||||
const failed = captured.find((e) => e.event === "FAILED");
|
||||
expect(failed?.reason).toBe("Timeout");
|
||||
expect(failed?.next).toBe("Account 2/3 (cd**zw)");
|
||||
});
|
||||
|
||||
it("does not leak events to the sink outside the run() scope", () => {
|
||||
const captured: RotationEvent[] = [];
|
||||
logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK");
|
||||
expect(captured).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("isolates two parallel item sinks (no cross-attribution)", async () => {
|
||||
const a: RotationEvent[] = [];
|
||||
const b: RotationEvent[] = [];
|
||||
await Promise.all([
|
||||
runWithRotationItemSink((ev) => a.push(ev), async () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "TEST");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "OK");
|
||||
}),
|
||||
runWithRotationItemSink((ev) => b.push(ev), async () => {
|
||||
logAccountRotation("INFO", "Debrid-Link", "Key 1 (b)", "TEST");
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
logAccountRotation("WARN", "Debrid-Link", "Key 1 (b)", "FAILED", { reason: "badToken" });
|
||||
})
|
||||
]);
|
||||
expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true);
|
||||
expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true);
|
||||
expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]);
|
||||
expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]);
|
||||
});
|
||||
|
||||
it("still feeds the global UI ring (outcomes only, TEST filtered)", () => {
|
||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST");
|
||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
|
||||
const ring = getRecentRotationEvents(10);
|
||||
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true);
|
||||
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reorderPackageOrderByDrop, sortPackageOrderByName } from "../src/renderer/package-order";
|
||||
|
||||
describe("reorderPackageOrderByDrop", () => {
|
||||
it("moves adjacent package down by one on drop", () => {
|
||||
const next = reorderPackageOrderByDrop(["a", "b", "c"], "b", "c");
|
||||
expect(next).toEqual(["a", "c", "b"]);
|
||||
});
|
||||
|
||||
it("moves package after lower drop target", () => {
|
||||
const next = reorderPackageOrderByDrop(["a", "b", "c", "d"], "a", "c");
|
||||
expect(next).toEqual(["b", "c", "a", "d"]);
|
||||
});
|
||||
|
||||
it("returns original order when ids are invalid", () => {
|
||||
const order = ["a", "b", "c"];
|
||||
expect(reorderPackageOrderByDrop(order, "x", "b")).toEqual(order);
|
||||
expect(reorderPackageOrderByDrop(order, "a", "x")).toEqual(order);
|
||||
expect(reorderPackageOrderByDrop(order, "a", "a")).toEqual(order);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortPackageOrderByName", () => {
|
||||
it("sorts package IDs alphabetically ascending", () => {
|
||||
const sorted = sortPackageOrderByName(
|
||||
["pkg3", "pkg1", "pkg2"],
|
||||
{
|
||||
pkg1: { id: "pkg1", name: "Alpha", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
|
||||
pkg2: { id: "pkg2", name: "beta", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
|
||||
pkg3: { id: "pkg3", name: "Gamma", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 }
|
||||
},
|
||||
false
|
||||
);
|
||||
expect(sorted).toEqual(["pkg1", "pkg2", "pkg3"]);
|
||||
});
|
||||
|
||||
it("sorts package IDs alphabetically descending", () => {
|
||||
const sorted = sortPackageOrderByName(
|
||||
["pkg1", "pkg2", "pkg3"],
|
||||
{
|
||||
pkg1: { id: "pkg1", name: "Alpha", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
|
||||
pkg2: { id: "pkg2", name: "beta", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 },
|
||||
pkg3: { id: "pkg3", name: "Gamma", outputDir: "", extractDir: "", status: "queued", itemIds: [], cancelled: false, enabled: true, priority: "normal", createdAt: 0, updatedAt: 0 }
|
||||
},
|
||||
true
|
||||
);
|
||||
expect(sorted).toEqual(["pkg3", "pkg2", "pkg1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
shutdownAuditLog();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("audit-log", () => {
|
||||
it("writes audit events to the audit log", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initAuditLog(baseDir);
|
||||
logAuditEvent("INFO", "Settings changed", { changedKeys: ["token", "autoExtract"] });
|
||||
|
||||
const logPath = getAuditLogPath();
|
||||
expect(logPath).not.toBeNull();
|
||||
expect(fs.existsSync(logPath!)).toBe(true);
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("Audit-Log Start");
|
||||
expect(content).toContain("Settings changed");
|
||||
expect(content).toContain("changedKeys");
|
||||
});
|
||||
|
||||
it("rotates oversized audit logs on startup", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-rotate-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
const oversizedPath = path.join(baseDir, "audit.log");
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8");
|
||||
|
||||
initAuditLog(baseDir);
|
||||
|
||||
expect(fs.existsSync(oversizedPath)).toBe(true);
|
||||
expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true);
|
||||
const content = fs.readFileSync(oversizedPath, "utf8");
|
||||
expect(content).toContain("Audit-Log Start");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { encryptBackup, decryptBackup } from "../src/main/backup-crypto";
|
||||
|
||||
describe("backup-crypto", () => {
|
||||
it("encrypts and decrypts a round-trip correctly", () => {
|
||||
const original = JSON.stringify({
|
||||
version: 2,
|
||||
settings: { token: "my-secret-api-key", outputDir: "C:\\Downloads" },
|
||||
session: { packages: {}, items: {} },
|
||||
history: [{ id: "h1", name: "Test" }]
|
||||
});
|
||||
|
||||
const encrypted = encryptBackup(original);
|
||||
const decrypted = decryptBackup(encrypted);
|
||||
expect(decrypted).toBe(original);
|
||||
});
|
||||
|
||||
it("produces binary output that is not plaintext readable", () => {
|
||||
const secret = "super-secret-token-12345";
|
||||
const plaintext = JSON.stringify({ settings: { token: secret } });
|
||||
const encrypted = encryptBackup(plaintext);
|
||||
|
||||
expect(encrypted.toString("utf8")).not.toContain(secret);
|
||||
expect(encrypted.toString("latin1")).not.toContain(secret);
|
||||
});
|
||||
|
||||
it("starts with the MDD1 magic bytes", () => {
|
||||
const encrypted = encryptBackup("test");
|
||||
expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD1");
|
||||
});
|
||||
|
||||
it("produces different ciphertext for the same input (random IV)", () => {
|
||||
const plaintext = "same input data";
|
||||
const a = encryptBackup(plaintext);
|
||||
const b = encryptBackup(plaintext);
|
||||
expect(a.equals(b)).toBe(false);
|
||||
expect(decryptBackup(a)).toBe(plaintext);
|
||||
expect(decryptBackup(b)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("throws on truncated data", () => {
|
||||
const encrypted = encryptBackup("test data");
|
||||
const truncated = encrypted.subarray(0, 10);
|
||||
expect(() => decryptBackup(truncated)).toThrow();
|
||||
});
|
||||
|
||||
it("throws on corrupted ciphertext", () => {
|
||||
const encrypted = encryptBackup("test data");
|
||||
const corrupted = Buffer.from(encrypted);
|
||||
corrupted[corrupted.length - 1] ^= 0xff;
|
||||
expect(() => decryptBackup(corrupted)).toThrow();
|
||||
});
|
||||
|
||||
it("throws on wrong magic bytes", () => {
|
||||
const encrypted = encryptBackup("test data");
|
||||
const wrongMagic = Buffer.from(encrypted);
|
||||
wrongMagic[0] = 0x00;
|
||||
expect(() => decryptBackup(wrongMagic)).toThrow(/Signatur/);
|
||||
});
|
||||
|
||||
it("throws on empty buffer", () => {
|
||||
expect(() => decryptBackup(Buffer.alloc(0))).toThrow();
|
||||
});
|
||||
|
||||
it("handles large payloads", () => {
|
||||
const large = JSON.stringify({ data: "x".repeat(1_000_000) });
|
||||
const encrypted = encryptBackup(large);
|
||||
const decrypted = decryptBackup(encrypted);
|
||||
expect(decrypted).toBe(large);
|
||||
});
|
||||
|
||||
it("handles unicode content", () => {
|
||||
const unicode = JSON.stringify({ name: "Ünïcödé 日本語 🎉", path: "C:\\Benutzer\\Ö" });
|
||||
const encrypted = encryptBackup(unicode);
|
||||
expect(decryptBackup(encrypted)).toBe(unicode);
|
||||
});
|
||||
|
||||
it("handles empty string round-trip", () => {
|
||||
const encrypted = encryptBackup("");
|
||||
expect(decryptBackup(encrypted)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
|
||||
import type { AppSettings, SessionState, HistoryEntry } from "../src/shared/types";
|
||||
|
||||
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
|
||||
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings;
|
||||
}
|
||||
|
||||
const session: SessionState = {
|
||||
version: 2, packageOrder: ["p1"], packages: { p1: {} as never }, items: { i1: {} as never },
|
||||
runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0,
|
||||
reconnectReason: "", paused: false, running: true, updatedAt: 0
|
||||
};
|
||||
const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry];
|
||||
|
||||
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history };
|
||||
|
||||
describe("buildBackupPayload — default is settings-only", () => {
|
||||
it("omits session AND history when backupIncludeDownloads is false (default)", () => {
|
||||
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
||||
expect(p.kind).toBe("settings-only");
|
||||
expect(p.session).toBeUndefined();
|
||||
expect(p.history).toBeUndefined();
|
||||
expect(p.settings).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes session + history when backupIncludeDownloads is true", () => {
|
||||
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
|
||||
expect(p.kind).toBe("full");
|
||||
expect(p.session).toBe(session);
|
||||
expect(p.history).toBe(history);
|
||||
});
|
||||
|
||||
it("treats a missing flag as settings-only (safe default)", () => {
|
||||
const p = buildBackupPayload({ ...baseInput, settings: {} as AppSettings });
|
||||
expect(p.kind).toBe("settings-only");
|
||||
expect(p.session).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ROUND-TRIP: toggle off -> exported payload carries the flag still false", () => {
|
||||
// "Haken aus bleibt aus": the exported settings object preserves the flag,
|
||||
// so importing it keeps the toggle off.
|
||||
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
||||
expect((p.settings as AppSettings).backupIncludeDownloads).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planBackupImport — decision follows the file, not the local toggle", () => {
|
||||
it("settings-only backup (no session) -> restore settings only, no relaunch", () => {
|
||||
const plan = planBackupImport({ version: 2, kind: "settings-only", settings: { theme: "dark" } });
|
||||
expect(plan.valid).toBe(true);
|
||||
expect(plan.restoreDownloads).toBe(false);
|
||||
expect(plan.message).toMatch(/Einstellungen/);
|
||||
});
|
||||
|
||||
it("full backup (with session) -> restore downloads + relaunch", () => {
|
||||
const plan = planBackupImport({ version: 2, kind: "full", settings: { theme: "dark" }, session });
|
||||
expect(plan.valid).toBe(true);
|
||||
expect(plan.restoreDownloads).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects payloads without settings", () => {
|
||||
expect(planBackupImport({ session }).valid).toBe(false);
|
||||
expect(planBackupImport(null).valid).toBe(false);
|
||||
expect(planBackupImport("nope").valid).toBe(false);
|
||||
expect(planBackupImport({}).valid).toBe(false);
|
||||
});
|
||||
|
||||
it("a settings-only export then import does NOT pull in the download list", () => {
|
||||
// Build with toggle off, then plan the import of exactly that payload.
|
||||
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
||||
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
|
||||
expect(plan.restoreDownloads).toBe(false); // queue stays untouched
|
||||
});
|
||||
|
||||
it("a full export then import DOES restore the download list", () => {
|
||||
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
|
||||
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
|
||||
expect(plan.restoreDownloads).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { once } from "node:events";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { buildBackupPayload, resolveRemoteDiagnosticsRestore, BackupRemoteDiagnostics } from "../src/main/backup-payload";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { normalizeSettings } from "../src/main/storage";
|
||||
import {
|
||||
startDebugServer,
|
||||
stopDebugServer,
|
||||
restartDebugServer,
|
||||
writeDebugServerConfig,
|
||||
getDebugAllowlist,
|
||||
getDebugServerRuntimeStatus
|
||||
} from "../src/main/debug-server";
|
||||
import type { DownloadManager } from "../src/main/download-manager";
|
||||
import type { AppSettings, SessionState } from "../src/shared/types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function input(settingsOverride: Partial<AppSettings>, remoteDiagnostics?: BackupRemoteDiagnostics) {
|
||||
return {
|
||||
settings: { ...defaultSettings(), ...settingsOverride } as AppSettings,
|
||||
appVersion: "1.7.233",
|
||||
exportedAt: "2026-08-01T00:00:00.000Z",
|
||||
session: {} as unknown as SessionState,
|
||||
history: [],
|
||||
remoteDiagnostics
|
||||
};
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
const probe = http.createServer();
|
||||
probe.listen(0, "127.0.0.1");
|
||||
await once(probe, "listening");
|
||||
const address = probe.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("port probe failed");
|
||||
}
|
||||
probe.close();
|
||||
await once(probe, "close");
|
||||
return address.port;
|
||||
}
|
||||
|
||||
async function waitForReady(url: string): Promise<void> {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
throw new Error(`debug server not ready: ${url}`);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
stopDebugServer();
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (!dir) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("backup remoteDiagnostics export gating", () => {
|
||||
it("includes remoteDiagnostics when backupIncludeRemoteDiagnostics is on", () => {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
backupIncludeRemoteDiagnostics: true
|
||||
};
|
||||
const payload = buildBackupPayload({
|
||||
settings,
|
||||
appVersion: "1.7.233",
|
||||
exportedAt: "2026-08-01T00:00:00.000Z",
|
||||
session: {} as unknown as SessionState,
|
||||
history: [],
|
||||
remoteDiagnostics: {
|
||||
allowlist: ["192.0.2.0/24"],
|
||||
port: 8976,
|
||||
hostMode: "network"
|
||||
}
|
||||
});
|
||||
|
||||
expect(payload.remoteDiagnostics).toEqual({
|
||||
allowlist: ["192.0.2.0/24"],
|
||||
port: 8976,
|
||||
hostMode: "network"
|
||||
});
|
||||
});
|
||||
|
||||
it("omits remoteDiagnostics when the toggle is off even if a section is provided", () => {
|
||||
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: false }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
||||
expect(payload.remoteDiagnostics).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits remoteDiagnostics when toggle on but no section gathered", () => {
|
||||
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: true }, undefined));
|
||||
expect(payload.remoteDiagnostics).toBeUndefined();
|
||||
});
|
||||
|
||||
it("the remoteDiagnostics section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => {
|
||||
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
||||
expect(payload.remoteDiagnostics && Object.keys(payload.remoteDiagnostics).sort()).toEqual(["allowlist", "hostMode", "port"]);
|
||||
const sectionJson = JSON.stringify(payload.remoteDiagnostics);
|
||||
expect(sectionJson.toLowerCase()).not.toContain("token");
|
||||
expect(sectionJson).not.toContain("publicHost");
|
||||
expect(sectionJson.toLowerCase()).not.toContain("\"name\"");
|
||||
});
|
||||
});
|
||||
|
||||
describe("backupIncludeRemoteDiagnostics settings persistence", () => {
|
||||
it("normalizeSettings preserves backupIncludeRemoteDiagnostics (the toggle survives save/load)", () => {
|
||||
expect(normalizeSettings({ backupIncludeRemoteDiagnostics: true } as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(true);
|
||||
expect(normalizeSettings({ backupIncludeRemoteDiagnostics: false } as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(false);
|
||||
expect(normalizeSettings({} as unknown as AppSettings).backupIncludeRemoteDiagnostics).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRemoteDiagnosticsRestore", () => {
|
||||
it("maps network + non-empty allowlist to 0.0.0.0", () => {
|
||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }))
|
||||
.toEqual({ host: "0.0.0.0", port: 9868, allowlist: ["10.0.0.5"] });
|
||||
});
|
||||
|
||||
it("SAFETY: network with EMPTY allowlist binds local, never 0.0.0.0", () => {
|
||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("maps local to 127.0.0.1", () => {
|
||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("rejects an out-of-range or non-integer port", () => {
|
||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined();
|
||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined();
|
||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined();
|
||||
});
|
||||
|
||||
it("filters non-string and blank allowlist entries and trims", () => {
|
||||
const r = resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5", "", " ", 5, null, " 8.8.8.8 "], port: 9868, hostMode: "network" });
|
||||
expect(r?.allowlist).toEqual(["10.0.0.5", "8.8.8.8"]);
|
||||
});
|
||||
|
||||
it("returns null for missing or empty/invalid sections", () => {
|
||||
expect(resolveRemoteDiagnosticsRestore(undefined)).toBeNull();
|
||||
expect(resolveRemoteDiagnosticsRestore(null)).toBeNull();
|
||||
expect(resolveRemoteDiagnosticsRestore("x")).toBeNull();
|
||||
expect(resolveRemoteDiagnosticsRestore({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("backup remoteDiagnostics live restore round-trip", () => {
|
||||
it("export -> resolve -> apply is reflected in the running debug-server (proves restart fired)", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-backup-remote-"));
|
||||
tempDirs.push(baseDir);
|
||||
const startPort = await getFreePort();
|
||||
const restorePort = await getFreePort();
|
||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt-secret", "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
||||
startDebugServer({} as unknown as DownloadManager, baseDir);
|
||||
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt-secret`);
|
||||
expect(getDebugAllowlist()).toEqual([]);
|
||||
|
||||
const payload = buildBackupPayload(input(
|
||||
{ backupIncludeRemoteDiagnostics: true },
|
||||
{ allowlist: ["203.0.113.4", "10.0.0.0/24"], port: restorePort, hostMode: "network" }
|
||||
));
|
||||
|
||||
const restore = resolveRemoteDiagnosticsRestore(payload.remoteDiagnostics);
|
||||
expect(restore).not.toBeNull();
|
||||
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
|
||||
const status = await restartDebugServer();
|
||||
|
||||
expect(getDebugAllowlist()).toEqual(["203.0.113.4", "10.0.0.0/24"]);
|
||||
expect(status.port).toBe(restorePort);
|
||||
expect(status.host).toBe("0.0.0.0");
|
||||
expect(status.allowlistCount).toBe(2);
|
||||
|
||||
expect(fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim()).toBe("rt-secret");
|
||||
expect(fs.existsSync(path.join(baseDir, "debug_remote.json"))).toBe(false);
|
||||
|
||||
await waitForReady(`http://127.0.0.1:${restorePort}/health?token=rt-secret`);
|
||||
});
|
||||
|
||||
it("full-backup path writes the debug_* files to disk without a restart (boot picks them up)", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-backup-remote2-"));
|
||||
tempDirs.push(baseDir);
|
||||
const startPort = await getFreePort();
|
||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "rt2", "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(startPort), "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
||||
startDebugServer({} as unknown as DownloadManager, baseDir);
|
||||
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt2`);
|
||||
|
||||
const restore = resolveRemoteDiagnosticsRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" });
|
||||
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
|
||||
|
||||
expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("0.0.0.0");
|
||||
expect(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()).toBe("9100");
|
||||
expect(fs.readFileSync(path.join(baseDir, "debug_allowlist.txt"), "utf8")).toContain("198.51.100.9");
|
||||
expect(getDebugServerRuntimeStatus().port).toBe(startPort);
|
||||
});
|
||||
});
|
||||
|
||||
describe("debug-server live diagnostics endpoints", () => {
|
||||
it("serves /providers (live cooldown/runtime snapshot) and /logs/conversion over authenticated HTTP", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-prov-"));
|
||||
tempDirs.push(baseDir);
|
||||
const port = await getFreePort();
|
||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), "prov-secret", "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
||||
startDebugServer({} as unknown as DownloadManager, baseDir);
|
||||
await waitForReady(`http://127.0.0.1:${port}/health?token=prov-secret`);
|
||||
|
||||
const provRes = await fetch(`http://127.0.0.1:${port}/providers?token=prov-secret`);
|
||||
expect(provRes.status).toBe(200);
|
||||
const prov = await provRes.json();
|
||||
expect(typeof prov.capturedAtMs).toBe("number");
|
||||
expect(prov.megaDebrid).toBeTruthy();
|
||||
expect(Array.isArray(prov.megaDebrid.accounts)).toBe(true);
|
||||
expect(typeof prov.megaDebrid.rotationCursor).toBe("number");
|
||||
expect(prov.debridLink).toBeTruthy();
|
||||
expect(Array.isArray(prov.debridLink.keys)).toBe(true);
|
||||
|
||||
const unauth = await fetch(`http://127.0.0.1:${port}/providers`);
|
||||
expect(unauth.status).toBe(401);
|
||||
|
||||
const convRes = await fetch(`http://127.0.0.1:${port}/logs/conversion?token=prov-secret`);
|
||||
expect(convRes.status).toBe(200);
|
||||
const conv = await convRes.json();
|
||||
expect(Array.isArray(conv.lines)).toBe(true);
|
||||
expect(conv).toHaveProperty("available");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockCookiesSet,
|
||||
mockFetch,
|
||||
mockClearStorageData,
|
||||
mockClearCache,
|
||||
mockFromPartition,
|
||||
mockSession
|
||||
} = vi.hoisted(() => {
|
||||
const cookiesSet = vi.fn();
|
||||
const fetch = vi.fn();
|
||||
const clearStorageData = vi.fn();
|
||||
const clearCache = vi.fn();
|
||||
const fromPartition = vi.fn();
|
||||
return {
|
||||
mockCookiesSet: cookiesSet,
|
||||
mockFetch: fetch,
|
||||
mockClearStorageData: clearStorageData,
|
||||
mockClearCache: clearCache,
|
||||
mockFromPartition: fromPartition,
|
||||
mockSession: {
|
||||
cookies: {
|
||||
set: cookiesSet
|
||||
},
|
||||
fetch,
|
||||
clearStorageData,
|
||||
clearCache
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
session: {
|
||||
fromPartition: mockFromPartition
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("../src/main/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
import { BestDebridWebFallback } from "../src/main/bestdebrid-web";
|
||||
|
||||
function createCookieFile(contents: string): string {
|
||||
const filePath = path.join(os.tmpdir(), `bestdebrid-cookies-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`);
|
||||
fs.writeFileSync(filePath, contents, "utf8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
describe("bestdebrid-web", () => {
|
||||
const tempFiles: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
mockFromPartition.mockReturnValue(mockSession);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFromPartition.mockReturnValue(mockSession);
|
||||
while (tempFiles.length > 0) {
|
||||
const filePath = tempFiles.pop();
|
||||
if (!filePath) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(filePath, { force: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("imports HttpOnly Netscape cookies instead of skipping them as comments", async () => {
|
||||
const filePath = createCookieFile([
|
||||
"# Netscape HTTP Cookie File",
|
||||
"#HttpOnly_.bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session",
|
||||
".bestdebrid.com\tTRUE\t/\tFALSE\t1806720721\t_ga\ttracking"
|
||||
].join("\n"));
|
||||
tempFiles.push(filePath);
|
||||
|
||||
const fallback = new BestDebridWebFallback(() => true);
|
||||
const count = await fallback.importCookiesFromFile(filePath);
|
||||
|
||||
expect(count).toBe(2);
|
||||
expect(mockClearStorageData).toHaveBeenCalledTimes(1);
|
||||
expect(mockClearStorageData).toHaveBeenCalledWith({
|
||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||
});
|
||||
expect(mockCookiesSet).toHaveBeenCalledTimes(2);
|
||||
expect(mockCookiesSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: "PHPSESSID",
|
||||
domain: ".bestdebrid.com",
|
||||
httpOnly: true,
|
||||
secure: true
|
||||
}));
|
||||
});
|
||||
|
||||
it("deduplicates conflicting session cookies and prefers the HttpOnly variant", async () => {
|
||||
const filePath = createCookieFile([
|
||||
"# Netscape HTTP Cookie File",
|
||||
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585384\tPHPSESSID\tnon-http-only",
|
||||
"#HttpOnly_.bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\tPHPSESSID\thttp-only"
|
||||
].join("\n"));
|
||||
tempFiles.push(filePath);
|
||||
|
||||
const fallback = new BestDebridWebFallback(() => true);
|
||||
const count = await fallback.importCookiesFromFile(filePath);
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(mockCookiesSet).toHaveBeenCalledTimes(1);
|
||||
expect(mockCookiesSet).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: "PHPSESSID",
|
||||
value: "http-only",
|
||||
httpOnly: true,
|
||||
domain: ".bestdebrid.com"
|
||||
}));
|
||||
});
|
||||
|
||||
it("rejects cookie files that only contain tracking cookies", async () => {
|
||||
const filePath = createCookieFile([
|
||||
"# Netscape HTTP Cookie File",
|
||||
".bestdebrid.com\tTRUE\t/\tTRUE\t1803585385\t__stripe_mid\tstripe",
|
||||
".bestdebrid.com\tTRUE\t/\tFALSE\t1806720721\t_ga\ttracking"
|
||||
].join("\n"));
|
||||
tempFiles.push(filePath);
|
||||
|
||||
const fallback = new BestDebridWebFallback(() => true);
|
||||
|
||||
await expect(fallback.importCookiesFromFile(filePath))
|
||||
.rejects.toThrow("Login-Cookie");
|
||||
expect(mockCookiesSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats BestDebrid free-user errors as logged-out sessions when the account page is guest-only", async () => {
|
||||
const filePath = createCookieFile([
|
||||
"# Netscape HTTP Cookie File",
|
||||
"bestdebrid.com\tFALSE\t/\tTRUE\t1803585385\tPHPSESSID\tsecret-session"
|
||||
].join("\n"));
|
||||
tempFiles.push(filePath);
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
error: 1,
|
||||
message: "Free users are not allowed to download using a VPN or proxy. Please purchase a premium plan."
|
||||
}), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response("<div class=\"font-medium\">Guest</div>", { status: 200 }));
|
||||
|
||||
const fallback = new BestDebridWebFallback(() => true);
|
||||
await fallback.importCookiesFromFile(filePath);
|
||||
|
||||
await expect(fallback.unrestrict("https://1fichier.com/?abc"))
|
||||
.rejects.toThrow("Nicht eingeloggt");
|
||||
await expect(fallback.unrestrict("https://1fichier.com/?abc"))
|
||||
.rejects.toThrow("Keine Cookies importiert");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe("https://bestdebrid.com/api/v1/generateLink");
|
||||
expect(mockFetch.mock.calls[1]?.[0]).toBe("https://bestdebrid.com/en/downloader/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "../src/main/cleanup";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("removes archive artifacts but keeps media", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
tempDirs.push(dir);
|
||||
fs.writeFileSync(path.join(dir, "release.part1.rar"), "x");
|
||||
fs.writeFileSync(path.join(dir, "movie.mkv"), "x");
|
||||
|
||||
const removed = cleanupCancelledPackageArtifacts(dir);
|
||||
expect(removed).toBeGreaterThan(0);
|
||||
expect(fs.existsSync(path.join(dir, "release.part1.rar"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(dir, "movie.mkv"))).toBe(true);
|
||||
});
|
||||
|
||||
it("removes sample artifacts and link files", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
tempDirs.push(dir);
|
||||
fs.mkdirSync(path.join(dir, "Samples"), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "Samples", "demo-sample.mkv"), "x");
|
||||
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://example.com/a\n");
|
||||
|
||||
const links = await removeDownloadLinkArtifacts(dir);
|
||||
const samples = await removeSampleArtifacts(dir);
|
||||
expect(links).toBeGreaterThan(0);
|
||||
expect(samples.files + samples.dirs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("cleans up archive files in nested directories", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
const sub1 = path.join(dir, "season1");
|
||||
const sub2 = path.join(dir, "season1", "extras");
|
||||
fs.mkdirSync(sub2, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(sub1, "episode.part1.rar"), "x");
|
||||
fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x");
|
||||
fs.writeFileSync(path.join(sub2, "bonus.zip"), "x");
|
||||
fs.writeFileSync(path.join(sub2, "bonus.7z"), "x");
|
||||
fs.writeFileSync(path.join(sub1, "video.mkv"), "real content");
|
||||
fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content");
|
||||
|
||||
const removed = cleanupCancelledPackageArtifacts(dir);
|
||||
expect(removed).toBe(4);
|
||||
expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects link artifacts by URL content in text files", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n");
|
||||
fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs");
|
||||
fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
|
||||
fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com");
|
||||
fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data");
|
||||
|
||||
const removed = await removeDownloadLinkArtifacts(dir);
|
||||
expect(removed).toBeGreaterThanOrEqual(3);
|
||||
expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not recurse into sample symlink or junction targets", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||
const external = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-ext-"));
|
||||
tempDirs.push(dir, external);
|
||||
|
||||
const outsideFile = path.join(external, "outside-sample.mkv");
|
||||
fs.writeFileSync(outsideFile, "keep", "utf8");
|
||||
|
||||
const linkedSampleDir = path.join(dir, "sample");
|
||||
const linkType: fs.symlink.Type = process.platform === "win32" ? "junction" : "dir";
|
||||
fs.symlinkSync(external, linkedSampleDir, linkType);
|
||||
|
||||
const result = await removeSampleArtifacts(dir);
|
||||
expect(result.files).toBe(0);
|
||||
expect(fs.existsSync(outsideFile)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { encodeConnectionCode } from "../src/main/connection-code";
|
||||
|
||||
function decodeConnectionCode(code: string) {
|
||||
const encoded = code.slice("rddiag:v1:".length).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const payload = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")) as Record<string, unknown>;
|
||||
return {
|
||||
host: payload.h,
|
||||
port: payload.p,
|
||||
token: payload.t,
|
||||
name: payload.n,
|
||||
scheme: payload.s ?? "http",
|
||||
fingerprint: payload.fp
|
||||
};
|
||||
}
|
||||
|
||||
describe("connection-code", () => {
|
||||
it("encodes a directly decodable payload", () => {
|
||||
const code = encodeConnectionCode({ host: "203.0.113.5", port: 9868, token: "deadbeef", name: "server-1" });
|
||||
expect(code.startsWith("rddiag:v1:")).toBe(true);
|
||||
const decoded = decodeConnectionCode(code);
|
||||
expect(decoded.host).toBe("203.0.113.5");
|
||||
expect(decoded.port).toBe(9868);
|
||||
expect(decoded.token).toBe("deadbeef");
|
||||
expect(decoded.name).toBe("server-1");
|
||||
expect(decoded.scheme).toBe("http");
|
||||
});
|
||||
|
||||
it("carries https scheme and fingerprint when set", () => {
|
||||
const code = encodeConnectionCode({
|
||||
host: "diag.example.com",
|
||||
port: 8443,
|
||||
token: "abc",
|
||||
scheme: "https",
|
||||
fingerprint: "AA:BB:CC"
|
||||
});
|
||||
const decoded = decodeConnectionCode(code);
|
||||
expect(decoded.scheme).toBe("https");
|
||||
expect(decoded.fingerprint).toBe("AA:BB:CC");
|
||||
});
|
||||
|
||||
it("omits scheme key for plain http (default)", () => {
|
||||
const code = encodeConnectionCode({ host: "10.0.0.2", port: 9868, token: "t" });
|
||||
const json = JSON.parse(Buffer.from(code.slice("rddiag:v1:".length).replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
|
||||
expect(json.s).toBeUndefined();
|
||||
expect(json).toMatchObject({ v: 1, h: "10.0.0.2", p: 9868, t: "t" });
|
||||
});
|
||||
|
||||
it("rejects invalid input", () => {
|
||||
expect(() => encodeConnectionCode({ host: "", port: 9868, token: "t" })).toThrow();
|
||||
expect(() => encodeConnectionCode({ host: "h", port: 0, token: "t" })).toThrow();
|
||||
expect(() => encodeConnectionCode({ host: "h", port: 9868, token: "" })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { importDlcContainers } from "../src/main/container";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("container", () => {
|
||||
it("skips oversized DLC files without throwing and blocking other files", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
||||
tempDirs.push(dir);
|
||||
const oversizedFilePath = path.join(dir, "oversized.dlc");
|
||||
fs.writeFileSync(oversizedFilePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1));
|
||||
|
||||
const validFilePath = path.join(dir, "valid.dlc");
|
||||
fs.writeFileSync(validFilePath, Buffer.from("Valid but not real DLC content..."));
|
||||
|
||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||
return new Response("http://example.com/file1.rar\nhttp://example.com/file2.rar", { status: 200 });
|
||||
}
|
||||
return new Response("", { status: 404 });
|
||||
});
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
|
||||
const result = await importDlcContainers([oversizedFilePath, validFilePath]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("valid");
|
||||
expect(result[0].links).toEqual(["http://example.com/file1.rar", "http://example.com/file2.rar"]);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips non-dlc files completely", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-non-"));
|
||||
tempDirs.push(dir);
|
||||
const txtPath = path.join(dir, "links.txt");
|
||||
fs.writeFileSync(txtPath, "http://link.com/1");
|
||||
|
||||
const result = await importDlcContainers([txtPath]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to dcrypt if local decryption returns empty", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, "fallback.dlc");
|
||||
|
||||
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
|
||||
|
||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes("service.jdownloader.org")) {
|
||||
return new Response("", { status: 404 });
|
||||
}
|
||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||
return new Response("http://fallback.com/1", { status: 200 });
|
||||
}
|
||||
return new Response("", { status: 404 });
|
||||
});
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
|
||||
const result = await importDlcContainers([filePath]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("fallback");
|
||||
expect(result[0].links).toEqual(["http://fallback.com/1"]);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("falls back to dcrypt when local decryption throws invalid padding", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, "invalid-local.dlc");
|
||||
fs.writeFileSync(filePath, "X".repeat(120));
|
||||
|
||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes("service.jdownloader.org")) {
|
||||
return new Response(`<rc>${Buffer.alloc(16).toString("base64")}</rc>`, { status: 200 });
|
||||
}
|
||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||
return new Response("http://example.com/fallback1", { status: 200 });
|
||||
}
|
||||
return new Response("", { status: 404 });
|
||||
});
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
|
||||
const result = await importDlcContainers([filePath]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("invalid-local");
|
||||
expect(result[0].links).toEqual(["http://example.com/fallback1"]);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("falls back to paste endpoint when upload returns 413", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, "big-dlc.dlc");
|
||||
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
|
||||
|
||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes("service.jdownloader.org")) {
|
||||
return new Response("", { status: 404 });
|
||||
}
|
||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||
return new Response("Request Entity Too Large", { status: 413 });
|
||||
}
|
||||
if (urlStr.includes("dcrypt.it/decrypt/paste")) {
|
||||
return new Response("http://paste-fallback.com/file1.rar\nhttp://paste-fallback.com/file2.rar", { status: 200 });
|
||||
}
|
||||
return new Response("", { status: 404 });
|
||||
});
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
|
||||
const result = await importDlcContainers([filePath]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("big-dlc");
|
||||
expect(result[0].links).toEqual(["http://paste-fallback.com/file1.rar", "http://paste-fallback.com/file2.rar"]);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("throws when both dcrypt endpoints return 413", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, "huge.dlc");
|
||||
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
|
||||
|
||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes("service.jdownloader.org")) {
|
||||
return new Response("", { status: 404 });
|
||||
}
|
||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||
return new Response("Request Entity Too Large", { status: 413 });
|
||||
}
|
||||
if (urlStr.includes("dcrypt.it/decrypt/paste")) {
|
||||
return new Response("Request Entity Too Large", { status: 413 });
|
||||
}
|
||||
return new Response("", { status: 500 });
|
||||
});
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
|
||||
await expect(importDlcContainers([filePath])).rejects.toThrow(/zu groß für dcrypt/i);
|
||||
});
|
||||
|
||||
it("throws when upload returns 413 and paste returns 500", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, "doomed.dlc");
|
||||
fs.writeFileSync(filePath, Buffer.from("not a valid dlc payload at all"));
|
||||
|
||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes("service.jdownloader.org")) {
|
||||
return new Response("", { status: 404 });
|
||||
}
|
||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||
return new Response("Request Entity Too Large", { status: 413 });
|
||||
}
|
||||
if (urlStr.includes("dcrypt.it/decrypt/paste")) {
|
||||
return new Response("paste failure", { status: 500 });
|
||||
}
|
||||
return new Response("", { status: 500 });
|
||||
});
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
|
||||
await expect(importDlcContainers([filePath])).rejects.toThrow(/DLC konnte nicht importiert werden/i);
|
||||
});
|
||||
|
||||
it("throws clear error when all dlc imports fail", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, "broken.dlc");
|
||||
fs.writeFileSync(filePath, Buffer.from("not a valid dlc payload at all"));
|
||||
|
||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes("service.jdownloader.org")) {
|
||||
return new Response("", { status: 404 });
|
||||
}
|
||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||
return new Response("upstream failure", { status: 500 });
|
||||
}
|
||||
return new Response("", { status: 500 });
|
||||
});
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
|
||||
await expect(importDlcContainers([filePath])).rejects.toThrow(/DLC konnte nicht importiert werden/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatConversionBlock,
|
||||
hasActiveConversionTrace,
|
||||
runWithConversionTrace,
|
||||
traceConversionPhase,
|
||||
type ConversionTrace
|
||||
} from "../src/main/conversion-trace";
|
||||
|
||||
describe("formatConversionBlock", () => {
|
||||
it("renders a header with verdict + total and one indented line per phase", () => {
|
||||
const trace: ConversionTrace = {
|
||||
startedAt: 1000,
|
||||
itemId: "id1",
|
||||
itemName: "tvs-foo.part5.rar",
|
||||
link: "https://rapidgator.net/file/abc/tvs-foo.part5.rar.html",
|
||||
providerOrder: "megadebrid-api,megadebrid-web",
|
||||
notes: { slots: "conv2/dl6/max8" },
|
||||
phases: [
|
||||
{ atMs: 0, phase: "chain-try", provider: "megadebrid-api" },
|
||||
{ atMs: 5, phase: "token", provider: "megadebrid-api", account: "2/2(e3)", tokenState: "fresh", workMs: 812, outcome: "ok" },
|
||||
{ atMs: 820, phase: "api-getlink", provider: "megadebrid-api", account: "2/2(e3)", workMs: 634, outcome: "ok" }
|
||||
]
|
||||
};
|
||||
const block = formatConversionBlock(trace, "OK", "", 1450);
|
||||
|
||||
const lines = block.split("\n");
|
||||
expect(lines[0]).toContain("[CONV]");
|
||||
expect(lines[0]).toContain("item=tvs-foo.part5.rar");
|
||||
expect(lines[0]).toContain("result=OK");
|
||||
expect(lines[0]).toContain("total=1450ms");
|
||||
expect(lines[0]).toContain("slots=conv2/dl6/max8");
|
||||
expect(lines).toHaveLength(4);
|
||||
expect(lines[2]).toContain("+5ms token");
|
||||
expect(lines[2]).toContain("token=fresh");
|
||||
expect(lines[2]).toContain("workMs=812");
|
||||
});
|
||||
|
||||
it("includes the failure detail in the header verdict", () => {
|
||||
const trace: ConversionTrace = {
|
||||
startedAt: 0, itemId: "i", itemName: "x", link: "l", providerOrder: "megadebrid-web", notes: {},
|
||||
phases: [{ atMs: 60000, phase: "caller-timeout", provider: "megadebrid-web", outcome: "timeout", detail: "Unrestrict Timeout nach 60s" }]
|
||||
};
|
||||
const block = formatConversionBlock(trace, "FAIL", "Unrestrict Timeout nach 60s", 60003);
|
||||
expect(block.split("\n")[0]).toContain("result=FAIL (Unrestrict Timeout nach 60s)");
|
||||
expect(block).toContain("caller-timeout");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversion trace context", () => {
|
||||
it("traceConversionPhase is a no-op outside an active trace and does not throw", () => {
|
||||
expect(hasActiveConversionTrace()).toBe(false);
|
||||
expect(() => traceConversionPhase({ phase: "orphan" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("activates an ambient trace across awaits inside runWithConversionTrace", async () => {
|
||||
expect(hasActiveConversionTrace()).toBe(false);
|
||||
const seen = await runWithConversionTrace(
|
||||
{ itemId: "i", itemName: "n", link: "l", providerOrder: "megadebrid-api" },
|
||||
async () => {
|
||||
const before = hasActiveConversionTrace();
|
||||
traceConversionPhase({ phase: "chain-try", provider: "megadebrid-api" });
|
||||
await Promise.resolve();
|
||||
const afterAwait = hasActiveConversionTrace();
|
||||
return before && afterAwait;
|
||||
}
|
||||
);
|
||||
expect(seen).toBe(true);
|
||||
expect(hasActiveConversionTrace()).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { once } from "node:events";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
startDebugServer,
|
||||
stopDebugServer,
|
||||
restartDebugServer,
|
||||
writeDebugServerConfig,
|
||||
getDebugServerRuntimeStatus,
|
||||
evaluateClientAllowed,
|
||||
getPeerIp
|
||||
} from "../src/main/debug-server";
|
||||
import type { DownloadManager } from "../src/main/download-manager";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const TOKEN = "allowlist-secret";
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
const probe = http.createServer();
|
||||
probe.listen(0, "127.0.0.1");
|
||||
await once(probe, "listening");
|
||||
const address = probe.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("port probe failed");
|
||||
}
|
||||
probe.close();
|
||||
await once(probe, "close");
|
||||
return address.port;
|
||||
}
|
||||
|
||||
async function waitForReady(url: string): Promise<void> {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
throw new Error(`debug server not ready: ${url}`);
|
||||
}
|
||||
|
||||
async function startWithAllowlist(allowlist: string[], host = "0.0.0.0"): Promise<{ baseUrl: string }> {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-allow-"));
|
||||
tempDirs.push(baseDir);
|
||||
const port = await getFreePort();
|
||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), TOKEN, "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), host, "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), allowlist.join("\n"), "utf8");
|
||||
const manager = {} as unknown as DownloadManager;
|
||||
startDebugServer(manager, baseDir);
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
|
||||
return { baseUrl };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
stopDebugServer();
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (!dir) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("debug-server allowlist matcher (pure)", () => {
|
||||
it("always allows loopback regardless of rules", () => {
|
||||
expect(evaluateClientAllowed("127.0.0.1", [])).toBe(true);
|
||||
expect(evaluateClientAllowed("::1", [])).toBe(true);
|
||||
expect(evaluateClientAllowed("::ffff:127.0.0.1", ["8.8.8.8"])).toBe(true);
|
||||
});
|
||||
|
||||
it("matches an exact allowlisted IP and rejects others", () => {
|
||||
expect(evaluateClientAllowed("8.8.8.8", ["8.8.8.8"])).toBe(true);
|
||||
expect(evaluateClientAllowed("9.9.9.9", ["8.8.8.8"])).toBe(false);
|
||||
});
|
||||
|
||||
it("matches inside a CIDR and rejects outside it", () => {
|
||||
expect(evaluateClientAllowed("10.0.0.42", ["10.0.0.0/24"])).toBe(true);
|
||||
expect(evaluateClientAllowed("10.0.1.42", ["10.0.0.0/24"])).toBe(false);
|
||||
});
|
||||
|
||||
it("fail-closed: empty rules reject every non-loopback client", () => {
|
||||
expect(evaluateClientAllowed("203.0.113.7", [])).toBe(false);
|
||||
expect(evaluateClientAllowed("8.8.8.8", [])).toBe(false);
|
||||
});
|
||||
|
||||
it("derives the client IP from the socket peer, never from X-Forwarded-For", () => {
|
||||
const forgedLoopback = {
|
||||
socket: { remoteAddress: "8.8.8.8" },
|
||||
headers: { "x-forwarded-for": "127.0.0.1" }
|
||||
} as unknown as http.IncomingMessage;
|
||||
expect(getPeerIp(forgedLoopback)).toBe("8.8.8.8");
|
||||
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), [])).toBe(false);
|
||||
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), ["9.9.9.9"])).toBe(false);
|
||||
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), ["8.8.8.8"])).toBe(true);
|
||||
|
||||
const ipv6Mapped = {
|
||||
socket: { remoteAddress: "::ffff:10.0.0.5" },
|
||||
headers: {}
|
||||
} as unknown as http.IncomingMessage;
|
||||
expect(getPeerIp(ipv6Mapped)).toBe("10.0.0.5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("debug-server allowlist enforcement (wired)", () => {
|
||||
it("allows a loopback connection and ignores a spoofed X-Forwarded-For", async () => {
|
||||
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
|
||||
const plain = await fetch(`${baseUrl}/health?token=${TOKEN}`);
|
||||
expect(plain.status).toBe(200);
|
||||
const spoofed = await fetch(`${baseUrl}/health?token=${TOKEN}`, {
|
||||
headers: { "X-Forwarded-For": "203.0.113.9" }
|
||||
});
|
||||
expect(spoofed.status).toBe(200);
|
||||
});
|
||||
|
||||
it("still enforces the token for loopback clients", async () => {
|
||||
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
|
||||
const res = await fetch(`${baseUrl}/health`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("reloads the allowlist live via restartDebugServer", async () => {
|
||||
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
|
||||
expect(getDebugServerRuntimeStatus().allowlistCount).toBe(1);
|
||||
writeDebugServerConfig({ allowlist: ["9.9.9.9", "10.0.0.0/24"] });
|
||||
const status = await restartDebugServer();
|
||||
expect(status.running).toBe(true);
|
||||
expect(status.allowlistCount).toBe(2);
|
||||
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
|
||||
expect((await fetch(`${baseUrl}/health?token=${TOKEN}`)).status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,563 @@
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { once } from "node:events";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../src/main/windows-host-diagnostics", () => ({
|
||||
getWindowsHostDiagnostics: () => ({
|
||||
collectedAt: "2026-03-09T00:00:03.000Z",
|
||||
supported: true,
|
||||
platform: "win32",
|
||||
crashControl: {
|
||||
crashDumpEnabled: 3,
|
||||
minidumpDir: "C:\\Windows\\Minidumps",
|
||||
dumpFile: "C:\\Windows\\MEMORY.DMP",
|
||||
overwrite: 1,
|
||||
logEvent: 1,
|
||||
autoReboot: 1
|
||||
},
|
||||
recentKernelPower: [
|
||||
{
|
||||
timeCreated: "2026-03-09T00:00:04.000Z",
|
||||
id: 41,
|
||||
providerName: "Microsoft-Windows-Kernel-Power",
|
||||
levelDisplayName: "Critical",
|
||||
message: "unexpected restart",
|
||||
bugcheckCode: "0",
|
||||
bugcheckCodeHex: "",
|
||||
reportId: ""
|
||||
}
|
||||
],
|
||||
recentWerKernel: [],
|
||||
recentKernelDump: [],
|
||||
recentAppCrashes: [],
|
||||
recentMinidumps: [],
|
||||
assessmentHints: ["watchdog hint"],
|
||||
errors: []
|
||||
})
|
||||
}));
|
||||
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log";
|
||||
import { startDebugServer, stopDebugServer } from "../src/main/debug-server";
|
||||
import { ensureItemLog, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
|
||||
import { configureLogger, getLogFilePath, logger } from "../src/main/logger";
|
||||
import { ensurePackageLog, initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
|
||||
import { getRenameLogPath, initRenameLog, logRenameEvent, shutdownRenameLog } from "../src/main/rename-log";
|
||||
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
|
||||
import { createStoragePaths, saveHistory, saveSettings } from "../src/main/storage";
|
||||
import { getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
|
||||
import { getDebridLinkApiKeyIds } from "../src/shared/debrid-link-keys";
|
||||
import type { DownloadManager } from "../src/main/download-manager";
|
||||
import type { UiSnapshot } from "../src/shared/types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
|
||||
const legacyManifestField = ["a", "i", "Manifest"].join("");
|
||||
const forbiddenSupportMarkers = [
|
||||
["A", "I"].join(""),
|
||||
["assist", "ant"].join(""),
|
||||
["assist", "ants"].join(""),
|
||||
["ag", "ent"].join(""),
|
||||
["ag", "ents"].join(""),
|
||||
["Clau", "de"].join(""),
|
||||
["Anthro", "pic"].join(""),
|
||||
["Co", "dex"].join(""),
|
||||
["Open", "AI"].join(""),
|
||||
["M", "C", "P"].join(""),
|
||||
["K", "I"].join("")
|
||||
];
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
const probe = http.createServer();
|
||||
probe.listen(0, "127.0.0.1");
|
||||
await once(probe, "listening");
|
||||
const address = probe.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("port probe failed");
|
||||
}
|
||||
probe.close();
|
||||
await once(probe, "close");
|
||||
return address.port;
|
||||
}
|
||||
|
||||
async function waitForReady(url: string): Promise<void> {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error(`debug server not ready: ${url}`);
|
||||
}
|
||||
|
||||
function buildSnapshot(baseDir: string): UiSnapshot {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
outputDir: path.join(baseDir, "downloads"),
|
||||
extractDir: path.join(baseDir, "extract")
|
||||
};
|
||||
|
||||
return {
|
||||
settings,
|
||||
session: {
|
||||
version: 1,
|
||||
packageOrder: ["pkg-1"],
|
||||
packages: {
|
||||
"pkg-1": {
|
||||
id: "pkg-1",
|
||||
name: "server-package",
|
||||
outputDir: path.join(baseDir, "downloads", "server-package"),
|
||||
extractDir: path.join(baseDir, "extract", "server-package"),
|
||||
status: "downloading",
|
||||
itemIds: ["item-1", "item-2"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
priority: "normal",
|
||||
postProcessLabel: "",
|
||||
createdAt: Date.now() - 30_000,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
},
|
||||
items: {
|
||||
"item-1": {
|
||||
id: "item-1",
|
||||
packageId: "pkg-1",
|
||||
url: "https://hoster.example/file-1",
|
||||
provider: "realdebrid",
|
||||
providerLabel: "Real-Debrid",
|
||||
status: "downloading",
|
||||
retries: 1,
|
||||
speedBps: 8 * 1024 * 1024,
|
||||
downloadedBytes: 64 * 1024 * 1024,
|
||||
totalBytes: 256 * 1024 * 1024,
|
||||
progressPercent: 25,
|
||||
fileName: "episode.part1.rar",
|
||||
targetPath: path.join(baseDir, "downloads", "server-package", "episode.part1.rar"),
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Download läuft (Real-Debrid)",
|
||||
createdAt: Date.now() - 30_000,
|
||||
updatedAt: Date.now()
|
||||
},
|
||||
"item-2": {
|
||||
id: "item-2",
|
||||
packageId: "pkg-1",
|
||||
url: "https://hoster.example/file-2",
|
||||
provider: "realdebrid",
|
||||
providerLabel: "Real-Debrid",
|
||||
status: "failed",
|
||||
retries: 3,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fileName: "episode.part2.rar",
|
||||
targetPath: path.join(baseDir, "downloads", "server-package", "episode.part2.rar"),
|
||||
resumable: false,
|
||||
attempts: 3,
|
||||
lastError: "hoster unavailable",
|
||||
fullStatus: "Fehler: hoster unavailable",
|
||||
createdAt: Date.now() - 30_000,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
},
|
||||
runStartedAt: Date.now() - 30_000,
|
||||
totalDownloadedBytes: 64 * 1024 * 1024,
|
||||
summaryText: "",
|
||||
reconnectUntil: 0,
|
||||
reconnectReason: "",
|
||||
paused: false,
|
||||
running: true,
|
||||
updatedAt: Date.now()
|
||||
},
|
||||
summary: null,
|
||||
stats: {
|
||||
totalDownloaded: 64 * 1024 * 1024,
|
||||
totalDownloadedAllTime: 128 * 1024 * 1024,
|
||||
totalFilesSession: 0,
|
||||
totalFilesAllTime: 0,
|
||||
totalPackages: 1,
|
||||
sessionStartedAt: Date.now() - 30_000,
|
||||
appSessionStartedAt: Date.now() - 60_000,
|
||||
sessionRuntimeMs: 60_000,
|
||||
totalRuntimeMs: 3 * 60_000,
|
||||
runtimeMeasuredAt: Date.now()
|
||||
},
|
||||
speedText: "8.0 MB/s",
|
||||
etaText: "ETA: 00:25",
|
||||
canStart: false,
|
||||
canStop: true,
|
||||
canPause: true,
|
||||
clipboardActive: false,
|
||||
reconnectSeconds: 0,
|
||||
packageSpeedBps: {
|
||||
"pkg-1": 8 * 1024 * 1024
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function createFixture() {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-debug-"));
|
||||
tempDirs.push(baseDir);
|
||||
const token = "debug-secret";
|
||||
const port = await getFreePort();
|
||||
const snapshot = buildSnapshot(baseDir);
|
||||
const storagePaths = createStoragePaths(baseDir);
|
||||
|
||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), token, "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
|
||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "0.0.0.0", "utf8");
|
||||
const debridLinkApiKeys = "key-a\nkey-b";
|
||||
const debridLinkKeyIds = getDebridLinkApiKeyIds(debridLinkApiKeys);
|
||||
|
||||
saveSettings(storagePaths, {
|
||||
...snapshot.settings,
|
||||
token: "rd-secret-token",
|
||||
realDebridUseWebLogin: true,
|
||||
debridLinkApiKeys,
|
||||
debridLinkDisabledKeyIds: debridLinkKeyIds[1] ? [debridLinkKeyIds[1]] : [],
|
||||
totalDownloadedAllTime: 128 * 1024 * 1024,
|
||||
totalCompletedFilesAllTime: 12,
|
||||
totalRuntimeAllTimeMs: 5 * 60_000
|
||||
});
|
||||
saveHistory(storagePaths, [
|
||||
{
|
||||
id: "hist-1",
|
||||
name: "server-package",
|
||||
totalBytes: 123,
|
||||
downloadedBytes: 123,
|
||||
fileCount: 2,
|
||||
provider: "realdebrid",
|
||||
completedAt: Date.now() - 5_000,
|
||||
durationSeconds: 42,
|
||||
status: "completed",
|
||||
outputDir: path.join(baseDir, "downloads", "server-package"),
|
||||
urls: ["https://hoster.example/file-1"]
|
||||
}
|
||||
]);
|
||||
|
||||
configureLogger(baseDir);
|
||||
fs.writeFileSync(getLogFilePath(), "2026-03-09T00:00:00.000Z [INFO] MAIN-LINE\n", "utf8");
|
||||
initAuditLog(baseDir);
|
||||
const auditLogPath = getAuditLogPath();
|
||||
if (!auditLogPath) {
|
||||
throw new Error("audit log path missing");
|
||||
}
|
||||
logAuditEvent("INFO", "AUDIT-LINE", { scope: "settings" });
|
||||
|
||||
initRenameLog(baseDir);
|
||||
logRenameEvent("INFO", "RENAME-LINE", { stage: "auto-rename", sourcePath: "C:\\extract\\old.mkv" });
|
||||
|
||||
initTraceLog(baseDir);
|
||||
setTraceEnabled(true, "test-fixture");
|
||||
logTraceEvent("INFO", "support", "TRACE-EVENT", { scope: "fixture" });
|
||||
|
||||
initSessionLog(baseDir);
|
||||
const sessionLogPath = getSessionLogPath();
|
||||
if (!sessionLogPath) {
|
||||
throw new Error("session log path missing");
|
||||
}
|
||||
fs.appendFileSync(sessionLogPath, "2026-03-09T00:00:01.000Z [INFO] SESSION-LINE\n", "utf8");
|
||||
logger.info("TRACE-MAIN-LINE");
|
||||
|
||||
initPackageLogs(baseDir);
|
||||
initItemLogs(baseDir);
|
||||
const packageLogPath = ensurePackageLog({
|
||||
packageId: "pkg-1",
|
||||
name: "server-package",
|
||||
outputDir: snapshot.session.packages["pkg-1"]!.outputDir,
|
||||
extractDir: snapshot.session.packages["pkg-1"]!.extractDir
|
||||
});
|
||||
if (!packageLogPath) {
|
||||
throw new Error("package log path missing");
|
||||
}
|
||||
fs.appendFileSync(packageLogPath, "2026-03-09T00:00:02.000Z [INFO] PACKAGE-LINE\n", "utf8");
|
||||
const itemLogPath = ensureItemLog({
|
||||
itemId: "item-2",
|
||||
packageId: "pkg-1",
|
||||
packageName: "server-package",
|
||||
fileName: "episode.part2.rar",
|
||||
targetPath: snapshot.session.items["item-2"]!.targetPath
|
||||
});
|
||||
if (!itemLogPath) {
|
||||
throw new Error("item log path missing");
|
||||
}
|
||||
fs.appendFileSync(itemLogPath, "2026-03-09T00:00:03.000Z [ERROR] ITEM-LINE\n", "utf8");
|
||||
|
||||
const manager = {
|
||||
getSnapshot: () => snapshot,
|
||||
getPackageLogPath: (packageId: string) => packageId === "pkg-1" ? packageLogPath : null,
|
||||
getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null
|
||||
} as unknown as DownloadManager;
|
||||
|
||||
startDebugServer(manager, baseDir);
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
await waitForReady(`${baseUrl}/health?token=${token}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
baseDir
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
stopDebugServer();
|
||||
shutdownSessionLog();
|
||||
shutdownPackageLogs();
|
||||
shutdownItemLogs();
|
||||
shutdownRenameLog();
|
||||
shutdownTraceLog();
|
||||
shutdownAuditLog();
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (!dir) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("debug-server", () => {
|
||||
it("serves diagnostics with main, session, and package log tails", async () => {
|
||||
const fixture = await createFixture();
|
||||
const response = await fetch(`${fixture.baseUrl}/diagnostics?token=${fixture.token}&package=server-package&lines=20`);
|
||||
expect(response.ok).toBe(true);
|
||||
const payload = await response.json() as Record<string, any>;
|
||||
|
||||
expect(payload.meta?.appVersion).toBeTruthy();
|
||||
expect(payload.meta?.debugServer?.host).toBe("0.0.0.0");
|
||||
expect(payload.status?.running).toBe(true);
|
||||
expect(payload.host?.platform).toBe("win32");
|
||||
expect(payload.host?.recentKernelPower?.[0]?.id).toBe(41);
|
||||
expect(payload.selectedPackage?.name).toBe("server-package");
|
||||
expect((payload.logs?.main?.lines || []).join("\n")).toContain("MAIN-LINE");
|
||||
expect((payload.logs?.audit?.lines || []).join("\n")).toContain("AUDIT-LINE");
|
||||
expect((payload.logs?.rename?.lines || []).join("\n")).toContain("RENAME-LINE");
|
||||
expect((payload.logs?.trace?.lines || []).join("\n")).toContain("TRACE-EVENT");
|
||||
expect((payload.logs?.session?.lines || []).join("\n")).toContain("SESSION-LINE");
|
||||
expect((payload.logs?.package?.lines || []).join("\n")).toContain("PACKAGE-LINE");
|
||||
expect(payload.accounts?.realDebrid?.configured).toBe(true);
|
||||
expect(payload.history?.total).toBe(1);
|
||||
});
|
||||
|
||||
it("writes a machine-readable support manifest into the runtime folder", async () => {
|
||||
const fixture = await createFixture();
|
||||
const manifestPath = path.join(fixture.baseDir, "debug_support_manifest.json");
|
||||
expect(fs.existsSync(manifestPath)).toBe(true);
|
||||
expect(fs.existsSync(path.join(fixture.baseDir, legacyManifestFile))).toBe(false);
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<string, any>;
|
||||
expect(manifest.purpose).toBe("Machine-readable manifest for support tooling and remote troubleshooting.");
|
||||
expect(JSON.stringify(manifest)).not.toMatch(new RegExp(`\\b(?:${forbiddenSupportMarkers.join("|")})\\b`, "i"));
|
||||
expect(JSON.stringify(manifest)).not.toContain(fixture.token);
|
||||
expect(manifest.debugServer?.port).toBeGreaterThan(0);
|
||||
expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain("<SERVER_IP_OR_DNS>");
|
||||
expect(manifest.remoteAccessRequirements).toContain("A reachable server IP or DNS name.");
|
||||
expect(manifest.setupCheckEndpoint).toBe("/debug/setup");
|
||||
expect(manifest.selfCheckEndpoint).toBe("/self-check");
|
||||
expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt");
|
||||
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/diagnostics")).toBe(true);
|
||||
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/logs/main")).toBe(true);
|
||||
|
||||
const metaResponse = await fetch(`${fixture.baseUrl}/meta?token=${fixture.token}`);
|
||||
expect(metaResponse.ok).toBe(true);
|
||||
const metaPayload = await metaResponse.json() as Record<string, any>;
|
||||
expect(metaPayload.supportFiles?.supportManifest).toBe(manifestPath);
|
||||
expect(metaPayload.supportFiles?.[legacyManifestField]).toBeUndefined();
|
||||
expect(metaPayload.supportFiles?.traceConfig).toBe(getTraceConfigPath());
|
||||
expect(metaPayload.supportFiles?.traceLog).toBe(getTraceLogPath());
|
||||
expect(metaPayload.logPaths?.rename).toBe(getRenameLogPath());
|
||||
expect(metaPayload.supportChecks?.setup).toBe("/debug/setup");
|
||||
expect(metaPayload.supportChecks?.selfCheck).toBe("/self-check");
|
||||
});
|
||||
|
||||
it("serves a debug setup check with trace expiry details", async () => {
|
||||
const fixture = await createFixture();
|
||||
const response = await fetch(`${fixture.baseUrl}/debug/setup?token=${fixture.token}`);
|
||||
expect(response.ok).toBe(true);
|
||||
const payload = await response.json() as Record<string, any>;
|
||||
|
||||
expect(payload.enabled).toBe(true);
|
||||
expect(payload.status).toBe("ok");
|
||||
expect(payload.runtimeBaseDir).toBe(fixture.baseDir);
|
||||
expect(payload.host).toBe("0.0.0.0");
|
||||
expect(payload.localOnly).toBe(false);
|
||||
expect(payload.tokenConfigured).toBe(true);
|
||||
expect(payload.supportManifestPresent).toBe(true);
|
||||
expect(payload[`${legacyManifestField}Present`]).toBeUndefined();
|
||||
expect(payload.supportManifestPath).toBe(path.join(fixture.baseDir, "debug_support_manifest.json"));
|
||||
expect(payload.traceEnabled).toBe(true);
|
||||
expect(payload.traceAutoDisableAt).toBeTruthy();
|
||||
expect(payload.diskSpace?.runtime?.freeBytes).toBeGreaterThan(0);
|
||||
expect(payload.diskSpace?.output?.freeBytes).toBeGreaterThan(0);
|
||||
expect(payload.diskSpace?.extract?.freeBytes).toBeGreaterThan(0);
|
||||
expect(payload.logSummary?.totalBytes).toBeGreaterThan(0);
|
||||
expect(payload.logSummary?.rename?.bytes).toBeGreaterThan(0);
|
||||
expect(payload.logSummary?.packageLogs?.fileCount).toBe(1);
|
||||
expect(payload.logSummary?.itemLogs?.fileCount).toBe(1);
|
||||
expect(payload.supportBundle?.estimatedBytes).toBeGreaterThan(0);
|
||||
expect(payload.remoteUrlTemplates?.health).toContain("<SERVER_IP_OR_DNS>");
|
||||
expect(Array.isArray(payload.notes)).toBe(true);
|
||||
});
|
||||
|
||||
it("serves the self-check alias", async () => {
|
||||
const fixture = await createFixture();
|
||||
const response = await fetch(`${fixture.baseUrl}/self-check?token=${fixture.token}`);
|
||||
expect(response.ok).toBe(true);
|
||||
const payload = await response.json() as Record<string, any>;
|
||||
expect(payload.status).toBe("ok");
|
||||
expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("writes the client IP into the debug trace log", async () => {
|
||||
const fixture = await createFixture();
|
||||
const response = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`, {
|
||||
headers: {
|
||||
"X-Forwarded-For": "159.195.63.46"
|
||||
}
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const traceLogPath = getTraceLogPath();
|
||||
expect(traceLogPath).toBeTruthy();
|
||||
const traceText = fs.readFileSync(traceLogPath!, "utf8");
|
||||
expect(traceText).toContain("clientIp=159.195.63.46");
|
||||
});
|
||||
|
||||
it("serves package details and package log by package query", async () => {
|
||||
const fixture = await createFixture();
|
||||
|
||||
const packagesResponse = await fetch(`${fixture.baseUrl}/packages?token=${fixture.token}&package=server&includeItems=1`);
|
||||
expect(packagesResponse.ok).toBe(true);
|
||||
const packagesPayload = await packagesResponse.json() as Record<string, any>;
|
||||
expect(packagesPayload.count).toBe(1);
|
||||
expect(packagesPayload.packages?.[0]?.items?.length).toBe(2);
|
||||
|
||||
const logResponse = await fetch(`${fixture.baseUrl}/logs/package?token=${fixture.token}&package=server-package&lines=20`);
|
||||
expect(logResponse.ok).toBe(true);
|
||||
const logPayload = await logResponse.json() as Record<string, any>;
|
||||
expect(logPayload.package?.name).toBe("server-package");
|
||||
expect((logPayload.lines || []).join("\n")).toContain("PACKAGE-LINE");
|
||||
});
|
||||
|
||||
it("serves item log by item query", async () => {
|
||||
const fixture = await createFixture();
|
||||
|
||||
const response = await fetch(`${fixture.baseUrl}/logs/item?token=${fixture.token}&item=episode.part2.rar&lines=20`);
|
||||
expect(response.ok).toBe(true);
|
||||
const payload = await response.json() as Record<string, any>;
|
||||
expect(payload.item?.id).toBe("item-2");
|
||||
expect(payload.item?.fileName).toBe("episode.part2.rar");
|
||||
expect((payload.lines || []).join("\n")).toContain("ITEM-LINE");
|
||||
});
|
||||
|
||||
it("serves host diagnostics separately", async () => {
|
||||
const fixture = await createFixture();
|
||||
const response = await fetch(`${fixture.baseUrl}/host/diagnostics?token=${fixture.token}`);
|
||||
expect(response.ok).toBe(true);
|
||||
const payload = await response.json() as Record<string, any>;
|
||||
expect(payload.platform).toBe("win32");
|
||||
expect(payload.crashControl?.crashDumpEnabled).toBe(3);
|
||||
expect(payload.assessmentHints?.[0]).toContain("watchdog");
|
||||
});
|
||||
|
||||
it("serves audit log, settings, accounts, stats, and history", async () => {
|
||||
const fixture = await createFixture();
|
||||
|
||||
const auditResponse = await fetch(`${fixture.baseUrl}/logs/audit?token=${fixture.token}&lines=20`);
|
||||
expect(auditResponse.ok).toBe(true);
|
||||
const auditPayload = await auditResponse.json() as Record<string, any>;
|
||||
expect((auditPayload.lines || []).join("\n")).toContain("AUDIT-LINE");
|
||||
|
||||
const renameResponse = await fetch(`${fixture.baseUrl}/logs/rename?token=${fixture.token}&lines=20`);
|
||||
expect(renameResponse.ok).toBe(true);
|
||||
const renamePayload = await renameResponse.json() as Record<string, any>;
|
||||
expect((renamePayload.lines || []).join("\n")).toContain("RENAME-LINE");
|
||||
|
||||
const traceResponse = await fetch(`${fixture.baseUrl}/logs/trace?token=${fixture.token}&lines=50`);
|
||||
expect(traceResponse.ok).toBe(true);
|
||||
const tracePayload = await traceResponse.json() as Record<string, any>;
|
||||
expect((tracePayload.lines || []).join("\n")).toContain("TRACE-EVENT");
|
||||
expect((tracePayload.lines || []).join("\n")).toContain("TRACE-MAIN-LINE");
|
||||
|
||||
const traceConfigResponse = await fetch(`${fixture.baseUrl}/trace/config?token=${fixture.token}&enable=0¬e=test`);
|
||||
expect(traceConfigResponse.ok).toBe(true);
|
||||
const traceConfigPayload = await traceConfigResponse.json() as Record<string, any>;
|
||||
expect(traceConfigPayload.config?.enabled).toBe(false);
|
||||
|
||||
const settingsResponse = await fetch(`${fixture.baseUrl}/settings?token=${fixture.token}`);
|
||||
expect(settingsResponse.ok).toBe(true);
|
||||
const settingsPayload = await settingsResponse.json() as Record<string, any>;
|
||||
expect(settingsPayload.accounts?.realDebrid?.configured).toBe(true);
|
||||
expect(settingsPayload.extraction?.archivePasswordCount).toBe(0);
|
||||
expect(JSON.stringify(settingsPayload)).not.toContain("rd-secret-token");
|
||||
expect(JSON.stringify(settingsPayload)).not.toContain("key-a");
|
||||
expect(JSON.stringify(settingsPayload)).not.toContain("key-b");
|
||||
|
||||
const accountsResponse = await fetch(`${fixture.baseUrl}/accounts?token=${fixture.token}`);
|
||||
expect(accountsResponse.ok).toBe(true);
|
||||
const accountsPayload = await accountsResponse.json() as Record<string, any>;
|
||||
expect(accountsPayload.debridLink?.keyCount).toBe(2);
|
||||
expect(accountsPayload.debridLink?.disabledKeyCount).toBe(1);
|
||||
|
||||
const statsResponse = await fetch(`${fixture.baseUrl}/stats?token=${fixture.token}`);
|
||||
expect(statsResponse.ok).toBe(true);
|
||||
const statsPayload = await statsResponse.json() as Record<string, any>;
|
||||
expect(statsPayload.session?.totalDownloaded).toBeGreaterThan(0);
|
||||
expect(statsPayload.allTime?.totalDownloadedAllTime).toBeGreaterThan(0);
|
||||
|
||||
const historyResponse = await fetch(`${fixture.baseUrl}/history?token=${fixture.token}&limit=10`);
|
||||
expect(historyResponse.ok).toBe(true);
|
||||
const historyPayload = await historyResponse.json() as Record<string, any>;
|
||||
expect(historyPayload.total).toBe(1);
|
||||
expect(historyPayload.entries?.[0]?.name).toBe("server-package");
|
||||
expect(historyPayload.entries?.[0]?.urlCount).toBe(1);
|
||||
});
|
||||
|
||||
it("downloads a support bundle zip", async () => {
|
||||
const fixture = await createFixture();
|
||||
fs.writeFileSync(path.join(fixture.baseDir, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8");
|
||||
const response = await fetch(`${fixture.baseUrl}/support/bundle?token=${fixture.token}`);
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.headers.get("content-type")).toContain("application/zip");
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const zip = new AdmZip(buffer);
|
||||
const entries = zip.getEntries().map((entry) => entry.entryName);
|
||||
expect(entries).toContain("overview/settings.json");
|
||||
expect(entries).toContain("overview/accounts.json");
|
||||
expect(entries).toContain("overview/debug-setup.json");
|
||||
expect(entries).toContain("overview/self-check.json");
|
||||
expect(entries).toContain("overview/trace-config.json");
|
||||
expect(entries).toContain("logs/audit.log");
|
||||
expect(entries).toContain("logs/rename.log");
|
||||
expect(entries).toContain("logs/trace.log");
|
||||
expect(entries).toContain("runtime/debug_support_manifest.json");
|
||||
expect(entries).toContain("overview/support-manifest.json");
|
||||
expect(entries).not.toContain(`runtime/${legacyManifestFile}`);
|
||||
expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join(""));
|
||||
expect(entries).not.toContain("runtime/debug_token.txt");
|
||||
});
|
||||
|
||||
it("rejects unauthenticated requests", async () => {
|
||||
const fixture = await createFixture();
|
||||
const response = await fetch(`${fixture.baseUrl}/status`);
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
getDesktopRenameLogPath,
|
||||
initDesktopRenameLog,
|
||||
logDesktopRename,
|
||||
shutdownDesktopRenameLog,
|
||||
verifyRename
|
||||
} from "../src/main/desktop-rename-log";
|
||||
|
||||
const createdTmpDirs: string[] = [];
|
||||
|
||||
function tmpDesktop(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rename-log-"));
|
||||
createdTmpDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
shutdownDesktopRenameLog();
|
||||
for (const dir of createdTmpDirs) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
createdTmpDirs.length = 0;
|
||||
});
|
||||
|
||||
describe("desktop-rename-log", () => {
|
||||
it("creates the Downloader-Log folder + session file on init and appends formatted lines", () => {
|
||||
const desktop = tmpDesktop();
|
||||
initDesktopRenameLog(desktop);
|
||||
|
||||
const logPath = getDesktopRenameLogPath();
|
||||
expect(logPath).toBeTruthy();
|
||||
expect(path.dirname(logPath as string).endsWith("Downloader-Log")).toBe(true);
|
||||
expect(fs.existsSync(logPath as string)).toBe(true);
|
||||
|
||||
logDesktopRename("INFO", "Test-Rename", { source: "a.mkv", requested: "b.mkv" });
|
||||
const content = fs.readFileSync(logPath as string, "utf8");
|
||||
expect(content).toContain("Rename-Session gestartet");
|
||||
expect(content).toContain("Test-Rename");
|
||||
expect(content).toContain("source=a.mkv");
|
||||
expect(content).toContain("requested=b.mkv");
|
||||
expect(content).toMatch(/\[INFO\]/);
|
||||
});
|
||||
|
||||
it("self-heals: recreates the whole Downloader-Log FOLDER and file if it is deleted mid-session", () => {
|
||||
const desktop = tmpDesktop();
|
||||
initDesktopRenameLog(desktop);
|
||||
const logPath = getDesktopRenameLogPath() as string;
|
||||
logDesktopRename("INFO", "ZeileA");
|
||||
|
||||
fs.rmSync(path.join(desktop, "Downloader-Log"), { recursive: true, force: true });
|
||||
expect(fs.existsSync(logPath)).toBe(false);
|
||||
|
||||
logDesktopRename("INFO", "ZeileB");
|
||||
expect(fs.existsSync(path.join(desktop, "Downloader-Log"))).toBe(true);
|
||||
expect(fs.existsSync(logPath)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(logPath, "utf8");
|
||||
expect(content).toContain("Rename-Session gestartet");
|
||||
expect(content).toContain("ZeileB");
|
||||
});
|
||||
|
||||
it("is a silent no-op when initialized without a desktop path (never throws)", () => {
|
||||
initDesktopRenameLog("");
|
||||
expect(getDesktopRenameLogPath()).toBeNull();
|
||||
expect(() => logDesktopRename("INFO", "egal")).not.toThrow();
|
||||
});
|
||||
|
||||
it("verifyRename: ok when the target exists under the exact name and the source is gone", () => {
|
||||
const dir = tmpDesktop();
|
||||
const source = path.join(dir, "scn-xyz.part1.rar");
|
||||
const target = path.join(dir, "Movie.2024.German.1080p.part1.rar");
|
||||
fs.writeFileSync(target, "data");
|
||||
|
||||
const v = verifyRename(source, target);
|
||||
expect(v.ok).toBe(true);
|
||||
expect(v.level).toBe("INFO");
|
||||
expect(v.targetExists).toBe(true);
|
||||
expect(v.onDiskName).toBe("Movie.2024.German.1080p.part1.rar");
|
||||
expect(v.nameMatches).toBe(true);
|
||||
expect(v.sourceGone).toBe(true);
|
||||
expect(v.targetSize).toBe(4);
|
||||
});
|
||||
|
||||
it("verifyRename: FAILS when the target is missing although rename reported success", () => {
|
||||
const dir = tmpDesktop();
|
||||
const v = verifyRename(path.join(dir, "src.rar"), path.join(dir, "never-created.rar"));
|
||||
expect(v.ok).toBe(false);
|
||||
expect(v.level).toBe("ERROR");
|
||||
expect(v.targetExists).toBe(false);
|
||||
expect(v.reason).toMatch(/nicht gefunden/i);
|
||||
});
|
||||
|
||||
it("verifyRename: FAILS (half-done move) when the source still exists next to the target", () => {
|
||||
const dir = tmpDesktop();
|
||||
const source = path.join(dir, "src.rar");
|
||||
const target = path.join(dir, "dst.rar");
|
||||
fs.writeFileSync(source, "x");
|
||||
fs.writeFileSync(target, "x");
|
||||
|
||||
const v = verifyRename(source, target);
|
||||
expect(v.ok).toBe(false);
|
||||
expect(v.level).toBe("ERROR");
|
||||
expect(v.sourceGone).toBe(false);
|
||||
expect(v.reason).toMatch(/Quelldatei existiert noch/i);
|
||||
});
|
||||
|
||||
it("verifyRename: an in-place rename (same path) is ok and does not flag a lingering source", () => {
|
||||
const dir = tmpDesktop();
|
||||
const p = path.join(dir, "file.mkv");
|
||||
fs.writeFileSync(p, "x");
|
||||
|
||||
const v = verifyRename(p, p);
|
||||
expect(v.ok).toBe(true);
|
||||
expect(v.targetExists).toBe(true);
|
||||
expect(v.nameMatches).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
||||
|
||||
describe("download-completion", () => {
|
||||
describe("planDownloadCompletion", () => {
|
||||
it("uses content-length when present", () => {
|
||||
const plan = planDownloadCompletion({
|
||||
existingBytes: 0, responseStatus: 200, contentLength: 1000,
|
||||
totalFromRange: null, knownTotal: null, correctedTotal: null
|
||||
});
|
||||
expect(plan.source).toBe("content-length");
|
||||
expect(plan.expectedTotal).toBe(1000);
|
||||
});
|
||||
it("falls back to stream-end when no size info is available", () => {
|
||||
const plan = planDownloadCompletion({
|
||||
existingBytes: 0, responseStatus: 200, contentLength: 0,
|
||||
totalFromRange: null, knownTotal: null, correctedTotal: null
|
||||
});
|
||||
expect(plan.source).toBe("stream-end");
|
||||
expect(plan.expectedTotal).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateDownloadedFileCompletion", () => {
|
||||
const streamEnd = { expectedTotal: null, source: "stream-end" as const, canFinishEarly: false };
|
||||
const contentLength = (n: number) => ({ expectedTotal: n, source: "content-length" as const, canFinishEarly: true });
|
||||
const providerMeta = (n: number) => ({ expectedTotal: n, source: "provider-metadata" as const, canFinishEarly: false });
|
||||
|
||||
it("rejects a 0-byte stream-end download (H3)", () => {
|
||||
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: streamEnd });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("download_underflow");
|
||||
});
|
||||
|
||||
it("accepts a non-empty stream-end download", () => {
|
||||
const result = validateDownloadedFileCompletion({ actualBytes: 5_000_000, plan: streamEnd });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.totalBytes).toBe(5_000_000);
|
||||
});
|
||||
|
||||
it("rejects an underflowing content-length download", () => {
|
||||
const result = validateDownloadedFileCompletion({ actualBytes: 400, plan: contentLength(1000), toleranceBytes: 0 });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a complete content-length download", () => {
|
||||
const result = validateDownloadedFileCompletion({ actualBytes: 1000, plan: contentLength(1000) });
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a 0-byte download even with known provider size", () => {
|
||||
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: providerMeta(2000) });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts provider-metadata download and flags size mismatch", () => {
|
||||
const result = validateDownloadedFileCompletion({ actualBytes: 1900, plan: providerMeta(2000), toleranceBytes: 0 });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileFinalizedSize", () => {
|
||||
it("keeps the streamed count for a pre-allocated file whose on-disk size is the zero-padding (corruption guard)", () => {
|
||||
expect(reconcileFinalizedSize(300_000_000, 1_000_000_000, true)).toBe(300_000_000);
|
||||
});
|
||||
|
||||
it("shrinks to the on-disk size when a pre-allocated file is genuinely short (real partial write)", () => {
|
||||
expect(reconcileFinalizedSize(500, 300, true)).toBe(300);
|
||||
});
|
||||
|
||||
it("reconciles in both directions for a non-pre-allocated file (stat is authoritative)", () => {
|
||||
expect(reconcileFinalizedSize(300, 1000, false)).toBe(1000);
|
||||
expect(reconcileFinalizedSize(1000, 300, false)).toBe(300);
|
||||
});
|
||||
|
||||
it("returns the streamed count unchanged when the stat is invalid", () => {
|
||||
expect(reconcileFinalizedSize(1234, Number.NaN, true)).toBe(1234);
|
||||
expect(reconcileFinalizedSize(1234, -1, false)).toBe(1234);
|
||||
});
|
||||
|
||||
it("is a no-op when on-disk size already equals the streamed count", () => {
|
||||
expect(reconcileFinalizedSize(777, 777, true)).toBe(777);
|
||||
expect(reconcileFinalizedSize(777, 777, false)).toBe(777);
|
||||
});
|
||||
|
||||
it("does not block legitimate overshoot on a pre-allocated file (server sent more than pre-alloc)", () => {
|
||||
expect(reconcileFinalizedSize(900, 900, true)).toBe(900);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createErrorRing } from "../src/main/error-ring";
|
||||
|
||||
describe("createErrorRing", () => {
|
||||
it("keeps entries in insertion order", () => {
|
||||
const ring = createErrorRing(10);
|
||||
ring.push({ ts: "t1", level: "ERROR", message: "a" });
|
||||
ring.push({ ts: "t2", level: "WARN", message: "b" });
|
||||
expect(ring.snapshot().map((e) => e.message)).toEqual(["a", "b"]);
|
||||
expect(ring.size()).toBe(2);
|
||||
});
|
||||
|
||||
it("caps at capacity by dropping the oldest", () => {
|
||||
const ring = createErrorRing(3);
|
||||
for (const m of ["a", "b", "c", "d", "e"]) {
|
||||
ring.push({ ts: m, level: "ERROR", message: m });
|
||||
}
|
||||
expect(ring.snapshot().map((e) => e.message)).toEqual(["c", "d", "e"]);
|
||||
expect(ring.size()).toBe(3);
|
||||
});
|
||||
|
||||
it("snapshot returns a copy, not the live buffer", () => {
|
||||
const ring = createErrorRing(5);
|
||||
ring.push({ ts: "t", level: "WARN", message: "x" });
|
||||
const snap = ring.snapshot();
|
||||
snap.push({ ts: "t2", level: "ERROR", message: "injected" });
|
||||
expect(ring.snapshot().map((e) => e.message)).toEqual(["x"]);
|
||||
});
|
||||
|
||||
it("clear empties the ring", () => {
|
||||
const ring = createErrorRing(5);
|
||||
ring.push({ ts: "t", level: "ERROR", message: "x" });
|
||||
ring.clear();
|
||||
expect(ring.snapshot()).toEqual([]);
|
||||
expect(ring.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("coerces a non-positive capacity to at least 1", () => {
|
||||
const ring = createErrorRing(0);
|
||||
ring.push({ ts: "t1", level: "ERROR", message: "a" });
|
||||
ring.push({ ts: "t2", level: "ERROR", message: "b" });
|
||||
expect(ring.snapshot().map((e) => e.message)).toEqual(["b"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { extractPackageArchives } from "../src/main/extractor";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const originalBackend = process.env.RD_EXTRACT_BACKEND;
|
||||
|
||||
function hasJavaRuntime(): boolean {
|
||||
const result = spawnSync("java", ["-version"], { stdio: "ignore" });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function hasJvmExtractorRuntime(): boolean {
|
||||
const root = path.join(process.cwd(), "resources", "extractor-jvm");
|
||||
const classesMain = path.join(root, "classes", "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class");
|
||||
const requiredLibs = [
|
||||
path.join(root, "lib", "sevenzipjbinding.jar"),
|
||||
path.join(root, "lib", "sevenzipjbinding-all-platforms.jar"),
|
||||
path.join(root, "lib", "zip4j.jar")
|
||||
];
|
||||
return fs.existsSync(classesMain) && requiredLibs.every((libPath) => fs.existsSync(libPath));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
if (originalBackend === undefined) {
|
||||
delete process.env.RD_EXTRACT_BACKEND;
|
||||
} else {
|
||||
process.env.RD_EXTRACT_BACKEND = originalBackend;
|
||||
}
|
||||
});
|
||||
|
||||
describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm backend", () => {
|
||||
it("extracts zip archives through SevenZipJBinding backend", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-extract-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
const zipPath = path.join(packageDir, "release.zip");
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("episode.txt", Buffer.from("ok"));
|
||||
zip.writeZip(zipPath);
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false
|
||||
});
|
||||
|
||||
expect(result.extracted).toBe(1);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(fs.existsSync(path.join(targetDir, "episode.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("emits progress callbacks with archiveName and percent", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-progress-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
const zipPath = path.join(packageDir, "progress-test.zip");
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100)));
|
||||
zip.addFile("file2.txt", Buffer.from("Another file ".repeat(100)));
|
||||
zip.writeZip(zipPath);
|
||||
|
||||
const progressUpdates: Array<{
|
||||
archiveName: string;
|
||||
percent: number;
|
||||
phase: string;
|
||||
archivePercent?: number;
|
||||
}> = [];
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
onProgress: (update) => {
|
||||
progressUpdates.push({
|
||||
archiveName: update.archiveName,
|
||||
percent: update.percent,
|
||||
phase: update.phase,
|
||||
archivePercent: update.archivePercent,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.extracted).toBe(1);
|
||||
expect(result.failed).toBe(0);
|
||||
|
||||
const phases = new Set(progressUpdates.map((u) => u.phase));
|
||||
expect(phases.has("preparing")).toBe(true);
|
||||
expect(phases.has("extracting")).toBe(true);
|
||||
|
||||
const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip");
|
||||
expect(extracting.length).toBeGreaterThan(0);
|
||||
|
||||
const lastExtracting = extracting[extracting.length - 1];
|
||||
expect(lastExtracting.archivePercent).toBe(100);
|
||||
|
||||
expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("extracts multiple archives sequentially with progress for each", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-multi-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
|
||||
const zip1 = new AdmZip();
|
||||
zip1.addFile("episode01.txt", Buffer.from("ep1 content"));
|
||||
zip1.writeZip(path.join(packageDir, "archive1.zip"));
|
||||
|
||||
const zip2 = new AdmZip();
|
||||
zip2.addFile("episode02.txt", Buffer.from("ep2 content"));
|
||||
zip2.writeZip(path.join(packageDir, "archive2.zip"));
|
||||
|
||||
const archiveNames = new Set<string>();
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "overwrite",
|
||||
removeLinks: false,
|
||||
removeSamples: false,
|
||||
onProgress: (update) => {
|
||||
if (update.phase === "extracting" && update.archiveName) {
|
||||
archiveNames.add(update.archiveName);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.extracted).toBe(2);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(archiveNames.has("archive1.zip")).toBe(true);
|
||||
expect(archiveNames.has("archive2.zip")).toBe(true);
|
||||
expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
it("respects ask/skip conflict mode in jvm backend", async () => {
|
||||
process.env.RD_EXTRACT_BACKEND = "jvm";
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-jvm-extract-"));
|
||||
tempDirs.push(root);
|
||||
const packageDir = path.join(root, "pkg");
|
||||
const targetDir = path.join(root, "out");
|
||||
fs.mkdirSync(packageDir, { recursive: true });
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
const zipPath = path.join(packageDir, "conflict.zip");
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("same.txt", Buffer.from("new"));
|
||||
zip.writeZip(zipPath);
|
||||
|
||||
const existingPath = path.join(targetDir, "same.txt");
|
||||
fs.writeFileSync(existingPath, "old", "utf8");
|
||||
|
||||
const result = await extractPackageArchives({
|
||||
packageDir,
|
||||
targetDir,
|
||||
cleanupMode: "none",
|
||||
conflictMode: "ask",
|
||||
removeLinks: false,
|
||||
removeSamples: false
|
||||
});
|
||||
|
||||
expect(result.extracted).toBe(1);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(fs.readFileSync(existingPath, "utf8")).toBe("old");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifyDiskError } from "../src/main/fs-error";
|
||||
import { isDebugFlagEnabled } from "../src/main/logger";
|
||||
|
||||
describe("classifyDiskError", () => {
|
||||
it("maps ENOSPC from an error code to a disk-full reason", () => {
|
||||
const err = Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" });
|
||||
expect(classifyDiskError(err)).toMatch(/Festplatte voll/);
|
||||
});
|
||||
|
||||
it("maps EACCES from a code to a permission reason", () => {
|
||||
const err = Object.assign(new Error("nope"), { code: "EACCES" });
|
||||
expect(classifyDiskError(err)).toMatch(/Zugriff verweigert/);
|
||||
});
|
||||
|
||||
it("lower-case codes are normalized", () => {
|
||||
const err = Object.assign(new Error("x"), { code: "enospc" });
|
||||
expect(classifyDiskError(err)).toMatch(/ENOSPC/);
|
||||
});
|
||||
|
||||
it("falls back to scanning the message text when no code is present", () => {
|
||||
expect(classifyDiskError(new Error("operation failed: ENOSPC on volume"))).toMatch(/Festplatte voll/);
|
||||
});
|
||||
|
||||
it("handles a plain string error", () => {
|
||||
expect(classifyDiskError("EROFS: read-only file system")).toMatch(/schreibgeschützt/);
|
||||
});
|
||||
|
||||
it("returns null for an unrelated error", () => {
|
||||
expect(classifyDiskError(new Error("write_drain_timeout"))).toBeNull();
|
||||
expect(classifyDiskError(new Error("premature close"))).toBeNull();
|
||||
expect(classifyDiskError(null)).toBeNull();
|
||||
expect(classifyDiskError(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDebugFlagEnabled", () => {
|
||||
it("is true for affirmative values", () => {
|
||||
for (const v of ["1", "true", "TRUE", "yes", "on", " on "]) {
|
||||
expect(isDebugFlagEnabled(v)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("is false for empty/negative/garbage values", () => {
|
||||
for (const v of [undefined, "", "0", "false", "off", "no", "maybe"]) {
|
||||
expect(isDebugFlagEnabled(v)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock only processVideoFile (the ffmpeg boundary); keep the real pure helpers
|
||||
// (stripDualLangMarker / hasDualLangMarker / isRemuxableVideoFile) so the
|
||||
// download-manager's selection + .DL.-rename wiring is exercised for real.
|
||||
vi.mock("../src/main/video-processor", async (importActual) => {
|
||||
const actual = await importActual<typeof import("../src/main/video-processor")>();
|
||||
return { ...actual, processVideoFile: vi.fn(), resolveVideoTooling: vi.fn() };
|
||||
});
|
||||
|
||||
import { DownloadManager } from "../src/main/download-manager";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||
import { shutdownItemLogs } from "../src/main/item-log";
|
||||
import { shutdownPackageLogs } from "../src/main/package-log";
|
||||
import { shutdownRenameLog } from "../src/main/rename-log";
|
||||
import { processVideoFile, resolveVideoTooling, type VideoProcessResult } from "../src/main/video-processor";
|
||||
|
||||
const mockedProcess = processVideoFile as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockedTooling = resolveVideoTooling as unknown as ReturnType<typeof vi.fn>;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
mockedProcess.mockReset();
|
||||
mockedTooling.mockReset();
|
||||
shutdownItemLogs();
|
||||
shutdownPackageLogs();
|
||||
shutdownRenameLog();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
function setup(keepGermanAudioOnly: boolean): { extractDir: string; manager: DownloadManager; pkg: any } {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ga-"));
|
||||
tempDirs.push(root);
|
||||
const extractDir = path.join(root, "extract");
|
||||
const stateDir = path.join(root, "state");
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
keepGermanAudioOnly,
|
||||
germanAudioMode: "tag",
|
||||
autoRename4sf4sj: false,
|
||||
outputDir: path.join(root, "out"),
|
||||
extractDir,
|
||||
mkvLibraryDir: path.join(stateDir, "_mkv")
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(stateDir)
|
||||
);
|
||||
const pkg: any = {
|
||||
id: "ga-pkg-1",
|
||||
name: "Test.Show.S01.GERMAN.DL.720p",
|
||||
outputDir: path.join(root, "out", "Test.Show"),
|
||||
extractDir,
|
||||
status: "completed",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
priority: "normal",
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
};
|
||||
// Default: ffmpeg/ffprobe "available" so the step proceeds to the (mocked)
|
||||
// processVideoFile. Tests that need the no-tool path override this.
|
||||
mockedTooling.mockResolvedValue({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" });
|
||||
return { extractDir, manager, pkg };
|
||||
}
|
||||
|
||||
const DL_MKV = "Show.S01E01.German.DL.720p.x264.mkv";
|
||||
const PLAIN_MKV = "Show.S01E02.German.1080p.x264.mkv";
|
||||
const SAMPLE_DL = "Show.sample.DL.mkv";
|
||||
const DL_AVI = "Show.S01E03.German.DL.avi";
|
||||
|
||||
function stage(extractDir: string): void {
|
||||
for (const f of [DL_MKV, PLAIN_MKV, SAMPLE_DL, DL_AVI]) {
|
||||
fs.writeFileSync(path.join(extractDir, f), "x");
|
||||
}
|
||||
}
|
||||
|
||||
describe("keepGermanAudioOnly integration", () => {
|
||||
it("processes only .DL. mkv/mp4 and strips .DL. after a successful remux", async () => {
|
||||
const { extractDir, manager, pkg } = setup(true);
|
||||
stage(extractDir);
|
||||
mockedProcess.mockResolvedValue({ action: "remuxed", reason: "german-tag", totalAudioTracks: 2, keptTrackIndex: 0 } as VideoProcessResult);
|
||||
|
||||
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
||||
|
||||
expect(mockedProcess).toHaveBeenCalledTimes(1);
|
||||
expect(mockedProcess.mock.calls[0][0]).toBe(path.join(extractDir, DL_MKV));
|
||||
expect(n).toBe(1);
|
||||
|
||||
const files = fs.readdirSync(extractDir);
|
||||
expect(files).toContain("Show.S01E01.German.720p.x264.mkv"); // .DL. stripped
|
||||
expect(files).not.toContain(DL_MKV);
|
||||
expect(files).toContain(PLAIN_MKV); // non-.DL. untouched
|
||||
expect(files).toContain(SAMPLE_DL); // sample skipped
|
||||
expect(files).toContain(DL_AVI); // avi not remuxable, skipped
|
||||
});
|
||||
|
||||
it("does nothing when the setting is off", async () => {
|
||||
const { extractDir, manager, pkg } = setup(false);
|
||||
stage(extractDir);
|
||||
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
||||
expect(n).toBe(0);
|
||||
expect(mockedProcess).not.toHaveBeenCalled();
|
||||
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
|
||||
});
|
||||
|
||||
it("leaves the file fully untouched (name included) when no German track is found", async () => {
|
||||
const { extractDir, manager, pkg } = setup(true);
|
||||
stage(extractDir);
|
||||
mockedProcess.mockResolvedValue({ action: "skipped-no-german", reason: "no-german-track", totalAudioTracks: 2 } as VideoProcessResult);
|
||||
|
||||
await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
||||
|
||||
expect(mockedProcess).toHaveBeenCalledTimes(1);
|
||||
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // NOT renamed -> stays visible as unprocessed
|
||||
});
|
||||
|
||||
it("still strips .DL. for a single-audio file (no remux needed)", async () => {
|
||||
const { extractDir, manager, pkg } = setup(true);
|
||||
stage(extractDir);
|
||||
mockedProcess.mockResolvedValue({ action: "kept-single", reason: "single-german", totalAudioTracks: 1, keptTrackIndex: 0 } as VideoProcessResult);
|
||||
|
||||
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
||||
|
||||
expect(n).toBe(0); // not counted as a remux
|
||||
expect(fs.readdirSync(extractDir)).toContain("Show.S01E01.German.720p.x264.mkv");
|
||||
});
|
||||
|
||||
it("skips up front (no processVideoFile calls) and leaves files untouched when ffmpeg is missing", async () => {
|
||||
const { extractDir, manager, pkg } = setup(true);
|
||||
stage(extractDir);
|
||||
mockedTooling.mockResolvedValue(null); // ffmpeg/ffprobe not found
|
||||
|
||||
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
||||
|
||||
expect(n).toBe(0);
|
||||
expect(mockedProcess).not.toHaveBeenCalled(); // bailed before touching any file
|
||||
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
|
||||
expect(pkg.audioStripSummary).toMatchObject({ candidates: 1, skippedNoTool: 1, remuxed: 0 });
|
||||
expect(pkg.audioStripSummary.files[0]).toMatchObject({ name: DL_MKV, action: "skipped-no-tool" });
|
||||
});
|
||||
|
||||
it("stores a per-package summary with counts and file details", async () => {
|
||||
const { extractDir, manager, pkg } = setup(true);
|
||||
stage(extractDir);
|
||||
mockedProcess.mockResolvedValue({ action: "skipped-no-german", reason: "no-german-track", totalAudioTracks: 2, audioLanguages: ["eng", "fre"] } as VideoProcessResult);
|
||||
|
||||
await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
||||
|
||||
expect(pkg.audioStripSummary).toMatchObject({ candidates: 1, skippedNoGerman: 1, remuxed: 0, failed: 0 });
|
||||
expect(pkg.audioStripSummary.files).toHaveLength(1);
|
||||
expect(pkg.audioStripSummary.files[0]).toMatchObject({ name: DL_MKV, action: "skipped-no-german", reason: "no-german-track", languages: "eng,fre" });
|
||||
expect(pkg.updatedAt).toBeGreaterThan(0); // bumped so the snapshot delta picks it up
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { DownloadManager } from "../src/main/download-manager";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||
import { shutdownItemLogs } from "../src/main/item-log";
|
||||
import { shutdownPackageLogs } from "../src/main/package-log";
|
||||
import { shutdownRenameLog } from "../src/main/rename-log";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
shutdownItemLogs();
|
||||
shutdownPackageLogs();
|
||||
shutdownRenameLog();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
const DL_MKV = "Show.S01E01.German.DL.720p.x264.mkv";
|
||||
const PLAIN_MKV = "Show.S01E02.German.720p.x264.mkv";
|
||||
const DL_AVI = "Show.S01E03.German.DL.avi";
|
||||
|
||||
function setup(keepGermanAudioOnly: boolean): { extractDir: string; libraryDir: string; manager: DownloadManager; pkg: any } {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-race-"));
|
||||
tempDirs.push(root);
|
||||
const extractDir = path.join(root, "extract");
|
||||
const stateDir = path.join(root, "state");
|
||||
const libraryDir = path.join(root, "library");
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
autoExtract: true,
|
||||
collectMkvToLibrary: true,
|
||||
keepGermanAudioOnly,
|
||||
germanAudioMode: "tag",
|
||||
autoRename4sf4sj: false,
|
||||
outputDir: path.join(root, "out"),
|
||||
extractDir,
|
||||
mkvLibraryDir: libraryDir
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(stateDir)
|
||||
);
|
||||
const pkg: any = {
|
||||
id: "race-pkg-1",
|
||||
name: "Show.S01.GERMAN.DL.720p",
|
||||
outputDir: path.join(root, "out", "Show.S01"),
|
||||
extractDir,
|
||||
status: "completed",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
priority: "normal",
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
};
|
||||
for (const f of [DL_MKV, PLAIN_MKV, DL_AVI]) {
|
||||
fs.writeFileSync(path.join(extractDir, f), "x");
|
||||
}
|
||||
return { extractDir, libraryDir, manager, pkg };
|
||||
}
|
||||
|
||||
function libraryNames(libraryDir: string): string[] {
|
||||
try { return fs.readdirSync(libraryDir); } catch { return []; }
|
||||
}
|
||||
|
||||
describe("Hybrid-Sammel Race-Schutz (.DL. noch nicht tonspur-bereinigt)", () => {
|
||||
it("haelt eine remuxbare .DL.-Datei im Hybrid-Lauf zurueck (keepGermanAudioOnly an)", async () => {
|
||||
const { extractDir, libraryDir, manager, pkg } = setup(true);
|
||||
|
||||
await (manager as any).collectMkvFilesToLibrary(pkg.id, pkg, undefined, true);
|
||||
|
||||
// Race-Opfer bleibt in extractDir, damit eine spaetere Runde / der Deferred-Pass es bereinigt
|
||||
expect(fs.existsSync(path.join(extractDir, DL_MKV))).toBe(true);
|
||||
expect(libraryNames(libraryDir)).not.toContain(DL_MKV);
|
||||
|
||||
// Praezision: bereits bereinigte mkv (kein .DL.) wird gesammelt
|
||||
expect(fs.existsSync(path.join(extractDir, PLAIN_MKV))).toBe(false);
|
||||
// Praezision: .DL.avi ist nicht remuxbar -> wird NICHT zurueckgehalten, sondern gesammelt
|
||||
expect(fs.existsSync(path.join(extractDir, DL_AVI))).toBe(false);
|
||||
});
|
||||
|
||||
it("sammelt die remuxbare .DL.-Datei im Deferred-Lauf (deferFreshFiles=false)", async () => {
|
||||
const { extractDir, libraryDir, manager, pkg } = setup(true);
|
||||
|
||||
await (manager as any).collectMkvFilesToLibrary(pkg.id, pkg, undefined, false);
|
||||
|
||||
expect(fs.existsSync(path.join(extractDir, DL_MKV))).toBe(false);
|
||||
expect(libraryNames(libraryDir)).toContain(DL_MKV);
|
||||
});
|
||||
|
||||
it("haelt nichts zurueck wenn keepGermanAudioOnly aus ist (.DL. ist dann normaler Output)", async () => {
|
||||
const { extractDir, manager, pkg } = setup(false);
|
||||
|
||||
await (manager as any).collectMkvFilesToLibrary(pkg.id, pkg, undefined, true);
|
||||
|
||||
expect(fs.existsSync(path.join(extractDir, DL_MKV))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { parseHashLine, readHashManifest, validateFileAgainstManifest } from "../src/main/integrity";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("integrity", () => {
|
||||
it("parses md5 and sfv lines", () => {
|
||||
const md = parseHashLine("d41d8cd98f00b204e9800998ecf8427e sample.bin");
|
||||
expect(md?.algorithm).toBe("md5");
|
||||
const sfv = parseHashLine("sample.bin 1A2B3C4D");
|
||||
expect(sfv?.algorithm).toBe("crc32");
|
||||
});
|
||||
|
||||
it("validates file against md5 manifest", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, "movie.bin");
|
||||
fs.writeFileSync(filePath, Buffer.from("hello"));
|
||||
fs.writeFileSync(path.join(dir, "hash.md5"), "5d41402abc4b2a76b9719d911017c592 movie.bin\n");
|
||||
const result = await validateFileAgainstManifest(filePath, dir);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("skips manifest files larger than 5MB", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
const largeContent = "d41d8cd98f00b204e9800998ecf8427e sample.bin\n".repeat(200000);
|
||||
const manifestPath = path.join(dir, "hashes.md5");
|
||||
fs.writeFileSync(manifestPath, largeContent, "utf8");
|
||||
|
||||
const stat = fs.statSync(manifestPath);
|
||||
expect(stat.size).toBeGreaterThan(5 * 1024 * 1024);
|
||||
|
||||
const manifest = readHashManifest(dir);
|
||||
expect(manifest.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not parse SHA256 (64-char hex) as valid hash", () => {
|
||||
const sha256Line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 emptyfile.bin";
|
||||
const result = parseHashLine(sha256Line);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("parses SHA1 hash lines correctly", () => {
|
||||
const sha1Line = "da39a3ee5e6b4b0d3255bfef95601890afd80709 emptyfile.bin";
|
||||
const result = parseHashLine(sha1Line);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.algorithm).toBe("sha1");
|
||||
expect(result?.digest).toBe("da39a3ee5e6b4b0d3255bfef95601890afd80709");
|
||||
expect(result?.fileName).toBe("emptyfile.bin");
|
||||
});
|
||||
|
||||
it("ignores comment lines in hash manifests", () => {
|
||||
expect(parseHashLine("; This is a comment")).toBeNull();
|
||||
expect(parseHashLine("")).toBeNull();
|
||||
expect(parseHashLine(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("trusts the per-line algorithm over the file extension for a mislabeled manifest (.sfv holding md5 lines)", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
||||
tempDirs.push(dir);
|
||||
const filePath = path.join(dir, "movie.bin");
|
||||
fs.writeFileSync(filePath, Buffer.from("hello"));
|
||||
fs.writeFileSync(path.join(dir, "checksums.sfv"), "5d41402abc4b2a76b9719d911017c592 movie.bin\n", "utf8");
|
||||
|
||||
const manifest = readHashManifest(dir);
|
||||
expect(manifest.get("movie.bin")?.algorithm).toBe("md5");
|
||||
|
||||
const result = await validateFileAgainstManifest(filePath, dir);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.message).toContain("MD5");
|
||||
});
|
||||
|
||||
it("keeps first hash entry when duplicate filename appears across manifests", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
||||
tempDirs.push(dir);
|
||||
|
||||
fs.writeFileSync(path.join(dir, "disc1.md5"), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa movie.mkv\n", "utf8");
|
||||
fs.writeFileSync(path.join(dir, "disc2.md5"), "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb movie.mkv\n", "utf8");
|
||||
|
||||
const manifest = readHashManifest(dir);
|
||||
expect(manifest.get("movie.mkv")?.digest).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ensureItemLog, getItemLogPath, initItemLogs, logItemEvent, shutdownItemLogs } from "../src/main/item-log";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
shutdownItemLogs();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("item-log", () => {
|
||||
it("creates a persistent item log file", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initItemLogs(baseDir);
|
||||
const logPath = ensureItemLog({
|
||||
itemId: "item-1",
|
||||
packageId: "pkg-1",
|
||||
packageName: "Test Paket",
|
||||
fileName: "episode.part2.rar",
|
||||
targetPath: "C:\\downloads\\Test Paket\\episode.part2.rar"
|
||||
});
|
||||
|
||||
expect(logPath).not.toBeNull();
|
||||
expect(fs.existsSync(logPath!)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("Item-Log Start");
|
||||
expect(content).toContain("episode.part2.rar");
|
||||
});
|
||||
|
||||
it("writes detail events into the item log", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initItemLogs(baseDir);
|
||||
ensureItemLog({
|
||||
itemId: "item-2",
|
||||
packageId: "pkg-2",
|
||||
packageName: "Detail Paket",
|
||||
fileName: "episode.part2.rar",
|
||||
targetPath: "C:\\downloads\\Detail Paket\\episode.part2.rar"
|
||||
});
|
||||
|
||||
logItemEvent("item-2", "ERROR", "Entpack-Fehler", {
|
||||
archive: "episode.part2.rar",
|
||||
code: "missing_parts",
|
||||
detail: "Unexpected end of archive"
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
|
||||
const logPath = getItemLogPath("item-2");
|
||||
expect(logPath).not.toBeNull();
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("Entpack-Fehler");
|
||||
expect(content).toContain("archive=episode.part2.rar");
|
||||
expect(content).toContain("code=missing_parts");
|
||||
});
|
||||
|
||||
it("keeps traversal-like item ids inside the item log directory", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initItemLogs(baseDir);
|
||||
const logPath = ensureItemLog({
|
||||
itemId: "..\\..\\outside",
|
||||
packageId: "pkg-traversal",
|
||||
packageName: "Traversal Paket",
|
||||
fileName: "episode.part2.rar",
|
||||
targetPath: "C:\\downloads\\Traversal Paket\\episode.part2.rar"
|
||||
});
|
||||
|
||||
expect(logPath).not.toBeNull();
|
||||
const logsDir = path.resolve(path.join(baseDir, "item-logs"));
|
||||
const resolvedLogPath = path.resolve(logPath!);
|
||||
expect(resolvedLogPath === logsDir || resolvedLogPath.startsWith(`${logsDir}${path.sep}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildLinkExportSelection, serializeLinkExportText } from "../src/main/link-export";
|
||||
import { parseCollectorInput } from "../src/main/link-parser";
|
||||
import type { UiSnapshot } from "../src/shared/types";
|
||||
|
||||
function buildSnapshot(): UiSnapshot {
|
||||
return {
|
||||
settings: {} as UiSnapshot["settings"],
|
||||
session: {
|
||||
version: 1,
|
||||
packageOrder: ["pkg-1", "pkg-2"],
|
||||
packages: {
|
||||
"pkg-1": {
|
||||
id: "pkg-1",
|
||||
name: "Dave Staffel 1",
|
||||
outputDir: "C:\\Downloads\\Dave Staffel 1",
|
||||
extractDir: "C:\\Extract\\Dave Staffel 1",
|
||||
status: "queued",
|
||||
itemIds: ["item-1", "item-2"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
priority: "normal",
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
"pkg-2": {
|
||||
id: "pkg-2",
|
||||
name: "Andere Staffel",
|
||||
outputDir: "C:\\Downloads\\Andere Staffel",
|
||||
extractDir: "C:\\Extract\\Andere Staffel",
|
||||
status: "queued",
|
||||
itemIds: ["item-3"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
priority: "normal",
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
},
|
||||
items: {
|
||||
"item-1": {
|
||||
id: "item-1",
|
||||
packageId: "pkg-1",
|
||||
url: "https://example.com/e01",
|
||||
provider: null,
|
||||
status: "queued",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fileName: "Dave.S01E01.rar",
|
||||
targetPath: "",
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "Wartet",
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
"item-2": {
|
||||
id: "item-2",
|
||||
packageId: "pkg-1",
|
||||
url: "https://example.com/e02",
|
||||
provider: null,
|
||||
status: "queued",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fileName: "Dave.S01E02.rar",
|
||||
targetPath: "",
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "Wartet",
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
"item-3": {
|
||||
id: "item-3",
|
||||
packageId: "pkg-2",
|
||||
url: "https://example.com/other",
|
||||
provider: null,
|
||||
status: "queued",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fileName: "Andere.S01E01.rar",
|
||||
targetPath: "",
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "Wartet",
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
},
|
||||
runStartedAt: 0,
|
||||
totalDownloadedBytes: 0,
|
||||
summaryText: "",
|
||||
reconnectUntil: 0,
|
||||
reconnectReason: "",
|
||||
paused: false,
|
||||
running: false,
|
||||
updatedAt: 1
|
||||
},
|
||||
summary: null,
|
||||
stats: {
|
||||
totalDownloaded: 0,
|
||||
totalDownloadedAllTime: 0,
|
||||
totalFilesSession: 0,
|
||||
totalFilesAllTime: 0,
|
||||
totalPackages: 2,
|
||||
sessionStartedAt: 0,
|
||||
appSessionStartedAt: 0,
|
||||
sessionRuntimeMs: 0,
|
||||
totalRuntimeMs: 0,
|
||||
runtimeMeasuredAt: 0
|
||||
},
|
||||
speedText: "",
|
||||
etaText: "",
|
||||
canStart: true,
|
||||
canStop: false,
|
||||
canPause: false,
|
||||
clipboardActive: false,
|
||||
reconnectSeconds: 0,
|
||||
packageSpeedBps: {}
|
||||
};
|
||||
}
|
||||
|
||||
describe("link-export", () => {
|
||||
it("keeps original package names when exporting selected items", () => {
|
||||
const selection = buildLinkExportSelection(buildSnapshot(), [], ["item-1", "item-3"]);
|
||||
expect(selection.packageCount).toBe(2);
|
||||
expect(selection.linkCount).toBe(2);
|
||||
expect(selection.packages.map((pkg) => pkg.name)).toEqual(["Dave Staffel 1", "Andere Staffel"]);
|
||||
});
|
||||
|
||||
it("roundtrips exported text back into parsed package inputs", () => {
|
||||
const selection = buildLinkExportSelection(buildSnapshot(), [], ["item-1", "item-2"]);
|
||||
const text = serializeLinkExportText(selection.packages);
|
||||
const reparsed = parseCollectorInput(text, "");
|
||||
|
||||
expect(reparsed).toHaveLength(1);
|
||||
expect(reparsed[0]?.name).toBe("Dave Staffel 1");
|
||||
expect(reparsed[0]?.links).toEqual(["https://example.com/e01", "https://example.com/e02"]);
|
||||
expect(reparsed[0]?.fileNames).toEqual(["Dave.S01E01.rar", "Dave.S01E02.rar"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mergePackageInputs, parseCollectorInput } from "../src/main/link-parser";
|
||||
|
||||
describe("link-parser", () => {
|
||||
describe("mergePackageInputs", () => {
|
||||
it("merges packages with the same name and preserves order", () => {
|
||||
const input = [
|
||||
{ name: "Package A", links: ["http://link1", "http://link2"] },
|
||||
{ name: "Package B", links: ["http://link3"] },
|
||||
{ name: "Package A", links: ["http://link4", "http://link1"] },
|
||||
{ name: "", links: ["http://link5"] }
|
||||
];
|
||||
|
||||
const result = mergePackageInputs(input);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
const pkgA = result.find(p => p.name === "Package A");
|
||||
expect(pkgA?.links).toEqual(["http://link1", "http://link2", "http://link4"]);
|
||||
|
||||
const pkgB = result.find(p => p.name === "Package B");
|
||||
expect(pkgB?.links).toEqual(["http://link3"]);
|
||||
});
|
||||
|
||||
it("sanitizes names during merge", () => {
|
||||
const input = [
|
||||
{ name: "Valid_Name", links: ["http://link1"] },
|
||||
{ name: "Valid?Name*", links: ["http://link2"] }
|
||||
];
|
||||
|
||||
const result = mergePackageInputs(input);
|
||||
|
||||
expect(result.map(p => p.name).sort()).toEqual(["Valid Name", "Valid_Name"]);
|
||||
});
|
||||
|
||||
it("preserves file name hints when merging packages", () => {
|
||||
const input = [
|
||||
{ name: "Package A", links: ["http://link1", "http://link2"], fileNames: ["one.rar", "two.rar"] },
|
||||
{ name: "Package A", links: ["http://link3", "http://link1"], fileNames: ["three.rar", "ignored.rar"] }
|
||||
];
|
||||
|
||||
const result = mergePackageInputs(input);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.links).toEqual(["http://link1", "http://link2", "http://link3"]);
|
||||
expect(result[0]?.fileNames).toEqual(["one.rar", "two.rar", "three.rar"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCollectorInput", () => {
|
||||
it("returns empty array for empty or invalid input", () => {
|
||||
expect(parseCollectorInput("")).toEqual([]);
|
||||
expect(parseCollectorInput("just some text without links")).toEqual([]);
|
||||
expect(parseCollectorInput("ftp://notsupported")).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses and merges links from raw text", () => {
|
||||
const rawText = `
|
||||
Here are some links:
|
||||
http://example.com/part1.rar
|
||||
http://example.com/part2.rar
|
||||
|
||||
# package: Custom_Name
|
||||
http://other.com/file1
|
||||
http://other.com/file2
|
||||
`;
|
||||
|
||||
const result = parseCollectorInput(rawText, "DefaultFallback");
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
const defaultPkg = result.find(p => p.name === "DefaultFallback");
|
||||
expect(defaultPkg?.links).toEqual([
|
||||
"http://example.com/part1.rar",
|
||||
"http://example.com/part2.rar"
|
||||
]);
|
||||
|
||||
const customPkg = result.find(p => p.name === "Custom_Name");
|
||||
expect(customPkg?.links).toEqual([
|
||||
"http://other.com/file1",
|
||||
"http://other.com/file2"
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { logTimestamp } from "../src/main/log-timestamp";
|
||||
|
||||
describe("logTimestamp", () => {
|
||||
it("formats local time with an explicit UTC offset (ISO 8601), not a UTC 'Z' string", () => {
|
||||
const instant = new Date("2026-05-31T17:29:43.605Z");
|
||||
const formatted = logTimestamp(instant);
|
||||
|
||||
expect(formatted).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/);
|
||||
expect(formatted.endsWith("Z")).toBe(false);
|
||||
});
|
||||
|
||||
it("is parseable back to the exact same instant (offset keeps it unambiguous)", () => {
|
||||
const instant = new Date("2026-05-31T17:29:43.605Z");
|
||||
expect(new Date(logTimestamp(instant)).getTime()).toBe(instant.getTime());
|
||||
});
|
||||
|
||||
it("shows the LOCAL wall-clock hour (machine-timezone-independent assertion)", () => {
|
||||
const instant = new Date("2026-05-31T17:29:43.605Z");
|
||||
const formatted = logTimestamp(instant);
|
||||
expect(formatted.slice(11, 13)).toBe(String(instant.getHours()).padStart(2, "0"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isMegaDebridResolveFailure, germanMegaDebridResolveReason, isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors";
|
||||
|
||||
describe("isMegaDebridResolveFailure", () => {
|
||||
it("detects the real Mega-Debrid French resolve-failure phrase", () => {
|
||||
expect(isMegaDebridResolveFailure("Mega-Debrid API: Fichier supprimé chez l'hébergeur")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches inside the aggregated provider-chain error (api fail | web timeout)", () => {
|
||||
const aggregated = "Unrestrict fehlgeschlagen: Mega-Debrid API: Mega-Debrid (API): Fichier supprimé chez l'hébergeur | Mega-Debrid Web: Mega-Debrid (Web): Abbruch/Timeout nach 60s";
|
||||
expect(isMegaDebridResolveFailure(aggregated)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the de-accented variant", () => {
|
||||
expect(isMegaDebridResolveFailure("Fichier supprime chez l'hebergeur")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches other Mega-Debrid resolve phrases", () => {
|
||||
expect(isMegaDebridResolveFailure("Fichier introuvable")).toBe(true);
|
||||
expect(isMegaDebridResolveFailure("Le fichier n'existe plus")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT match unrelated/transient text", () => {
|
||||
expect(isMegaDebridResolveFailure("Abbruch/Timeout nach 60s")).toBe(false);
|
||||
expect(isMegaDebridResolveFailure("Quota/Limit erreicht")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("germanMegaDebridResolveReason (transient wording, NOT 'tot')", () => {
|
||||
it("renders 'supprimé' as a transient, retryable German reason", () => {
|
||||
const reason = germanMegaDebridResolveReason("Mega-Debrid API: Fichier supprimé chez l'hébergeur");
|
||||
expect(reason).toBe("Datei beim Hoster gerade nicht abrufbar");
|
||||
expect(reason.toLowerCase()).not.toContain("tot");
|
||||
expect(reason.toLowerCase()).not.toContain("gelöscht");
|
||||
});
|
||||
|
||||
it("renders not-found phrases in German", () => {
|
||||
expect(germanMegaDebridResolveReason("Fichier introuvable")).toBe("Datei beim Hoster nicht gefunden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMegaDebridTransientResolveFailure (matches raw French AND rendered German)", () => {
|
||||
it("matches the raw French phrase that may reach the download-manager", () => {
|
||||
expect(isMegaDebridTransientResolveFailure("Mega-Debrid API: Fichier supprimé chez l'hébergeur")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the German rendered reason that classifyAccountFailure produces", () => {
|
||||
const aggregated = "Mega-Debrid (Account 1/4, ab***@x): Datei beim Hoster gerade nicht abrufbar | Mega-Debrid (Account 2/4, cd***@y): Datei beim Hoster gerade nicht abrufbar";
|
||||
expect(isMegaDebridTransientResolveFailure(aggregated)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the German not-found rendered reason", () => {
|
||||
expect(isMegaDebridTransientResolveFailure("Mega-Debrid (Account 1/4): Datei beim Hoster nicht gefunden")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT match a Mega-Debrid timeout/abort (that has its own account cooldown path)", () => {
|
||||
expect(isMegaDebridTransientResolveFailure("Mega-Debrid (Account 1/4): Abbruch/Timeout nach 60s")).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT match unrelated provider errors", () => {
|
||||
expect(isMegaDebridTransientResolveFailure("AllDebrid: zu viele aktive Downloads")).toBe(false);
|
||||
expect(isMegaDebridTransientResolveFailure("Debrid-Link: badToken")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import crypto from "node:crypto";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
decryptMegaAttributes,
|
||||
isMegaFileUrl,
|
||||
parseMegaUrl,
|
||||
resolveMegaFilename
|
||||
} from "../src/main/mega-public-api";
|
||||
|
||||
function base64Url(buf: Buffer): string {
|
||||
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function makeRandomFileKey(): Buffer {
|
||||
return crypto.randomBytes(32);
|
||||
}
|
||||
|
||||
function encryptAttributes(jsonAttrs: Record<string, unknown>, aesKey: Buffer): string {
|
||||
const plain = "MEGA" + JSON.stringify(jsonAttrs);
|
||||
const padded = Buffer.from(plain, "utf8");
|
||||
const padLen = (16 - (padded.length % 16)) % 16;
|
||||
const buf = Buffer.concat([padded, Buffer.alloc(padLen, 0)]);
|
||||
const cipher = crypto.createCipheriv("aes-128-cbc", aesKey, Buffer.alloc(16));
|
||||
cipher.setAutoPadding(false);
|
||||
const enc = Buffer.concat([cipher.update(buf), cipher.final()]);
|
||||
return base64Url(enc);
|
||||
}
|
||||
|
||||
describe("mega-public-api", () => {
|
||||
describe("isMegaFileUrl", () => {
|
||||
it("recognizes new format", () => {
|
||||
expect(isMegaFileUrl("https://mega.nz/file/pZl1wBRQ#BFx-HachDy4o9EgKy90IiLMsw3idHFGaDoJhajK5zzo")).toBe(true);
|
||||
});
|
||||
it("recognizes legacy format", () => {
|
||||
expect(isMegaFileUrl("https://mega.nz/#!abc123!def456")).toBe(true);
|
||||
});
|
||||
it("recognizes mega.co.nz", () => {
|
||||
expect(isMegaFileUrl("https://mega.co.nz/file/abc#xyz")).toBe(true);
|
||||
});
|
||||
it("rejects folder URLs", () => {
|
||||
expect(isMegaFileUrl("https://mega.nz/folder/abc#xyz")).toBe(false);
|
||||
});
|
||||
it("rejects non-mega URLs", () => {
|
||||
expect(isMegaFileUrl("https://example.com/file/abc#xyz")).toBe(false);
|
||||
});
|
||||
it("rejects garbage", () => {
|
||||
expect(isMegaFileUrl("")).toBe(false);
|
||||
expect(isMegaFileUrl("foo")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMegaUrl", () => {
|
||||
it("parses new-format URL into id + 32-byte key", () => {
|
||||
const url = "https://mega.nz/file/pZl1wBRQ#BFx-HachDy4o9EgKy90IiLMsw3idHFGaDoJhajK5zzo";
|
||||
const parsed = parseMegaUrl(url);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.id).toBe("pZl1wBRQ");
|
||||
expect(parsed?.rawKey.length).toBe(32);
|
||||
});
|
||||
it("parses legacy-format URL", () => {
|
||||
const id = "abcDEF12";
|
||||
const key = makeRandomFileKey();
|
||||
const url = `https://mega.nz/#!${id}!${base64Url(key)}`;
|
||||
const parsed = parseMegaUrl(url);
|
||||
expect(parsed?.id).toBe(id);
|
||||
expect(parsed?.rawKey.equals(key)).toBe(true);
|
||||
});
|
||||
it("rejects URL with folder key (16 bytes)", () => {
|
||||
const url = `https://mega.nz/file/abc#${base64Url(crypto.randomBytes(16))}`;
|
||||
expect(parseMegaUrl(url)).toBeNull();
|
||||
});
|
||||
it("rejects malformed URLs", () => {
|
||||
expect(parseMegaUrl("not-a-url")).toBeNull();
|
||||
expect(parseMegaUrl("https://mega.nz/file/abc")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("decryptMegaAttributes", () => {
|
||||
it("round-trips encrypted Mega attributes", () => {
|
||||
const aesKey = crypto.randomBytes(16);
|
||||
const original = { n: "Test.S01E01.German.1080p.WEB.x264-DEMO.mkv", c: "ignored" };
|
||||
const enc = encryptAttributes(original, aesKey);
|
||||
const decoded = Buffer.from(enc + "=".repeat((4 - (enc.length % 4)) % 4), "base64");
|
||||
const decrypted = decryptMegaAttributes(decoded, aesKey);
|
||||
expect(decrypted).not.toBeNull();
|
||||
expect(decrypted?.n).toBe(original.n);
|
||||
});
|
||||
it("returns null for wrong key", () => {
|
||||
const aesKey = crypto.randomBytes(16);
|
||||
const wrongKey = crypto.randomBytes(16);
|
||||
const enc = encryptAttributes({ n: "x" }, aesKey);
|
||||
const decoded = Buffer.from(enc + "=".repeat((4 - (enc.length % 4)) % 4), "base64");
|
||||
expect(decryptMegaAttributes(decoded, wrongKey)).toBeNull();
|
||||
});
|
||||
it("returns null for non-multiple-of-16 input", () => {
|
||||
const aesKey = crypto.randomBytes(16);
|
||||
expect(decryptMegaAttributes(Buffer.alloc(15), aesKey)).toBeNull();
|
||||
});
|
||||
it("returns null for wrong key length", () => {
|
||||
expect(decryptMegaAttributes(Buffer.alloc(16), Buffer.alloc(8))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMegaFilename (mocked fetch)", () => {
|
||||
let originalFetch: typeof fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns filename + size for a valid Mega response", async () => {
|
||||
const fileKey = makeRandomFileKey();
|
||||
const aesKey = fileKey.subarray(0, 16);
|
||||
const url = `https://mega.nz/file/testId12#${base64Url(fileKey)}`;
|
||||
const encrypted = encryptAttributes(
|
||||
{ n: "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv" },
|
||||
aesKey
|
||||
);
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
async json() {
|
||||
return [{ s: 1234567890, at: encrypted, msd: 1 }];
|
||||
}
|
||||
} as unknown as Response);
|
||||
|
||||
const result = await resolveMegaFilename(url);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.name).toBe("Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv");
|
||||
expect(result?.size).toBe(1234567890);
|
||||
});
|
||||
|
||||
it("returns null when Mega returns numeric error", async () => {
|
||||
const fileKey = makeRandomFileKey();
|
||||
const url = `https://mega.nz/file/blockedId#${base64Url(fileKey)}`;
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
async json() {
|
||||
return -9;
|
||||
}
|
||||
} as unknown as Response);
|
||||
|
||||
expect(await resolveMegaFilename(url)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when response is array with error code", async () => {
|
||||
const fileKey = makeRandomFileKey();
|
||||
const url = `https://mega.nz/file/blockedId#${base64Url(fileKey)}`;
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
async json() {
|
||||
return [-16];
|
||||
}
|
||||
} as unknown as Response);
|
||||
|
||||
expect(await resolveMegaFilename(url)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when fetch throws", async () => {
|
||||
const fileKey = makeRandomFileKey();
|
||||
const url = `https://mega.nz/file/networkFail#${base64Url(fileKey)}`;
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error("network down"));
|
||||
expect(await resolveMegaFilename(url)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-mega URL without making any fetch call", async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
global.fetch = fetchSpy as unknown as typeof fetch;
|
||||
expect(await resolveMegaFilename("https://example.com/file/abc#xyz")).toBeNull();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { MegaWebFallback } from "../src/main/mega-web-fallback";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
describe("mega-web-fallback", () => {
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("MegaWebFallback class", () => {
|
||||
it("returns null when credentials are empty", async () => {
|
||||
const fallback = new MegaWebFallback(() => ({ login: "", password: "" }));
|
||||
const result = await fallback.unrestrict("https://mega.debrid/test");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("logs in, fetches HTML, parses code, and polls AJAX for direct url", async () => {
|
||||
let fetchCallCount = 0;
|
||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
fetchCallCount += 1;
|
||||
|
||||
if (urlStr.includes("form=login")) {
|
||||
const headers = new Headers();
|
||||
headers.append("set-cookie", "session=goodcookie; path=/");
|
||||
return new Response("", { headers, status: 200 });
|
||||
}
|
||||
|
||||
if (urlStr.includes("page=debrideur")) {
|
||||
return new Response('<form id="debridForm"></form>', { status: 200 });
|
||||
}
|
||||
|
||||
if (urlStr.includes("form=debrid")) {
|
||||
return new Response(`
|
||||
<div class="acp-box">
|
||||
<h3>Link: https://mega.debrid/link1</h3>
|
||||
<a href="javascript:processDebrid(1,'secretcode123',0)">Download</a>
|
||||
</div>
|
||||
`, { status: 200 });
|
||||
}
|
||||
|
||||
if (urlStr.includes("ajax=debrid")) {
|
||||
return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 });
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
||||
|
||||
const result = await fallback.unrestrict("https://mega.debrid/link1");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.directUrl).toBe("https://mega.direct/123");
|
||||
expect(result?.fileName).toBe("link1");
|
||||
expect(fetchCallCount).toBe(4);
|
||||
});
|
||||
|
||||
it("fails fast on 'Kein Server für diesen Hoster' (account hoster quota) instead of re-login + re-poll", async () => {
|
||||
let ajaxCalls = 0;
|
||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes("form=login")) {
|
||||
const headers = new Headers();
|
||||
headers.append("set-cookie", "session=goodcookie; path=/");
|
||||
return new Response("", { headers, status: 200 });
|
||||
}
|
||||
if (urlStr.includes("page=debrideur")) {
|
||||
return new Response('<form id="debridForm"></form>', { status: 200 });
|
||||
}
|
||||
if (urlStr.includes("form=debrid")) {
|
||||
return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l1</h3><a href="javascript:processDebrid(1,'code1',0)">d</a></div>`, { status: 200 });
|
||||
}
|
||||
if (urlStr.includes("ajax=debrid")) {
|
||||
ajaxCalls += 1;
|
||||
return new Response(JSON.stringify({ link: "", text: "Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal." }), { status: 200 });
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
||||
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
|
||||
expect(ajaxCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("surfaces 'Kein Server für diesen Hoster' from the debrid PAGE (daily limit, no debrid code) instead of empty", async () => {
|
||||
let ajaxCalls = 0;
|
||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes("form=login")) {
|
||||
const headers = new Headers();
|
||||
headers.append("set-cookie", "session=goodcookie; path=/");
|
||||
return new Response("", { headers, status: 200 });
|
||||
}
|
||||
if (urlStr.includes("page=debrideur")) {
|
||||
return new Response('<form id="debridForm"></form>', { status: 200 });
|
||||
}
|
||||
if (urlStr.includes("form=debrid")) {
|
||||
return new Response('<div class="error">Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.</div>', { status: 200 });
|
||||
}
|
||||
if (urlStr.includes("ajax=debrid")) {
|
||||
ajaxCalls += 1;
|
||||
return new Response(JSON.stringify({ link: "https://should.not/happen" }), { status: 200 });
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
||||
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
|
||||
expect(ajaxCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("logs in with the per-account credentials passed to unrestrict, not the default", async () => {
|
||||
const loginsUsed: string[] = [];
|
||||
globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: { body?: unknown }) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes("form=login")) {
|
||||
const params = new URLSearchParams(String(opts?.body ?? ""));
|
||||
loginsUsed.push(params.get("login") || "");
|
||||
const headers = new Headers();
|
||||
headers.append("set-cookie", "session=goodcookie; path=/");
|
||||
return new Response("", { headers, status: 200 });
|
||||
}
|
||||
if (urlStr.includes("page=debrideur")) {
|
||||
return new Response('<form id="debridForm"></form>', { status: 200 });
|
||||
}
|
||||
if (urlStr.includes("form=debrid")) {
|
||||
return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l1</h3><a href="javascript:processDebrid(1,'code1',0)">d</a></div>`, { status: 200 });
|
||||
}
|
||||
if (urlStr.includes("ajax=debrid")) {
|
||||
return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "defaultacc", password: "defpw" }));
|
||||
const result = await fallback.unrestrict("https://mega.debrid/l1", undefined, { login: "account2", password: "pw2" });
|
||||
expect(result?.directUrl).toBe("https://mega.direct/ok");
|
||||
expect(loginsUsed).toContain("account2");
|
||||
expect(loginsUsed).not.toContain("defaultacc");
|
||||
});
|
||||
|
||||
it("throws if login fails to set cookie", async () => {
|
||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes("form=login")) {
|
||||
const headers = new Headers();
|
||||
return new Response("", { headers, status: 200 });
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "bad", password: "bad" }));
|
||||
|
||||
await expect(fallback.unrestrict("http://mega.debrid/file"))
|
||||
.rejects.toThrow("Mega-Web Login liefert kein Session-Cookie");
|
||||
});
|
||||
|
||||
it("throws if login verify check fails (no form found)", async () => {
|
||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
if (urlStr.includes("form=login")) {
|
||||
const headers = new Headers();
|
||||
headers.append("set-cookie", "session=goodcookie; path=/");
|
||||
return new Response("", { headers, status: 200 });
|
||||
}
|
||||
if (urlStr.includes("page=debrideur")) {
|
||||
return new Response('<html><body>Nothing here</body></html>', { status: 200 });
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
|
||||
|
||||
await expect(fallback.unrestrict("http://mega.debrid/file"))
|
||||
.rejects.toThrow("Mega-Web Login ungültig oder Session blockiert");
|
||||
});
|
||||
|
||||
it("returns null if generation fails to find a code", async () => {
|
||||
let callCount = 0;
|
||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||
const urlStr = String(url);
|
||||
callCount++;
|
||||
if (urlStr.includes("form=login")) {
|
||||
const headers = new Headers();
|
||||
headers.append("set-cookie", "session=goodcookie; path=/");
|
||||
return new Response("", { headers, status: 200 });
|
||||
}
|
||||
if (urlStr.includes("page=debrideur")) {
|
||||
return new Response('<form id="debridForm"></form>', { status: 200 });
|
||||
}
|
||||
if (urlStr.includes("form=debrid")) {
|
||||
return new Response(`<div>No links here</div>`, { status: 200 });
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
|
||||
const result = await fallback.unrestrict("http://mega.debrid/file");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("serialisiert gleichzeitige Umwandlungen auf DEMSELBEN Account (kein Doppel-Login)", async () => {
|
||||
let loginCount = 0;
|
||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||
const u = String(url);
|
||||
if (u.includes("form=login")) {
|
||||
loginCount += 1;
|
||||
await new Promise((r) => setTimeout(r, 15));
|
||||
const headers = new Headers();
|
||||
headers.append("set-cookie", "session=c; path=/");
|
||||
return new Response("", { headers, status: 200 });
|
||||
}
|
||||
if (u.includes("page=debrideur")) return new Response('<form id="debridForm"></form>', { status: 200 });
|
||||
if (u.includes("form=debrid")) return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l</h3><a href="javascript:processDebrid(1,'code',0)">d</a></div>`, { status: 200 });
|
||||
if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
|
||||
return new Response("Not found", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "same", password: "pw" }));
|
||||
const [r1, r2] = await Promise.all([
|
||||
fallback.unrestrict("https://mega.debrid/a", undefined, { login: "same", password: "pw" }),
|
||||
fallback.unrestrict("https://mega.debrid/b", undefined, { login: "same", password: "pw" })
|
||||
]);
|
||||
expect(r1?.directUrl).toBe("https://mega.direct/ok");
|
||||
expect(r2?.directUrl).toBe("https://mega.direct/ok");
|
||||
// Serialisiert auf demselben Account → der zweite nutzt die gecachte Session, kein zweiter Login.
|
||||
expect(loginCount).toBe(1);
|
||||
});
|
||||
|
||||
it("wandelt auf VERSCHIEDENEN Accounts parallel um (Logins laufen gleichzeitig)", async () => {
|
||||
let activeLogins = 0;
|
||||
let maxActiveLogins = 0;
|
||||
const bothInFlight = new Promise<void>((resolve) => {
|
||||
const check = setInterval(() => { if (activeLogins >= 2) { clearInterval(check); resolve(); } }, 5);
|
||||
});
|
||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||
const u = String(url);
|
||||
if (u.includes("form=login")) {
|
||||
activeLogins += 1;
|
||||
maxActiveLogins = Math.max(maxActiveLogins, activeLogins);
|
||||
await bothInFlight;
|
||||
activeLogins -= 1;
|
||||
const headers = new Headers();
|
||||
headers.append("set-cookie", "session=c; path=/");
|
||||
return new Response("", { headers, status: 200 });
|
||||
}
|
||||
if (u.includes("page=debrideur")) return new Response('<form id="debridForm"></form>', { status: 200 });
|
||||
if (u.includes("form=debrid")) return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l</h3><a href="javascript:processDebrid(1,'code',0)">d</a></div>`, { status: 200 });
|
||||
if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
|
||||
return new Response("Not found", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "d", password: "p" }));
|
||||
const [r1, r2] = await Promise.all([
|
||||
fallback.unrestrict("https://mega.debrid/a", undefined, { login: "acc1", password: "p" }),
|
||||
fallback.unrestrict("https://mega.debrid/b", undefined, { login: "acc2", password: "p" })
|
||||
]);
|
||||
expect(r1?.directUrl).toBe("https://mega.direct/ok");
|
||||
expect(r2?.directUrl).toBe("https://mega.direct/ok");
|
||||
// Verschiedene Accounts → beide Logins gleichzeitig in-flight (sonst haengt es am bothInFlight-Barrier).
|
||||
expect(maxActiveLogins).toBe(2);
|
||||
}, 10000);
|
||||
|
||||
it("aborts pending Mega-Web polling when signal is cancelled", async () => {
|
||||
globalThis.fetch = vi.fn((url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
const urlStr = String(url);
|
||||
|
||||
if (urlStr.includes("form=login")) {
|
||||
const headers = new Headers();
|
||||
headers.append("set-cookie", "session=goodcookie; path=/");
|
||||
return Promise.resolve(new Response("", { headers, status: 200 }));
|
||||
}
|
||||
|
||||
if (urlStr.includes("page=debrideur")) {
|
||||
return Promise.resolve(new Response('<form id="debridForm"></form>', { status: 200 }));
|
||||
}
|
||||
|
||||
if (urlStr.includes("form=debrid")) {
|
||||
return Promise.resolve(new Response(`
|
||||
<div class="acp-box">
|
||||
<h3>Link: https://mega.debrid/link2</h3>
|
||||
<a href="javascript:processDebrid(1,'secretcode456',0)">Download</a>
|
||||
</div>
|
||||
`, { status: 200 }));
|
||||
}
|
||||
|
||||
if (urlStr.includes("ajax=debrid")) {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
const onAbort = (): void => reject(new Error("aborted:ajax"));
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response("Not found", { status: 404 }));
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort("test");
|
||||
}, 200);
|
||||
|
||||
try {
|
||||
await expect(fallback.unrestrict("https://mega.debrid/link2", controller.signal)).rejects.toThrow(/aborted/i);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
it("klassifiziert einen Abbruch WAEHREND in der Queue als Queue-Timeout (nicht harter Abbruch), damit der belegte Account nicht bestraft wird", async () => {
|
||||
let releaseLogin: () => void = () => {};
|
||||
const loginGate = new Promise<void>((resolve) => { releaseLogin = resolve; });
|
||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||
const u = String(url);
|
||||
if (u.includes("form=login")) {
|
||||
await loginGate;
|
||||
const headers = new Headers();
|
||||
headers.append("set-cookie", "session=c; path=/");
|
||||
return new Response("", { headers, status: 200 });
|
||||
}
|
||||
if (u.includes("page=debrideur")) return new Response('<form id="debridForm"></form>', { status: 200 });
|
||||
if (u.includes("form=debrid")) return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l</h3><a href="javascript:processDebrid(1,'code',0)">d</a></div>`, { status: 200 });
|
||||
if (u.includes("ajax=debrid")) return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
|
||||
return new Response("Not found", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const fallback = new MegaWebFallback(() => ({ login: "same", password: "pw" }));
|
||||
const firstCtrl = new AbortController();
|
||||
const first = fallback.unrestrict("https://mega.debrid/a", firstCtrl.signal, { login: "same", password: "pw" });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
|
||||
const secondCtrl = new AbortController();
|
||||
const second = fallback.unrestrict("https://mega.debrid/b", secondCtrl.signal, { login: "same", password: "pw" });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
secondCtrl.abort("caller-timeout");
|
||||
|
||||
await expect(second).rejects.toThrow(/queue.?timeout/i);
|
||||
|
||||
releaseLogin();
|
||||
await first.catch(() => null);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../src/main/notify", async (importActual) => {
|
||||
const actual = await importActual<typeof import("../src/main/notify")>();
|
||||
return { ...actual, sendNotification: vi.fn().mockResolvedValue(true) };
|
||||
});
|
||||
|
||||
import { DownloadManager } from "../src/main/download-manager";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||
import { shutdownItemLogs } from "../src/main/item-log";
|
||||
import { shutdownPackageLogs } from "../src/main/package-log";
|
||||
import { shutdownRenameLog } from "../src/main/rename-log";
|
||||
import { sendNotification } from "../src/main/notify";
|
||||
|
||||
const mockedSend = sendNotification as unknown as ReturnType<typeof vi.fn>;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
mockedSend.mockClear();
|
||||
shutdownItemLogs();
|
||||
shutdownPackageLogs();
|
||||
shutdownRenameLog();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
function setup(): { manager: DownloadManager; session: ReturnType<typeof emptySession> } {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-"));
|
||||
tempDirs.push(root);
|
||||
const session = emptySession();
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "out"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
notifyUrl: "https://discord.com/api/webhooks/123/abc",
|
||||
notifyOnPackageCompleted: true,
|
||||
notifyOnPackageFailed: true
|
||||
},
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
return { manager, session };
|
||||
}
|
||||
|
||||
function addPackage(session: ReturnType<typeof emptySession>, itemStatuses: string[]): any {
|
||||
const pkgId = "pkg-1";
|
||||
const pkg: any = {
|
||||
id: pkgId,
|
||||
name: "Test.Show.S01",
|
||||
outputDir: "C:/out",
|
||||
extractDir: "C:/extract",
|
||||
status: "queued",
|
||||
itemIds: itemStatuses.map((_s, i) => `it-${i}`),
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
priority: "normal",
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
};
|
||||
session.packages[pkgId] = pkg;
|
||||
session.packageOrder.push(pkgId);
|
||||
itemStatuses.forEach((status, i) => {
|
||||
session.items[`it-${i}`] = {
|
||||
id: `it-${i}`,
|
||||
packageId: pkgId,
|
||||
url: `https://dummy/${i}`,
|
||||
provider: null,
|
||||
status,
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fileName: `f${i}.rar`,
|
||||
targetPath: "",
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "",
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
} as any;
|
||||
});
|
||||
return pkg;
|
||||
}
|
||||
|
||||
describe("refreshPackageStatus failed-transition notify", () => {
|
||||
it("notifies a MIXED package (some success, last finisher failed) — the lost-webhook case", () => {
|
||||
const { manager, session } = setup();
|
||||
const pkg = addPackage(session, ["completed", "failed"]);
|
||||
session.running = true;
|
||||
|
||||
(manager as any).refreshPackageStatus(pkg);
|
||||
|
||||
expect(pkg.status).toBe("failed");
|
||||
expect(mockedSend).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSend.mock.calls[0][1].title).toBe("❌ Paket fehlgeschlagen");
|
||||
expect(mockedSend.mock.calls[0][1].message).toContain("1 von 2");
|
||||
});
|
||||
|
||||
it("notifies an all-failed package and dedups repeat refreshes", () => {
|
||||
const { manager, session } = setup();
|
||||
const pkg = addPackage(session, ["failed", "failed"]);
|
||||
session.running = true;
|
||||
|
||||
(manager as any).refreshPackageStatus(pkg);
|
||||
(manager as any).refreshPackageStatus(pkg);
|
||||
|
||||
expect(pkg.status).toBe("failed");
|
||||
expect(mockedSend).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stays silent outside a run (startup recovery must not spam)", () => {
|
||||
const { manager, session } = setup();
|
||||
const pkg = addPackage(session, ["failed"]);
|
||||
session.running = false;
|
||||
|
||||
(manager as any).refreshPackageStatus(pkg);
|
||||
|
||||
expect(pkg.status).toBe("failed");
|
||||
expect(mockedSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not notify while items are still pending", () => {
|
||||
const { manager, session } = setup();
|
||||
const pkg = addPackage(session, ["failed", "queued"]);
|
||||
session.running = true;
|
||||
|
||||
(manager as any).refreshPackageStatus(pkg);
|
||||
|
||||
expect(pkg.status).toBe("queued");
|
||||
expect(mockedSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases the dedup marker when the send ultimately fails (retro-notify possible)", async () => {
|
||||
const { manager, session } = setup();
|
||||
const pkg = addPackage(session, ["failed", "failed"]);
|
||||
session.running = true;
|
||||
mockedSend.mockResolvedValueOnce(false);
|
||||
|
||||
(manager as any).refreshPackageStatus(pkg);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect((manager as any).notifiedPackages.has(pkg.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildNotifyRequest, isNotifyUrlValid, normalizeDiscordMention, sendNotification, truncateContent } from "../src/main/notify";
|
||||
|
||||
const noSleep = async (): Promise<void> => {};
|
||||
const WEBHOOK = "https://discord.com/api/webhooks/123/abc";
|
||||
|
||||
describe("normalizeDiscordMention", () => {
|
||||
it("wraps a bare user ID as a pinging mention", () => {
|
||||
expect(normalizeDiscordMention("123456789012345678")).toBe("<@123456789012345678>");
|
||||
expect(normalizeDiscordMention(" 987654321 ")).toBe("<@987654321>");
|
||||
});
|
||||
it("passes @everyone/@here and formed mentions through", () => {
|
||||
expect(normalizeDiscordMention("@everyone")).toBe("@everyone");
|
||||
expect(normalizeDiscordMention("@here")).toBe("@here");
|
||||
expect(normalizeDiscordMention("<@123456789>")).toBe("<@123456789>");
|
||||
expect(normalizeDiscordMention("<@&111222333>")).toBe("<@&111222333>");
|
||||
});
|
||||
it("returns empty for empty input", () => {
|
||||
expect(normalizeDiscordMention("")).toBe("");
|
||||
expect(normalizeDiscordMention(" ")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNotifyUrlValid", () => {
|
||||
it("accepts http/https URLs", () => {
|
||||
expect(isNotifyUrlValid(WEBHOOK)).toBe(true);
|
||||
expect(isNotifyUrlValid("http://192.168.1.10:8080/hook")).toBe(true);
|
||||
expect(isNotifyUrlValid(` ${WEBHOOK} `)).toBe(true);
|
||||
});
|
||||
it("rejects empty and non-http values", () => {
|
||||
expect(isNotifyUrlValid("")).toBe(false);
|
||||
expect(isNotifyUrlValid("discord.com/api/webhooks/123/abc")).toBe(false);
|
||||
expect(isNotifyUrlValid("ftp://x")).toBe(false);
|
||||
expect(isNotifyUrlValid("https:// mit leerzeichen")).toBe(false);
|
||||
expect(isNotifyUrlValid("***")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateContent", () => {
|
||||
it("leaves short content untouched", () => {
|
||||
expect(truncateContent("hallo")).toBe("hallo");
|
||||
});
|
||||
it("caps at the limit", () => {
|
||||
expect(truncateContent("x".repeat(3000)).length).toBe(2000);
|
||||
});
|
||||
it("never splits a surrogate pair at the boundary", () => {
|
||||
const emoji = "🏁";
|
||||
const content = "x".repeat(1999) + emoji;
|
||||
const cut = truncateContent(content);
|
||||
expect(cut.length).toBe(1999);
|
||||
expect(/[\uD800-\uDBFF]$/.test(cut)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNotifyRequest", () => {
|
||||
it("builds a Discord-compatible JSON webhook POST (bold title + message as content)", () => {
|
||||
const req = buildNotifyRequest(` ${WEBHOOK} `, { title: "✅ Paket fertig", message: "Show.S01\n5 Datei(en)" });
|
||||
expect(req.url).toBe(WEBHOOK);
|
||||
expect(req.init.method).toBe("POST");
|
||||
expect(req.init.headers).toMatchObject({ "Content-Type": "application/json" });
|
||||
const body = JSON.parse(String(req.init.body));
|
||||
expect(body.content).toBe("**✅ Paket fertig**\nShow.S01\n5 Datei(en)");
|
||||
expect(body.username).toBe("Real-Debrid Downloader");
|
||||
});
|
||||
it("prepends the mention so Discord pings (bare ID gets wrapped)", () => {
|
||||
const req = buildNotifyRequest(WEBHOOK, { title: "T", message: "M", mention: "123456789012345678" });
|
||||
const body = JSON.parse(String(req.init.body));
|
||||
expect(body.content).toBe("<@123456789012345678> **T**\nM");
|
||||
});
|
||||
it("sends no mention prefix when the field is empty", () => {
|
||||
const req = buildNotifyRequest(WEBHOOK, { title: "T", message: "M", mention: "" });
|
||||
const body = JSON.parse(String(req.init.body));
|
||||
expect(body.content).toBe("**T**\nM");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendNotification", () => {
|
||||
it("returns true on HTTP ok (Discord answers 204 No Content)", async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
||||
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(true);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("retries a 429 using Discord's retry_after and then succeeds", async () => {
|
||||
const waits: number[] = [];
|
||||
const sleepSpy = async (ms: number): Promise<void> => { waits.push(ms); };
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ retry_after: 1.2 }), { status: 429 }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, sleepSpy)).resolves.toBe(true);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
expect(waits).toContain(1200); // seconds -> ms
|
||||
});
|
||||
it("retries transient 5xx and network errors, then gives up", async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(new Response("", { status: 502 }));
|
||||
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(3); // initial + 2 retries
|
||||
|
||||
const fetchErr = vi.fn().mockRejectedValue(new Error("offline"));
|
||||
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchErr, noSleep)).resolves.toBe(false);
|
||||
expect(fetchErr).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
it("does not retry a permanent 4xx", async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(new Response("", { status: 404 }));
|
||||
await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("serializes concurrent sends in order (burst protection)", async () => {
|
||||
const order: string[] = [];
|
||||
const fetchFn = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => {
|
||||
order.push(JSON.parse(String(init.body)).content);
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
const sends = [
|
||||
sendNotification(WEBHOOK, { title: "1", message: "" }, fetchFn, noSleep),
|
||||
sendNotification(WEBHOOK, { title: "2", message: "" }, fetchFn, noSleep),
|
||||
sendNotification(WEBHOOK, { title: "3", message: "" }, fetchFn, noSleep)
|
||||
];
|
||||
await expect(Promise.all(sends)).resolves.toEqual([true, true, true]);
|
||||
expect(order).toEqual(["**1**\n", "**2**\n", "**3**\n"]);
|
||||
});
|
||||
it("does not call fetch for an invalid URL", async () => {
|
||||
const fetchFn = vi.fn();
|
||||
await expect(sendNotification("", { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
|
||||
await expect(sendNotification("kein-url", { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false);
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ensurePackageLog, getPackageLogPath, initPackageLogs, logPackageEvent, shutdownPackageLogs } from "../src/main/package-log";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
shutdownPackageLogs();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("package-log", () => {
|
||||
it("creates a persistent package log file", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initPackageLogs(baseDir);
|
||||
const logPath = ensurePackageLog({
|
||||
packageId: "pkg-1",
|
||||
name: "Test Paket",
|
||||
outputDir: "C:\\downloads\\Test Paket",
|
||||
extractDir: "C:\\extract\\Test Paket"
|
||||
});
|
||||
|
||||
expect(logPath).not.toBeNull();
|
||||
expect(fs.existsSync(logPath!)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("Paket-Log Start");
|
||||
expect(content).toContain("Test Paket");
|
||||
});
|
||||
|
||||
it("writes detail events into the package log", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initPackageLogs(baseDir);
|
||||
ensurePackageLog({
|
||||
packageId: "pkg-2",
|
||||
name: "Detail Paket",
|
||||
outputDir: "C:\\downloads\\Detail Paket",
|
||||
extractDir: "C:\\extract\\Detail Paket"
|
||||
});
|
||||
|
||||
logPackageEvent("pkg-2", "INFO", "Passwort-Versuch", {
|
||||
archive: "episode.part1.rar",
|
||||
attempt: "1/3",
|
||||
password: "\"secret\""
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
|
||||
const logPath = getPackageLogPath("pkg-2");
|
||||
expect(logPath).not.toBeNull();
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("Passwort-Versuch");
|
||||
expect(content).toContain("archive=episode.part1.rar");
|
||||
expect(content).toContain("password=\"secret\"");
|
||||
});
|
||||
|
||||
it("keeps traversal-like package ids inside the package log directory", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initPackageLogs(baseDir);
|
||||
const logPath = ensurePackageLog({
|
||||
packageId: "..\\..\\outside",
|
||||
name: "Traversal Paket",
|
||||
outputDir: "C:\\downloads\\Traversal Paket",
|
||||
extractDir: "C:\\extract\\Traversal Paket"
|
||||
});
|
||||
|
||||
expect(logPath).not.toBeNull();
|
||||
const logsDir = path.resolve(path.join(baseDir, "package-logs"));
|
||||
const resolvedLogPath = path.resolve(logPath!);
|
||||
expect(resolvedLogPath === logsDir || resolvedLogPath.startsWith(`${logsDir}${path.sep}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DownloadItem, PackageEntry } from "../src/shared/types";
|
||||
import { sortPackagesForDisplay } from "../src/renderer/package-order";
|
||||
|
||||
function createPackage(id: string, itemIds: string[]): PackageEntry {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
outputDir: "",
|
||||
extractDir: "",
|
||||
status: "queued",
|
||||
itemIds,
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
priority: "normal",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
}
|
||||
|
||||
function createItem(id: string, packageId: string, status: DownloadItem["status"], downloadedBytes: number): DownloadItem {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id,
|
||||
packageId,
|
||||
url: `https://hoster.example/${id}`,
|
||||
provider: null,
|
||||
status,
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes,
|
||||
totalBytes: downloadedBytes,
|
||||
progressPercent: downloadedBytes > 0 ? 50 : 0,
|
||||
fileName: `${id}.bin`,
|
||||
targetPath: "",
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
}
|
||||
|
||||
describe("sortPackagesForDisplay", () => {
|
||||
it("floats active packages to the top, keeping queue order within each group", () => {
|
||||
// pkg-a and pkg-b both have an active (downloading) item -> both float up in
|
||||
// their original queue order; pkg-c (queued only) sinks below.
|
||||
const packages = [
|
||||
createPackage("pkg-a", ["a1", "a2"]),
|
||||
createPackage("pkg-c", ["c1"]),
|
||||
createPackage("pkg-b", ["b1", "b2"])
|
||||
];
|
||||
const items: Record<string, DownloadItem> = {
|
||||
a1: createItem("a1", "pkg-a", "downloading", 250),
|
||||
a2: createItem("a2", "pkg-a", "completed", 500),
|
||||
c1: createItem("c1", "pkg-c", "queued", 0),
|
||||
b1: createItem("b1", "pkg-b", "downloading", 800),
|
||||
b2: createItem("b2", "pkg-b", "completed", 900)
|
||||
};
|
||||
|
||||
const sorted = sortPackagesForDisplay(packages, items, true, true);
|
||||
|
||||
// active group [pkg-a, pkg-b] in queue order, then rest [pkg-c]
|
||||
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
|
||||
});
|
||||
|
||||
it("does NOT reshuffle active packages when only their progress changes (anti-flicker)", () => {
|
||||
const packages = [
|
||||
createPackage("pkg-a", ["a1"]),
|
||||
createPackage("pkg-b", ["b1"])
|
||||
];
|
||||
// Both active. pkg-b initially has more bytes than pkg-a.
|
||||
const before: Record<string, DownloadItem> = {
|
||||
a1: createItem("a1", "pkg-a", "downloading", 100),
|
||||
b1: createItem("b1", "pkg-b", "downloading", 900)
|
||||
};
|
||||
const orderBefore = sortPackagesForDisplay(packages, before, true, true).map((p) => p.id);
|
||||
|
||||
// A progress tick: pkg-a overtakes pkg-b in bytes. Order must NOT change —
|
||||
// both are still active, so they keep queue order. (Old code swapped them.)
|
||||
const after: Record<string, DownloadItem> = {
|
||||
a1: createItem("a1", "pkg-a", "downloading", 5000),
|
||||
b1: createItem("b1", "pkg-b", "downloading", 950)
|
||||
};
|
||||
const orderAfter = sortPackagesForDisplay(packages, after, true, true).map((p) => p.id);
|
||||
|
||||
expect(orderBefore).toEqual(["pkg-a", "pkg-b"]);
|
||||
expect(orderAfter).toEqual(orderBefore);
|
||||
});
|
||||
|
||||
it("keeps package order untouched when auto sort is disabled", () => {
|
||||
const packages = [
|
||||
createPackage("pkg-a", ["a1"]),
|
||||
createPackage("pkg-b", ["b1"]),
|
||||
createPackage("pkg-c", ["c1"])
|
||||
];
|
||||
const items: Record<string, DownloadItem> = {
|
||||
a1: createItem("a1", "pkg-a", "queued", 0),
|
||||
b1: createItem("b1", "pkg-b", "downloading", 500),
|
||||
c1: createItem("c1", "pkg-c", "queued", 0)
|
||||
};
|
||||
|
||||
const sorted = sortPackagesForDisplay(packages, items, true, false);
|
||||
|
||||
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
type ReleaseVerification = {
|
||||
publish: {
|
||||
provider: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
};
|
||||
latestArtifact: string;
|
||||
missingArtifacts: string[];
|
||||
};
|
||||
|
||||
type CommandResult = {
|
||||
status: number | null;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
type ArchiveVerification = {
|
||||
verifiedArchives: string[];
|
||||
};
|
||||
|
||||
const verifierPath = path.resolve("scripts", "verify_public_release.mjs");
|
||||
const verifierUrl = "../scripts/verify_public_release.mjs";
|
||||
const { verifyPublicRelease, verifyReleaseArchives } = await import(verifierUrl) as {
|
||||
verifyPublicRelease: (rootDir: string) => ReleaseVerification;
|
||||
verifyReleaseArchives: (
|
||||
rootDir: string,
|
||||
options: {
|
||||
sevenZipPath: string;
|
||||
runCommand: (command: string, args: string[]) => CommandResult;
|
||||
}
|
||||
) => ArchiveVerification;
|
||||
};
|
||||
const fixtureRoots: string[] = [];
|
||||
const redistributionFiles = [
|
||||
"LICENSE",
|
||||
"resources/extractor-jvm/licenses/LGPL-2.1.txt",
|
||||
"resources/extractor-jvm/licenses/7-Zip-license.txt",
|
||||
"resources/extractor-jvm/licenses/Apache-2.0.txt",
|
||||
"resources/extractor-jvm/THIRD_PARTY_NOTICES.txt"
|
||||
] as const;
|
||||
|
||||
function writeFile(rootDir: string, relativePath: string, content: string | Buffer): void {
|
||||
const filePath = path.join(rootDir, ...relativePath.split("/"));
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
function writeRedistributionFiles(rootDir: string, packaged = false): void {
|
||||
for (const relativePath of redistributionFiles) {
|
||||
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
|
||||
let targetPath: string = relativePath;
|
||||
if (packaged && relativePath === "LICENSE") {
|
||||
targetPath = "win-unpacked/resources/LICENSE";
|
||||
} else if (packaged) {
|
||||
targetPath = `win-unpacked/resources/app.asar.unpacked/${relativePath}`;
|
||||
}
|
||||
writeFile(rootDir, targetPath, content);
|
||||
}
|
||||
}
|
||||
|
||||
function writeArchivePayload(outputDir: string, omittedName = ""): void {
|
||||
for (const relativePath of redistributionFiles) {
|
||||
if (path.basename(relativePath) === omittedName) {
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(path.resolve(...relativePath.split("/")));
|
||||
const targetPath = relativePath === "LICENSE"
|
||||
? "resources/LICENSE"
|
||||
: `resources/app.asar.unpacked/${relativePath}`;
|
||||
writeFile(outputDir, targetPath, content);
|
||||
}
|
||||
}
|
||||
|
||||
function createArchiveCommandRunner(omittedName = "") {
|
||||
return (command: string, args: string[]): CommandResult => {
|
||||
const archivePath = args[1] || "";
|
||||
const outputArg = args.find((arg) => arg.startsWith("-o"));
|
||||
if (!outputArg) {
|
||||
return { status: 2, stderr: "missing output directory" };
|
||||
}
|
||||
const outputDir = outputArg.slice(2);
|
||||
if (archivePath.toLowerCase().endsWith(".exe")) {
|
||||
writeFile(outputDir, "payload/app-64.7z", "nested archive");
|
||||
} else if (archivePath.toLowerCase().endsWith(".7z")) {
|
||||
writeArchivePayload(outputDir, omittedName);
|
||||
}
|
||||
return { status: command ? 0 : 2, stdout: "ok", stderr: "" };
|
||||
};
|
||||
}
|
||||
|
||||
function createReleaseFixture(): string {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-metadata-"));
|
||||
fixtureRoots.push(rootDir);
|
||||
const setupPayload = Buffer.from("setup");
|
||||
const setupSha512 = crypto.createHash("sha512").update(setupPayload).digest("base64");
|
||||
|
||||
writeFile(rootDir, "package.json", `${JSON.stringify({
|
||||
name: "real-debrid-downloader",
|
||||
version: "1.7.233",
|
||||
build: {
|
||||
productName: "Real-Debrid-Downloader",
|
||||
publish: {
|
||||
provider: "github",
|
||||
owner: "Sucukdeluxe",
|
||||
repo: "multi-debrid-downloader"
|
||||
},
|
||||
files: [
|
||||
"build/main/**/*",
|
||||
"build/renderer/**/*",
|
||||
"resources/extractor-jvm/**/*",
|
||||
"LICENSE",
|
||||
"package.json"
|
||||
],
|
||||
extraResources: [
|
||||
{
|
||||
from: "LICENSE",
|
||||
to: "LICENSE"
|
||||
}
|
||||
],
|
||||
nsis: {
|
||||
artifactName: "${productName}-Setup-${version}.${ext}",
|
||||
oneClick: false,
|
||||
perMachine: false,
|
||||
allowToChangeInstallationDirectory: true,
|
||||
createDesktopShortcut: true
|
||||
},
|
||||
portable: {
|
||||
artifactName: "${productName}-${version}-portable.${ext}"
|
||||
}
|
||||
}
|
||||
}, null, 2)}\n`);
|
||||
writeFile(
|
||||
rootDir,
|
||||
"latest.yml",
|
||||
`version: 1.7.233\nfiles:\n - url: Real-Debrid-Downloader-Setup-1.7.233.exe\n sha512: ${setupSha512}\n size: ${setupPayload.length}\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\nsha512: ${setupSha512}\n`
|
||||
);
|
||||
writeFile(
|
||||
rootDir,
|
||||
"win-unpacked/resources/app-update.yml",
|
||||
"provider: github\nowner: Sucukdeluxe\nrepo: multi-debrid-downloader\n"
|
||||
);
|
||||
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe", setupPayload);
|
||||
writeFile(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe.blockmap", "blockmap");
|
||||
writeFile(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe", "portable");
|
||||
writeRedistributionFiles(rootDir);
|
||||
writeRedistributionFiles(rootDir, true);
|
||||
|
||||
return rootDir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const rootDir of fixtureRoots.splice(0)) {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("public release metadata", () => {
|
||||
it("accepts the canonical GitHub release metadata and artifacts", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
|
||||
const result = verifyPublicRelease(rootDir);
|
||||
|
||||
expect(result.publish).toEqual({
|
||||
provider: "github",
|
||||
owner: "Sucukdeluxe",
|
||||
repo: "multi-debrid-downloader"
|
||||
});
|
||||
expect(result.latestArtifact).toBe("Real-Debrid-Downloader-Setup-1.7.233.exe");
|
||||
expect(result.missingArtifacts).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a package configured for a different GitHub owner", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const packagePath = path.join(rootDir, "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
packageJson.build.publish.owner = "DifferentOwner";
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/owner/i);
|
||||
});
|
||||
|
||||
it("rejects a latest.yml path whose artifact does not exist", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.rmSync(path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe"));
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/Real-Debrid-Downloader-Setup-1\.7\.233\.exe/);
|
||||
});
|
||||
|
||||
it("rejects syntactically invalid latest.yml", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "latest.yml"),
|
||||
"version: 1.7.233\nfiles: [\npath: Real-Debrid-Downloader-Setup-1.7.233.exe\n"
|
||||
);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/latest\.yml|yaml/i);
|
||||
});
|
||||
|
||||
it("rejects a noncanonical files entry in latest.yml", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const latestPath = path.join(rootDir, "latest.yml");
|
||||
const latest = fs.readFileSync(latestPath, "utf8").replace(
|
||||
"url: Real-Debrid-Downloader-Setup-1.7.233.exe",
|
||||
"url: Different-Setup-1.7.233.exe"
|
||||
);
|
||||
fs.writeFileSync(latestPath, latest);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/files|url|canonical/i);
|
||||
});
|
||||
|
||||
it("rejects a latest.yml SHA512 digest that does not match the installer", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const latestPath = path.join(rootDir, "latest.yml");
|
||||
const latest = fs.readFileSync(latestPath, "utf8");
|
||||
const wrongDigest = Buffer.alloc(64, 0x23).toString("base64");
|
||||
fs.writeFileSync(latestPath, latest.replace(/sha512: [^\n]+/g, `sha512: ${wrongDigest}`));
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/sha512|digest|integrity/i);
|
||||
});
|
||||
|
||||
it("rejects a directory in place of an artifact file", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
|
||||
fs.rmSync(setupPath);
|
||||
fs.mkdirSync(setupPath);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|file/i);
|
||||
});
|
||||
|
||||
it("rejects an empty artifact file", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.writeFileSync(path.join(rootDir, "Real-Debrid-Downloader-1.7.233-portable.exe"), "");
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/artifact|empty|file/i);
|
||||
});
|
||||
|
||||
it("rejects a release missing a declared redistribution license", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.rmSync(path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt"));
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/Apache-2\.0\.txt/);
|
||||
});
|
||||
|
||||
it("rejects a modified official license text", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.appendFileSync(
|
||||
path.join(rootDir, "resources", "extractor-jvm", "licenses", "LGPL-2.1.txt"),
|
||||
"modified"
|
||||
);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/LGPL-2\.1\.txt|digest|content/i);
|
||||
});
|
||||
|
||||
it("rejects swapped third-party license assignments", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const noticePath = path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt");
|
||||
const notice = fs.readFileSync(noticePath, "utf8")
|
||||
.replace("GNU Lesser General Public License 2.1 or later", "Apache License 2.0")
|
||||
.replace("licenses/LGPL-2.1.txt; licenses/7-Zip-license.txt", "licenses/Apache-2.0.txt");
|
||||
fs.writeFileSync(noticePath, notice);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|digest|mapping/i);
|
||||
});
|
||||
|
||||
it("rejects a release whose unpacked application omits a license", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.rmSync(path.join(
|
||||
rootDir,
|
||||
"win-unpacked",
|
||||
"resources",
|
||||
"app.asar.unpacked",
|
||||
"resources",
|
||||
"extractor-jvm",
|
||||
"licenses",
|
||||
"Apache-2.0.txt"
|
||||
));
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/win-unpacked|Apache-2\.0\.txt|packaged/i);
|
||||
});
|
||||
|
||||
it("rejects a symlink in place of a release artifact", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const setupPath = path.join(rootDir, "Real-Debrid-Downloader-Setup-1.7.233.exe");
|
||||
const targetPath = path.join(rootDir, "setup-target.exe");
|
||||
fs.renameSync(setupPath, targetPath);
|
||||
fs.symlinkSync(targetPath, setupPath, "file");
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
|
||||
});
|
||||
|
||||
it("rejects a symlink in place of an official license", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const licensePath = path.join(rootDir, "resources", "extractor-jvm", "licenses", "Apache-2.0.txt");
|
||||
const targetPath = path.join(rootDir, "Apache-target.txt");
|
||||
fs.renameSync(licensePath, targetPath);
|
||||
fs.symlinkSync(targetPath, licensePath, "file");
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/symbolic|symlink|regular file/i);
|
||||
});
|
||||
|
||||
it("rejects build metadata that omits the project license", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const packagePath = path.join(rootDir, "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
packageJson.build.files = packageJson.build.files.filter((entry: string) => entry !== "LICENSE");
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/LICENSE/);
|
||||
});
|
||||
|
||||
it("rejects build metadata that does not copy the project license into resources", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const packagePath = path.join(rootDir, "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
delete packageJson.build.extraResources;
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/extraResources|LICENSE/);
|
||||
});
|
||||
|
||||
it("rejects incomplete third-party redistribution notices", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "resources", "extractor-jvm", "THIRD_PARTY_NOTICES.txt"),
|
||||
"net.sf.sevenzipjbinding:sevenzipjbinding:16.02-2.01 LGPL-2.1.txt\n"
|
||||
);
|
||||
|
||||
expect(() => verifyPublicRelease(rootDir)).toThrow(/THIRD_PARTY_NOTICES|notice|content/i);
|
||||
});
|
||||
|
||||
it("returns a nonzero CLI status for invalid release metadata", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const packagePath = path.join(rootDir, "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
packageJson.build.publish.owner = "DifferentOwner";
|
||||
fs.writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
const result = spawnSync(process.execPath, [verifierPath, rootDir], {
|
||||
encoding: "utf8"
|
||||
});
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toMatch(/owner/i);
|
||||
});
|
||||
|
||||
it("recursively verifies redistribution files inside setup and portable archives", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const result = verifyReleaseArchives(rootDir, {
|
||||
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
|
||||
runCommand: createArchiveCommandRunner()
|
||||
});
|
||||
|
||||
expect(result.verifiedArchives).toEqual([
|
||||
"Real-Debrid-Downloader-Setup-1.7.233.exe",
|
||||
"Real-Debrid-Downloader-1.7.233-portable.exe"
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an archive whose nested application payload omits a license", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
|
||||
expect(() => verifyReleaseArchives(rootDir, {
|
||||
sevenZipPath: "C:\\Tools\\7-Zip\\7z.exe",
|
||||
runCommand: createArchiveCommandRunner("Apache-2.0.txt")
|
||||
})).toThrow(/Apache-2\.0\.txt|missing redistribution file/i);
|
||||
});
|
||||
|
||||
it("exposes archive verification as a nonzero CLI gate", () => {
|
||||
const rootDir = createReleaseFixture();
|
||||
const result = spawnSync(process.execPath, [
|
||||
verifierPath,
|
||||
rootDir,
|
||||
"--verify-archives",
|
||||
"--seven-zip",
|
||||
path.join(rootDir, "missing-7z.exe")
|
||||
], {
|
||||
encoding: "utf8"
|
||||
});
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toMatch(/7-Zip|command|spawn/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockSessionFetch,
|
||||
mockClearStorageData,
|
||||
mockClearCache,
|
||||
mockFromPartition,
|
||||
mockBrowserWindow,
|
||||
mockBrowserWindowCtor,
|
||||
mockExecuteJavaScript,
|
||||
mockLoadURL,
|
||||
mockShow,
|
||||
mockFocus
|
||||
} = vi.hoisted(() => {
|
||||
const sessionFetch = vi.fn();
|
||||
const clearStorageData = vi.fn();
|
||||
const clearCache = vi.fn();
|
||||
const fromPartition = vi.fn();
|
||||
const executeJavaScript = vi.fn();
|
||||
const loadURL = vi.fn(async () => {});
|
||||
const show = vi.fn();
|
||||
const focus = vi.fn();
|
||||
const webContentsEvents: Record<string, (...args: unknown[]) => void> = {};
|
||||
const windowEvents: Record<string, (...args: unknown[]) => void> = {};
|
||||
let destroyed = false;
|
||||
|
||||
const browserWindow = {
|
||||
isDestroyed: vi.fn(() => destroyed),
|
||||
isMinimized: vi.fn(() => false),
|
||||
restore: vi.fn(),
|
||||
show,
|
||||
focus,
|
||||
close: vi.fn(() => {
|
||||
destroyed = true;
|
||||
windowEvents.closed?.();
|
||||
}),
|
||||
setMenuBarVisibility: vi.fn(),
|
||||
loadURL,
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
windowEvents[event] = handler;
|
||||
return browserWindow;
|
||||
}),
|
||||
webContents: {
|
||||
setUserAgent: vi.fn(),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
webContentsEvents[event] = handler;
|
||||
}),
|
||||
executeJavaScript
|
||||
}
|
||||
};
|
||||
|
||||
const BrowserWindowCtor = vi.fn(() => {
|
||||
destroyed = false;
|
||||
return browserWindow;
|
||||
});
|
||||
|
||||
return {
|
||||
mockSessionFetch: sessionFetch,
|
||||
mockClearStorageData: clearStorageData,
|
||||
mockClearCache: clearCache,
|
||||
mockFromPartition: fromPartition,
|
||||
mockBrowserWindow: browserWindow,
|
||||
mockBrowserWindowCtor: BrowserWindowCtor,
|
||||
mockExecuteJavaScript: executeJavaScript,
|
||||
mockLoadURL: loadURL,
|
||||
mockShow: show,
|
||||
mockFocus: focus
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
session: {
|
||||
fromPartition: mockFromPartition
|
||||
},
|
||||
BrowserWindow: mockBrowserWindowCtor
|
||||
}));
|
||||
|
||||
import { RealDebridWebFallback, extractPrivateTokenFromHtml } from "../src/main/realdebrid-web";
|
||||
|
||||
describe("realdebrid-web", () => {
|
||||
const mockSession = {
|
||||
fetch: mockSessionFetch,
|
||||
clearStorageData: mockClearStorageData,
|
||||
clearCache: mockClearCache
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFromPartition.mockReturnValue(mockSession);
|
||||
mockExecuteJavaScript.mockReset();
|
||||
mockLoadURL.mockClear();
|
||||
mockShow.mockClear();
|
||||
mockFocus.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
mockFromPartition.mockReturnValue(mockSession);
|
||||
});
|
||||
|
||||
it("extracts private tokens from current Real-Debrid HTML patterns", () => {
|
||||
expect(extractPrivateTokenFromHtml("document.querySelectorAll('input[name=private_token]')[0].value = 'abc123';"))
|
||||
.toBe("abc123");
|
||||
expect(extractPrivateTokenFromHtml("<input type=\"text\" name=\"private_token\" value=\"def456\">"))
|
||||
.toBe("def456");
|
||||
expect(extractPrivateTokenFromHtml("<input value=\"ghi789\" name=\"private_token\">"))
|
||||
.toBe("ghi789");
|
||||
});
|
||||
|
||||
it("uses the already logged-in browser window to warm the token cache before unrestricting", async () => {
|
||||
const apiFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
download: "https://cdn.real-debrid.example/file.bin",
|
||||
filename: "file.bin",
|
||||
filesize: 12345
|
||||
}), { status: 200 }));
|
||||
vi.stubGlobal("fetch", apiFetch);
|
||||
|
||||
mockExecuteJavaScript.mockResolvedValue("token-from-window");
|
||||
|
||||
const fallback = new RealDebridWebFallback(() => true);
|
||||
await fallback.openLoginWindow();
|
||||
|
||||
const result = await fallback.unrestrict("https://rapidgator.net/file/abc");
|
||||
|
||||
expect(result).toEqual({
|
||||
directUrl: "https://cdn.real-debrid.example/file.bin",
|
||||
fileName: "file.bin",
|
||||
fileSize: 12345,
|
||||
retriesUsed: 0
|
||||
});
|
||||
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
|
||||
expect(mockLoadURL).toHaveBeenCalledWith("https://real-debrid.com");
|
||||
expect(mockShow).toHaveBeenCalled();
|
||||
expect(mockFocus).toHaveBeenCalled();
|
||||
expect(mockSessionFetch).not.toHaveBeenCalled();
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
expect(apiFetch.mock.calls[0]?.[0]).toBe("https://api.real-debrid.com/rest/1.0/unrestrict/link");
|
||||
expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { RealDebridClient } from "../src/main/realdebrid";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("realdebrid client", () => {
|
||||
it("returns a clear error when HTML is returned instead of JSON", async () => {
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response("<html><title>Cloudflare</title></html>", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html" }
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const client = new RealDebridClient("rd-token");
|
||||
await expect(client.unrestrictLink("https://hoster.example/file/html")).rejects.toThrow(/html/i);
|
||||
});
|
||||
|
||||
it("does not leak raw response body on JSON parse errors", async () => {
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response("<html>token=secret-should-not-leak</html>", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const client = new RealDebridClient("rd-token");
|
||||
try {
|
||||
await client.unrestrictLink("https://hoster.example/file/invalid-json");
|
||||
throw new Error("expected unrestrict to fail");
|
||||
} catch (error) {
|
||||
const text = String(error || "");
|
||||
expect(text.toLowerCase()).toContain("json");
|
||||
expect(text.toLowerCase()).not.toContain("secret-should-not-leak");
|
||||
expect(text.toLowerCase()).not.toContain("<html>");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { getRenameLogPath, initRenameLog, logRenameEvent, shutdownRenameLog } from "../src/main/rename-log";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
shutdownRenameLog();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("rename-log", () => {
|
||||
it("writes rename events to the rename log", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rlog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initRenameLog(baseDir);
|
||||
logRenameEvent("INFO", "Auto-Rename durchgeführt", {
|
||||
packageName: "Test Paket",
|
||||
sourcePath: "C:\\extract\\old.mkv",
|
||||
targetPath: "C:\\extract\\new.mkv"
|
||||
});
|
||||
|
||||
const logPath = getRenameLogPath();
|
||||
expect(logPath).not.toBeNull();
|
||||
expect(fs.existsSync(logPath!)).toBe(true);
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("Rename-Log Start");
|
||||
expect(content).toContain("Auto-Rename durchgeführt");
|
||||
expect(content).toContain("sourcePath=C:\\extract\\old.mkv");
|
||||
});
|
||||
|
||||
it("rotates oversized rename logs on startup", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rlog-rotate-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
const oversizedPath = path.join(baseDir, "rename.log");
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8");
|
||||
|
||||
initRenameLog(baseDir);
|
||||
|
||||
expect(fs.existsSync(oversizedPath)).toBe(true);
|
||||
expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true);
|
||||
const content = fs.readFileSync(oversizedPath, "utf8");
|
||||
expect(content).toContain("Rename-Log Start");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveArchiveItemsFromList } from "../src/main/download-manager";
|
||||
|
||||
type MinimalItem = {
|
||||
targetPath?: string;
|
||||
fileName?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function makeItems(names: string[]): MinimalItem[] {
|
||||
return names.map((name) => ({
|
||||
targetPath: `C:\\Downloads\\Package\\${name}`,
|
||||
fileName: name,
|
||||
id: name,
|
||||
status: "completed",
|
||||
}));
|
||||
}
|
||||
|
||||
describe("resolveArchiveItemsFromList", () => {
|
||||
|
||||
it("matches multipart .part1.rar archives", () => {
|
||||
const items = makeItems([
|
||||
"Movie.part1.rar",
|
||||
"Movie.part2.rar",
|
||||
"Movie.part3.rar",
|
||||
"Other.rar",
|
||||
]);
|
||||
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((i: any) => i.fileName)).toEqual([
|
||||
"Movie.part1.rar",
|
||||
"Movie.part2.rar",
|
||||
"Movie.part3.rar",
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches multipart .part01.rar archives (zero-padded)", () => {
|
||||
const items = makeItems([
|
||||
"Film.part01.rar",
|
||||
"Film.part02.rar",
|
||||
"Film.part10.rar",
|
||||
"Unrelated.zip",
|
||||
]);
|
||||
const result = resolveArchiveItemsFromList("Film.part01.rar", items as any);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("matches old-style .rar + .rNN volumes", () => {
|
||||
const items = makeItems([
|
||||
"Archive.rar",
|
||||
"Archive.r00",
|
||||
"Archive.r01",
|
||||
"Archive.r02",
|
||||
"Other.zip",
|
||||
]);
|
||||
const result = resolveArchiveItemsFromList("Archive.rar", items as any);
|
||||
expect(result).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("matches a single .rar file", () => {
|
||||
const items = makeItems(["SingleFile.rar", "Other.mkv"]);
|
||||
const result = resolveArchiveItemsFromList("SingleFile.rar", items as any);
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result[0] as any).fileName).toBe("SingleFile.rar");
|
||||
});
|
||||
|
||||
it("matches split .zip.NNN files", () => {
|
||||
const items = makeItems([
|
||||
"Data.zip",
|
||||
"Data.zip.001",
|
||||
"Data.zip.002",
|
||||
"Data.zip.003",
|
||||
]);
|
||||
const result = resolveArchiveItemsFromList("Data.zip.001", items as any);
|
||||
expect(result).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("matches split .7z.NNN files", () => {
|
||||
const items = makeItems([
|
||||
"Backup.7z.001",
|
||||
"Backup.7z.002",
|
||||
]);
|
||||
const result = resolveArchiveItemsFromList("Backup.7z.001", items as any);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("matches generic .NNN split files", () => {
|
||||
const items = makeItems([
|
||||
"video.001",
|
||||
"video.002",
|
||||
"video.003",
|
||||
]);
|
||||
const result = resolveArchiveItemsFromList("video.001", items as any);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("matches a single .zip by exact name", () => {
|
||||
const items = makeItems(["myarchive.zip", "other.rar"]);
|
||||
const result = resolveArchiveItemsFromList("myarchive.zip", items as any);
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result[0] as any).fileName).toBe("myarchive.zip");
|
||||
});
|
||||
|
||||
it("matches case-insensitively", () => {
|
||||
const items = makeItems([
|
||||
"MOVIE.PART1.RAR",
|
||||
"MOVIE.PART2.RAR",
|
||||
]);
|
||||
const result = resolveArchiveItemsFromList("movie.part1.rar", items as any);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("uses stem-based fallback when exact patterns fail", () => {
|
||||
const items = makeItems([
|
||||
"Movie.rar",
|
||||
]);
|
||||
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns single archive item when no pattern matches", () => {
|
||||
const items = makeItems(["totally-different-name.rar"]);
|
||||
const result = resolveArchiveItemsFromList("Original.rar", items as any);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns empty when items have no archive extensions", () => {
|
||||
const items = makeItems(["video.mkv", "subtitle.srt"]);
|
||||
const result = resolveArchiveItemsFromList("Archive.rar", items as any);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("falls back to fileName when targetPath is missing", () => {
|
||||
const items = [
|
||||
{ fileName: "Movie.part1.rar", id: "1", status: "completed" },
|
||||
{ fileName: "Movie.part2.rar", id: "2", status: "completed" },
|
||||
];
|
||||
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not cross-match different archive groups", () => {
|
||||
const items = makeItems([
|
||||
"Episode.S01E01.part1.rar",
|
||||
"Episode.S01E01.part2.rar",
|
||||
"Episode.S01E02.part1.rar",
|
||||
"Episode.S01E02.part2.rar",
|
||||
]);
|
||||
const result1 = resolveArchiveItemsFromList("Episode.S01E01.part1.rar", items as any);
|
||||
expect(result1).toHaveLength(2);
|
||||
expect(result1.every((i: any) => i.fileName.includes("S01E01"))).toBe(true);
|
||||
|
||||
const result2 = resolveArchiveItemsFromList("Episode.S01E02.part1.rar", items as any);
|
||||
expect(result2).toHaveLength(2);
|
||||
expect(result2.every((i: any) => i.fileName.includes("S01E02"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pruneSelection } from "../src/renderer/selection";
|
||||
import type { SessionState } from "../src/shared/types";
|
||||
|
||||
function session(packageIds: string[], itemIds: string[]): Pick<SessionState, "packages" | "items"> {
|
||||
const packages: Record<string, never> = {};
|
||||
const items: Record<string, never> = {};
|
||||
for (const id of packageIds) packages[id] = {} as never;
|
||||
for (const id of itemIds) items[id] = {} as never;
|
||||
return { packages, items };
|
||||
}
|
||||
|
||||
describe("pruneSelection", () => {
|
||||
it("drops ids whose package/item no longer exists", () => {
|
||||
const sel = new Set(["p1", "i1", "ghost-p", "ghost-i"]);
|
||||
const next = pruneSelection(sel, session(["p1"], ["i1"]));
|
||||
expect([...next].sort()).toEqual(["i1", "p1"]);
|
||||
});
|
||||
|
||||
it("returns the SAME set instance when nothing changed (no needless re-render)", () => {
|
||||
const sel = new Set(["p1", "i1"]);
|
||||
const next = pruneSelection(sel, session(["p1"], ["i1"]));
|
||||
expect(next).toBe(sel);
|
||||
});
|
||||
|
||||
it("returns the same instance for an empty selection", () => {
|
||||
const sel = new Set<string>();
|
||||
expect(pruneSelection(sel, session(["p1"], ["i1"]))).toBe(sel);
|
||||
});
|
||||
|
||||
it("prunes everything when the whole session was swapped out", () => {
|
||||
const sel = new Set(["p1", "i1"]);
|
||||
const next = pruneSelection(sel, session([], []));
|
||||
expect(next.size).toBe(0);
|
||||
expect(next).not.toBe(sel);
|
||||
});
|
||||
|
||||
it("keeps a mixed package+item selection when both survive", () => {
|
||||
const sel = new Set(["p1", "p2", "i1"]);
|
||||
const next = pruneSelection(sel, session(["p1", "p2"], ["i1", "i2"]));
|
||||
expect([...next].sort()).toEqual(["i1", "p1", "p2"]);
|
||||
expect(next).toBe(sel); // unchanged → same instance
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import { once } from "node:events";
|
||||
import { DownloadManager } from "../src/main/download-manager";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) {
|
||||
throw new Error(`Self-check fehlgeschlagen: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 20000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("Timeout während Self-check");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
async function runDownloadCase(baseDir: string, baseUrl: string, url: string, options?: Partial<ReturnType<typeof defaultSettings>>): Promise<DownloadManager> {
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "demo-token",
|
||||
outputDir: path.join(baseDir, "downloads"),
|
||||
extractDir: path.join(baseDir, "extract"),
|
||||
autoExtract: false,
|
||||
autoReconnect: true,
|
||||
reconnectWaitSeconds: 1,
|
||||
...options
|
||||
};
|
||||
|
||||
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(baseDir, "state")));
|
||||
manager.addPackages([
|
||||
{
|
||||
name: "test-package",
|
||||
links: [url]
|
||||
}
|
||||
]);
|
||||
manager.start();
|
||||
await waitFor(() => !manager.getSnapshot().session.running, 30000);
|
||||
return manager;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rd-node-self-"));
|
||||
const binary = Buffer.alloc(512 * 1024, 7);
|
||||
let flakyFailures = 1;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = req.url || "/";
|
||||
if (url.startsWith("/file.bin") || url.startsWith("/slow.bin") || url.startsWith("/rarcancel.bin") || url.startsWith("/flaky.bin")) {
|
||||
if (url.startsWith("/flaky.bin") && flakyFailures > 0) {
|
||||
flakyFailures -= 1;
|
||||
res.statusCode = 503;
|
||||
res.end("retry");
|
||||
return;
|
||||
}
|
||||
|
||||
const range = req.headers.range;
|
||||
let start = 0;
|
||||
if (range) {
|
||||
const match = String(range).match(/bytes=(\d+)-/i);
|
||||
if (match) {
|
||||
start = Number(match[1]);
|
||||
}
|
||||
}
|
||||
const chunk = binary.subarray(start);
|
||||
if (start > 0) {
|
||||
res.statusCode = 206;
|
||||
res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`);
|
||||
}
|
||||
res.setHeader("Accept-Ranges", "bytes");
|
||||
res.setHeader("Content-Length", chunk.length);
|
||||
res.statusCode = res.statusCode || 200;
|
||||
|
||||
if (url.startsWith("/slow.bin") || url.startsWith("/rarcancel.bin")) {
|
||||
const mid = Math.floor(chunk.length / 2);
|
||||
res.write(chunk.subarray(0, mid));
|
||||
setTimeout(() => {
|
||||
res.end(chunk.subarray(mid));
|
||||
}, 400);
|
||||
return;
|
||||
}
|
||||
res.end(chunk);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end("not-found");
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Server konnte nicht gestartet werden");
|
||||
}
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/unrestrict/link")) {
|
||||
const body = init?.body;
|
||||
const params = body instanceof URLSearchParams ? body : new URLSearchParams(String(body || ""));
|
||||
const link = params.get("link") || "";
|
||||
const filename = link.includes("rarcancel") ? "release.part1.rar" : "file.bin";
|
||||
const direct = link.includes("slow")
|
||||
? `${baseUrl}/slow.bin`
|
||||
: link.includes("rarcancel")
|
||||
? `${baseUrl}/rarcancel.bin`
|
||||
: link.includes("flaky")
|
||||
? `${baseUrl}/flaky.bin`
|
||||
: `${baseUrl}/file.bin`;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
download: direct,
|
||||
filename,
|
||||
filesize: binary.length
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
);
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
|
||||
try {
|
||||
const manager1 = await runDownloadCase(tempRoot, baseUrl, "https://dummy/file");
|
||||
const snapshot1 = manager1.getSnapshot();
|
||||
const item1 = Object.values(snapshot1.session.items)[0];
|
||||
assert(item1?.status === "completed", "normaler Download wurde nicht abgeschlossen");
|
||||
assert(fs.existsSync(item1.targetPath), "Datei fehlt nach Download");
|
||||
|
||||
const manager2 = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "demo-token",
|
||||
outputDir: path.join(tempRoot, "downloads-pause"),
|
||||
extractDir: path.join(tempRoot, "extract-pause"),
|
||||
autoExtract: false,
|
||||
autoReconnect: false
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(tempRoot, "state-pause"))
|
||||
);
|
||||
manager2.addPackages([{ name: "pause", links: ["https://dummy/slow"] }]);
|
||||
await manager2.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
const paused = manager2.togglePause();
|
||||
assert(paused, "Pause konnte nicht aktiviert werden");
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
manager2.togglePause();
|
||||
await waitFor(() => !manager2.getSnapshot().session.running, 30000);
|
||||
const item2 = Object.values(manager2.getSnapshot().session.items)[0];
|
||||
assert(item2?.status === "completed", "Pause/Resume Download nicht abgeschlossen");
|
||||
|
||||
const manager3 = await runDownloadCase(tempRoot, baseUrl, "https://dummy/flaky", { autoReconnect: true, reconnectWaitSeconds: 1 });
|
||||
const item3 = Object.values(manager3.getSnapshot().session.items)[0];
|
||||
assert(item3?.status === "completed", "Reconnect-Fall nicht abgeschlossen");
|
||||
|
||||
const manager4 = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "demo-token",
|
||||
outputDir: path.join(tempRoot, "downloads-cancel"),
|
||||
extractDir: path.join(tempRoot, "extract-cancel"),
|
||||
autoExtract: false
|
||||
},
|
||||
emptySession(),
|
||||
createStoragePaths(path.join(tempRoot, "state-cancel"))
|
||||
);
|
||||
manager4.addPackages([{ name: "cancel", links: ["https://dummy/rarcancel"] }]);
|
||||
manager4.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const pkgId = manager4.getSnapshot().session.packageOrder[0];
|
||||
manager4.cancelPackage(pkgId);
|
||||
await waitFor(() => !manager4.getSnapshot().session.running || Object.values(manager4.getSnapshot().session.items).every((item) => item.status !== "downloading"), 15000);
|
||||
const cancelSnapshot = manager4.getSnapshot();
|
||||
const remainingItems = Object.values(cancelSnapshot.session.items);
|
||||
if (remainingItems.length === 0) {
|
||||
assert(cancelSnapshot.session.packageOrder.length === 0, "Abgebrochenes Paket wurde nicht entfernt");
|
||||
} else {
|
||||
const cancelItem = remainingItems[0];
|
||||
assert(cancelItem?.status === "cancelled" || cancelItem?.status === "queued", "Paketabbruch nicht wirksam");
|
||||
}
|
||||
const packageDir = path.join(path.join(tempRoot, "downloads-cancel"), "cancel");
|
||||
const cancelArtifact = path.join(packageDir, "release.part1.rar");
|
||||
await waitFor(() => !fs.existsSync(cancelArtifact), 10000);
|
||||
assert(!fs.existsSync(cancelArtifact), "RAR-Artefakt wurde nicht gelöscht");
|
||||
|
||||
console.log("Node self-check erfolgreich");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
server.close();
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -0,0 +1,151 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "../src/main/session-log";
|
||||
import { setLogListener } from "../src/main/logger";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
shutdownSessionLog();
|
||||
setLogListener(null);
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("session-log", () => {
|
||||
it("initSessionLog creates directory and file", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initSessionLog(baseDir);
|
||||
const logPath = getSessionLogPath();
|
||||
expect(logPath).not.toBeNull();
|
||||
expect(fs.existsSync(logPath!)).toBe(true);
|
||||
expect(fs.existsSync(path.join(baseDir, "session-logs"))).toBe(true);
|
||||
expect(path.basename(logPath!)).toMatch(/^session_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.txt$/);
|
||||
|
||||
const content = fs.readFileSync(logPath!, "utf8");
|
||||
expect(content).toContain("=== Session gestartet:");
|
||||
|
||||
shutdownSessionLog();
|
||||
});
|
||||
|
||||
it("logger listener writes to session log", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initSessionLog(baseDir);
|
||||
const logPath = getSessionLogPath()!;
|
||||
|
||||
const { logger } = await import("../src/main/logger");
|
||||
logger.info("Test-Nachricht für Session-Log");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const content = fs.readFileSync(logPath, "utf8");
|
||||
expect(content).toContain("Test-Nachricht für Session-Log");
|
||||
|
||||
shutdownSessionLog();
|
||||
});
|
||||
|
||||
it("shutdownSessionLog writes closing line", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initSessionLog(baseDir);
|
||||
const logPath = getSessionLogPath()!;
|
||||
|
||||
shutdownSessionLog();
|
||||
|
||||
const content = fs.readFileSync(logPath, "utf8");
|
||||
expect(content).toContain("=== Session beendet:");
|
||||
});
|
||||
|
||||
it("shutdownSessionLog removes listener", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initSessionLog(baseDir);
|
||||
const logPath = getSessionLogPath()!;
|
||||
|
||||
shutdownSessionLog();
|
||||
|
||||
const { logger } = await import("../src/main/logger");
|
||||
logger.info("Nach-Shutdown-Nachricht");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const content = fs.readFileSync(logPath, "utf8");
|
||||
expect(content).not.toContain("Nach-Shutdown-Nachricht");
|
||||
});
|
||||
|
||||
it("cleanupOldSessionLogs deletes old files", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
const logsDir = path.join(baseDir, "session-logs");
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
const oldFile = path.join(logsDir, "session_2020-01-01_00-00-00.txt");
|
||||
fs.writeFileSync(oldFile, "old session");
|
||||
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
fs.utimesSync(oldFile, oldTime, oldTime);
|
||||
|
||||
const newFile = path.join(logsDir, "session_2099-01-01_00-00-00.txt");
|
||||
fs.writeFileSync(newFile, "new session");
|
||||
|
||||
initSessionLog(baseDir);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
expect(fs.existsSync(oldFile)).toBe(false);
|
||||
expect(fs.existsSync(newFile)).toBe(true);
|
||||
|
||||
shutdownSessionLog();
|
||||
});
|
||||
|
||||
it("cleanupOldSessionLogs keeps recent files", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
const logsDir = path.join(baseDir, "session-logs");
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
const recentFile = path.join(logsDir, "session_2025-12-01_00-00-00.txt");
|
||||
fs.writeFileSync(recentFile, "recent session");
|
||||
const recentTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
||||
fs.utimesSync(recentFile, recentTime, recentTime);
|
||||
|
||||
initSessionLog(baseDir);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
expect(fs.existsSync(recentFile)).toBe(true);
|
||||
|
||||
shutdownSessionLog();
|
||||
});
|
||||
|
||||
it("multiple sessions create different files", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-slog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
initSessionLog(baseDir);
|
||||
const path1 = getSessionLogPath();
|
||||
shutdownSessionLog();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
initSessionLog(baseDir);
|
||||
const path2 = getSessionLogPath();
|
||||
shutdownSessionLog();
|
||||
|
||||
expect(path1).not.toBeNull();
|
||||
expect(path2).not.toBeNull();
|
||||
expect(path1).not.toBe(path2);
|
||||
expect(fs.existsSync(path1!)).toBe(true);
|
||||
expect(fs.existsSync(path2!)).toBe(true);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,134 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { createStoragePaths } from "../src/main/storage";
|
||||
import { runStartupHealthCheck } from "../src/main/startup-health-check";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function makeTempBase(): { baseDir: string; outputDir: string; paths: ReturnType<typeof createStoragePaths> } {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-"));
|
||||
tempDirs.push(baseDir);
|
||||
const outputDir = path.join(baseDir, "downloads");
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
return {
|
||||
baseDir: path.join(baseDir, "runtime"),
|
||||
outputDir,
|
||||
paths: createStoragePaths(path.join(baseDir, "runtime"))
|
||||
};
|
||||
}
|
||||
|
||||
describe("runStartupHealthCheck", () => {
|
||||
it("flags missing download directory", () => {
|
||||
const { outputDir, paths } = makeTempBase();
|
||||
fs.mkdirSync(paths.baseDir, { recursive: true });
|
||||
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(outputDir, "does-not-exist-subdir")
|
||||
};
|
||||
const report = runStartupHealthCheck(settings, paths);
|
||||
const codes = report.findings.map((f) => f.code);
|
||||
expect(codes).toContain("outputDir_not_found");
|
||||
});
|
||||
|
||||
it("flags no-provider-configured when all credentials are empty", () => {
|
||||
const { outputDir, paths } = makeTempBase();
|
||||
fs.mkdirSync(paths.baseDir, { recursive: true });
|
||||
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "",
|
||||
megaLogin: "",
|
||||
megaPassword: "",
|
||||
megaCredentials: "",
|
||||
allDebridToken: "",
|
||||
bestToken: "",
|
||||
oneFichierApiKey: "",
|
||||
debridLinkApiKeys: "",
|
||||
outputDir
|
||||
};
|
||||
const report = runStartupHealthCheck(settings, paths);
|
||||
const codes = report.findings.map((f) => f.code);
|
||||
expect(codes).toContain("no_provider_configured");
|
||||
expect(report.warnCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("reports configured providers when at least one credential is set", () => {
|
||||
const { outputDir, paths } = makeTempBase();
|
||||
fs.mkdirSync(paths.baseDir, { recursive: true });
|
||||
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "rd-token-here",
|
||||
debridLinkApiKeys: "dl-key-a\ndl-key-b",
|
||||
outputDir
|
||||
};
|
||||
const report = runStartupHealthCheck(settings, paths);
|
||||
const providersFinding = report.findings.find((f) => f.code === "providers_configured");
|
||||
expect(providersFinding).toBeDefined();
|
||||
expect(providersFinding?.message).toContain("Real-Debrid");
|
||||
expect(providersFinding?.message).toContain("Debrid-Link");
|
||||
expect(providersFinding?.message).toContain("2 Keys");
|
||||
});
|
||||
|
||||
it("flags large state files", () => {
|
||||
const { outputDir, paths } = makeTempBase();
|
||||
fs.mkdirSync(paths.baseDir, { recursive: true });
|
||||
fs.writeFileSync(paths.sessionFile, Buffer.alloc(60 * 1024 * 1024, 0));
|
||||
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir
|
||||
};
|
||||
const report = runStartupHealthCheck(settings, paths);
|
||||
const codes = report.findings.map((f) => f.code);
|
||||
expect(codes).toContain("large_state_file");
|
||||
});
|
||||
|
||||
it("flags missing base dir as ERROR", () => {
|
||||
const { outputDir, paths } = makeTempBase();
|
||||
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir
|
||||
};
|
||||
const report = runStartupHealthCheck(settings, paths);
|
||||
const codes = report.findings.map((f) => f.code);
|
||||
expect(codes).toContain("baseDir_missing");
|
||||
expect(report.errorCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("passes cleanly when everything is healthy", () => {
|
||||
const { outputDir, paths } = makeTempBase();
|
||||
fs.mkdirSync(paths.baseDir, { recursive: true });
|
||||
|
||||
const settings = {
|
||||
...defaultSettings(),
|
||||
token: "rd-token-here",
|
||||
outputDir
|
||||
};
|
||||
const report = runStartupHealthCheck(settings, paths);
|
||||
expect(report.errorCount).toBe(0);
|
||||
const codes = report.findings.map((f) => f.code);
|
||||
expect(codes).not.toContain("outputDir_not_found");
|
||||
expect(codes).not.toContain("outputDir_not_writable");
|
||||
expect(codes).not.toContain("no_provider_configured");
|
||||
expect(codes).not.toContain("baseDir_missing");
|
||||
expect(codes).not.toContain("baseDir_not_writable");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,867 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||
import { AppSettings } from "../src/shared/types";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings } from "../src/main/storage";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("settings storage", () => {
|
||||
it("does not persist provider credentials when rememberToken is disabled", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
saveSettings(paths, {
|
||||
...defaultSettings(),
|
||||
rememberToken: false,
|
||||
token: "rd-token",
|
||||
megaLogin: "mega-user",
|
||||
megaPassword: "mega-pass",
|
||||
bestToken: "best-token",
|
||||
allDebridToken: "all-token"
|
||||
});
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(paths.configFile, "utf8")) as Record<string, unknown>;
|
||||
expect(raw.token).toBe("");
|
||||
expect(raw.megaLogin).toBe("");
|
||||
expect(raw.megaPassword).toBe("");
|
||||
expect(raw.bestToken).toBe("");
|
||||
expect(raw.allDebridToken).toBe("");
|
||||
|
||||
const loaded = loadSettings(paths);
|
||||
expect(loaded.rememberToken).toBe(false);
|
||||
expect(loaded.token).toBe("");
|
||||
expect(loaded.megaLogin).toBe("");
|
||||
expect(loaded.megaPassword).toBe("");
|
||||
expect(loaded.bestToken).toBe("");
|
||||
expect(loaded.allDebridToken).toBe("");
|
||||
});
|
||||
|
||||
it("persists provider credentials when rememberToken is enabled", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
saveSettings(paths, {
|
||||
...defaultSettings(),
|
||||
rememberToken: true,
|
||||
token: "rd-token",
|
||||
megaLogin: "mega-user",
|
||||
megaPassword: "mega-pass",
|
||||
bestToken: "best-token",
|
||||
allDebridToken: "all-token"
|
||||
});
|
||||
|
||||
const loaded = loadSettings(paths);
|
||||
expect(loaded.token).toBe("rd-token");
|
||||
expect(loaded.megaLogin).toBe("mega-user");
|
||||
expect(loaded.megaPassword).toBe("mega-pass");
|
||||
expect(loaded.bestToken).toBe("best-token");
|
||||
expect(loaded.allDebridToken).toBe("all-token");
|
||||
});
|
||||
|
||||
it("normalizes invalid enum and numeric values", () => {
|
||||
const normalized = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
providerPrimary: "invalid-provider" as unknown as AppSettings["providerPrimary"],
|
||||
providerSecondary: "invalid-provider" as unknown as AppSettings["providerSecondary"],
|
||||
providerTertiary: "invalid-provider" as unknown as AppSettings["providerTertiary"],
|
||||
cleanupMode: "broken" as unknown as AppSettings["cleanupMode"],
|
||||
extractConflictMode: "broken" as unknown as AppSettings["extractConflictMode"],
|
||||
completedCleanupPolicy: "broken" as unknown as AppSettings["completedCleanupPolicy"],
|
||||
speedLimitMode: "broken" as unknown as AppSettings["speedLimitMode"],
|
||||
maxParallel: 0,
|
||||
retryLimit: 999,
|
||||
reconnectWaitSeconds: 9999,
|
||||
speedLimitKbps: -1,
|
||||
outputDir: " ",
|
||||
extractDir: " ",
|
||||
mkvLibraryDir: " ",
|
||||
updateRepo: " "
|
||||
});
|
||||
|
||||
expect(normalized.providerPrimary).toBe("realdebrid");
|
||||
expect(normalized.providerSecondary).toBe("none");
|
||||
expect(normalized.providerTertiary).toBe("none");
|
||||
expect(normalized.cleanupMode).toBe("none");
|
||||
expect(normalized.extractConflictMode).toBe("overwrite");
|
||||
expect(normalized.completedCleanupPolicy).toBe("never");
|
||||
expect(normalized.speedLimitMode).toBe("global");
|
||||
expect(normalized.maxParallel).toBe(1);
|
||||
expect(normalized.retryLimit).toBe(99);
|
||||
expect(normalized.reconnectWaitSeconds).toBe(600);
|
||||
expect(normalized.speedLimitKbps).toBe(0);
|
||||
expect(normalized.outputDir).toBe(defaultSettings().outputDir);
|
||||
expect(normalized.extractDir).toBe(defaultSettings().extractDir);
|
||||
expect(normalized.mkvLibraryDir).toBe(defaultSettings().mkvLibraryDir);
|
||||
expect(normalized.updateRepo).toBe(defaultSettings().updateRepo);
|
||||
});
|
||||
|
||||
it("normalizes malformed persisted config on load", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
fs.writeFileSync(
|
||||
paths.configFile,
|
||||
JSON.stringify({
|
||||
providerPrimary: "not-valid",
|
||||
completedCleanupPolicy: "not-valid",
|
||||
maxParallel: "999",
|
||||
retryLimit: "-3",
|
||||
reconnectWaitSeconds: "1",
|
||||
speedLimitMode: "not-valid",
|
||||
updateRepo: "",
|
||||
autoSortPackagesByProgress: false
|
||||
}),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const loaded = loadSettings(paths);
|
||||
expect(loaded.providerPrimary).toBe("realdebrid");
|
||||
expect(loaded.completedCleanupPolicy).toBe("never");
|
||||
expect(loaded.maxParallel).toBe(50);
|
||||
expect(loaded.retryLimit).toBe(0);
|
||||
expect(loaded.reconnectWaitSeconds).toBe(10);
|
||||
expect(loaded.speedLimitMode).toBe("global");
|
||||
expect(loaded.updateRepo).toBe(defaultSettings().updateRepo);
|
||||
expect(loaded.autoSortPackagesByProgress).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps explicit none as fallback provider choice", () => {
|
||||
const normalized = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
providerSecondary: "none",
|
||||
providerTertiary: "none"
|
||||
});
|
||||
|
||||
expect(normalized.providerSecondary).toBe("none");
|
||||
expect(normalized.providerTertiary).toBe("none");
|
||||
});
|
||||
|
||||
it("migrates legacy MegaDebrid provider selections to explicit API/Web providers", () => {
|
||||
const apiNormalized = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
megaLogin: "mega-user",
|
||||
megaPassword: "mega-pass",
|
||||
megaDebridPreferApi: true,
|
||||
providerPrimary: "megadebrid" as unknown as AppSettings["providerPrimary"],
|
||||
providerSecondary: "megadebrid" as unknown as AppSettings["providerSecondary"],
|
||||
disabledProviders: ["megadebrid" as unknown as AppSettings["providerPrimary"]]
|
||||
});
|
||||
|
||||
expect(apiNormalized.providerPrimary).toBe("megadebrid-api");
|
||||
expect(apiNormalized.providerSecondary).toBe("none");
|
||||
expect(apiNormalized.disabledProviders).toEqual(["megadebrid-api", "megadebrid-web"]);
|
||||
|
||||
const webNormalized = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
megaLogin: "mega-user",
|
||||
megaPassword: "mega-pass",
|
||||
megaDebridPreferApi: false,
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: true,
|
||||
providerPrimary: "megadebrid" as unknown as AppSettings["providerPrimary"],
|
||||
hosterRouting: { rapidgator: "megadebrid" as unknown as AppSettings["providerPrimary"] }
|
||||
});
|
||||
|
||||
expect(webNormalized.providerPrimary).toBe("megadebrid-web");
|
||||
expect(webNormalized.hosterRouting.rapidgator).toBe("megadebrid-web");
|
||||
});
|
||||
|
||||
it("migriert eine pre-v1.6.90-Config (Mega-Creds, beide Enable-Flags fehlen) zu aktiviertem Mega-Debrid statt es still auf false zu setzen", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const legacyApi = {
|
||||
megaLogin: "mega-user",
|
||||
megaPassword: "mega-pass",
|
||||
megaDebridPreferApi: true,
|
||||
providerPrimary: "realdebrid",
|
||||
providerSecondary: "megadebrid"
|
||||
};
|
||||
fs.writeFileSync(paths.configFile, JSON.stringify(legacyApi), "utf8");
|
||||
const loadedApi = loadSettings(paths);
|
||||
expect(loadedApi.megaDebridApiEnabled).toBe(true);
|
||||
expect(loadedApi.megaDebridWebEnabled).toBe(false);
|
||||
|
||||
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir2);
|
||||
const paths2 = createStoragePaths(dir2);
|
||||
const legacyWeb = {
|
||||
megaLogin: "mega-user",
|
||||
megaPassword: "mega-pass",
|
||||
megaDebridPreferApi: false,
|
||||
providerPrimary: "realdebrid",
|
||||
providerSecondary: "megadebrid"
|
||||
};
|
||||
fs.writeFileSync(paths2.configFile, JSON.stringify(legacyWeb), "utf8");
|
||||
const loadedWeb = loadSettings(paths2);
|
||||
expect(loadedWeb.megaDebridApiEnabled).toBe(false);
|
||||
expect(loadedWeb.megaDebridWebEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("re-aktiviert KEINE bewusst deaktivierten Mega-Flags und migriert nicht ohne Mega-Creds", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const deliberatelyDisabled = {
|
||||
megaLogin: "mega-user",
|
||||
megaPassword: "mega-pass",
|
||||
megaDebridPreferApi: true,
|
||||
megaDebridApiEnabled: false,
|
||||
megaDebridWebEnabled: false
|
||||
};
|
||||
fs.writeFileSync(paths.configFile, JSON.stringify(deliberatelyDisabled), "utf8");
|
||||
const loaded = loadSettings(paths);
|
||||
expect(loaded.megaDebridApiEnabled).toBe(false);
|
||||
expect(loaded.megaDebridWebEnabled).toBe(false);
|
||||
|
||||
const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir2);
|
||||
const paths2 = createStoragePaths(dir2);
|
||||
const noCreds = {
|
||||
megaDebridPreferApi: true,
|
||||
providerPrimary: "realdebrid"
|
||||
};
|
||||
fs.writeFileSync(paths2.configFile, JSON.stringify(noCreds), "utf8");
|
||||
const loadedNoCreds = loadSettings(paths2);
|
||||
expect(loadedNoCreds.megaDebridApiEnabled).toBe(false);
|
||||
expect(loadedNoCreds.megaDebridWebEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes provider daily limits and resets stale daily usage", () => {
|
||||
const [debridLinkKey] = parseDebridLinkApiKeys("dl-key-one");
|
||||
const normalized = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
megaLogin: "mega-user",
|
||||
megaPassword: "mega-pass",
|
||||
megaDebridApiEnabled: true,
|
||||
debridLinkApiKeys: "dl-key-one",
|
||||
providerDailyLimitBytes: {
|
||||
realdebrid: 1024,
|
||||
megadebrid: 2048
|
||||
} as AppSettings["providerDailyLimitBytes"],
|
||||
providerTotalUsageBytes: {
|
||||
realdebrid: 16384,
|
||||
megadebrid: 32768
|
||||
} as AppSettings["providerTotalUsageBytes"],
|
||||
debridLinkApiKeyDailyLimitBytes: {
|
||||
[debridLinkKey.id]: 3072,
|
||||
stale: 1234
|
||||
},
|
||||
providerDailyUsageDay: "2001-01-01",
|
||||
providerDailyUsageBytes: {
|
||||
realdebrid: 4096,
|
||||
megadebrid: 8192
|
||||
} as AppSettings["providerDailyUsageBytes"],
|
||||
debridLinkApiKeyDailyUsageBytes: {
|
||||
[debridLinkKey.id]: 8192,
|
||||
stale: 9999
|
||||
},
|
||||
debridLinkApiKeyTotalUsageBytes: {
|
||||
[debridLinkKey.id]: 12288,
|
||||
stale: 9999
|
||||
}
|
||||
});
|
||||
|
||||
expect(normalized.providerDailyLimitBytes.realdebrid).toBe(1024);
|
||||
expect(normalized.providerDailyLimitBytes["megadebrid-api"]).toBe(2048);
|
||||
expect(normalized.debridLinkApiKeyDailyLimitBytes).toEqual({
|
||||
[debridLinkKey.id]: 3072
|
||||
});
|
||||
expect(normalized.providerTotalUsageBytes).toEqual({
|
||||
realdebrid: 16384,
|
||||
"megadebrid-api": 32768
|
||||
});
|
||||
expect(normalized.providerDailyUsageDay).toBe(getProviderUsageDayKey());
|
||||
expect(normalized.providerDailyUsageBytes).toEqual({});
|
||||
expect(normalized.debridLinkApiKeyDailyUsageBytes).toEqual({});
|
||||
expect(normalized.debridLinkApiKeyTotalUsageBytes).toEqual({
|
||||
[debridLinkKey.id]: 12288
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes archive password list line endings", () => {
|
||||
const normalized = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
archivePasswordList: "one\r\ntwo\r\nthree"
|
||||
});
|
||||
|
||||
expect(normalized.archivePasswordList).toBe("one\ntwo\nthree");
|
||||
});
|
||||
|
||||
it("defaults Real-Debrid web login to disabled and normalizes the flag", () => {
|
||||
expect(defaultSettings().realDebridUseWebLogin).toBe(false);
|
||||
|
||||
const normalizedEnabled = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
realDebridUseWebLogin: 1 as unknown as boolean
|
||||
});
|
||||
expect(normalizedEnabled.realDebridUseWebLogin).toBe(true);
|
||||
|
||||
const normalizedDisabled = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
realDebridUseWebLogin: 0 as unknown as boolean
|
||||
});
|
||||
expect(normalizedDisabled.realDebridUseWebLogin).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults AllDebrid web login to disabled and normalizes the flag", () => {
|
||||
expect(defaultSettings().allDebridUseWebLogin).toBe(false);
|
||||
|
||||
const normalizedEnabled = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
allDebridUseWebLogin: 1 as unknown as boolean
|
||||
});
|
||||
expect(normalizedEnabled.allDebridUseWebLogin).toBe(true);
|
||||
|
||||
const normalizedDisabled = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
allDebridUseWebLogin: 0 as unknown as boolean
|
||||
});
|
||||
expect(normalizedDisabled.allDebridUseWebLogin).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults history retention to permanent and normalizes invalid values", () => {
|
||||
expect(defaultSettings().historyRetentionMode).toBe("permanent");
|
||||
|
||||
const normalized = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
historyRetentionMode: "broken" as unknown as AppSettings["historyRetentionMode"]
|
||||
});
|
||||
|
||||
expect(normalized.historyRetentionMode).toBe("permanent");
|
||||
});
|
||||
|
||||
it("skips adding persisted history entries when history retention is never", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
const result = addHistoryEntryForRetention(paths, "never", {
|
||||
id: "hist-1",
|
||||
name: "ignored",
|
||||
totalBytes: 1024,
|
||||
downloadedBytes: 1024,
|
||||
fileCount: 1,
|
||||
provider: "realdebrid",
|
||||
completedAt: Date.now(),
|
||||
durationSeconds: 12,
|
||||
status: "completed",
|
||||
outputDir: path.join(dir, "out"),
|
||||
urls: ["https://example.com/file.rar"]
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(loadHistory(paths)).toEqual([]);
|
||||
expect(loadHistoryForRetention(paths, "never")).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears persisted history for session retention mode", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
saveHistory(paths, [{
|
||||
id: "hist-2",
|
||||
name: "kept",
|
||||
totalBytes: 2048,
|
||||
downloadedBytes: 2048,
|
||||
fileCount: 1,
|
||||
provider: "realdebrid",
|
||||
completedAt: Date.now(),
|
||||
durationSeconds: 20,
|
||||
status: "completed",
|
||||
outputDir: path.join(dir, "out"),
|
||||
urls: ["https://example.com/file2.rar"]
|
||||
}]);
|
||||
|
||||
resetHistoryForRetention(paths, "session");
|
||||
|
||||
expect(loadHistory(paths)).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps persisted history to the configured maxEntries", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const now = Date.now();
|
||||
const entries = Array.from({ length: 10 }, (_unused, i) => ({
|
||||
id: `h-${i}`,
|
||||
name: `e${i}`,
|
||||
totalBytes: 1,
|
||||
downloadedBytes: 1,
|
||||
fileCount: 1,
|
||||
provider: "realdebrid" as const,
|
||||
completedAt: now - i * 1000,
|
||||
durationSeconds: 1,
|
||||
status: "completed" as const,
|
||||
outputDir: path.join(dir, "out"),
|
||||
urls: []
|
||||
}));
|
||||
|
||||
saveHistory(paths, entries, { maxEntries: 3, maxAgeDays: 0 });
|
||||
|
||||
const loaded = loadHistory(paths, { maxEntries: 3, maxAgeDays: 0 });
|
||||
expect(loaded).toHaveLength(3);
|
||||
expect(loaded.map((e) => e.id)).toEqual(["h-0", "h-1", "h-2"]);
|
||||
});
|
||||
|
||||
it("drops history entries older than maxAgeDays", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const now = Date.now();
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
const fresh = {
|
||||
id: "fresh",
|
||||
name: "fresh",
|
||||
totalBytes: 1,
|
||||
downloadedBytes: 1,
|
||||
fileCount: 1,
|
||||
provider: "realdebrid" as const,
|
||||
completedAt: now - 2 * day,
|
||||
durationSeconds: 1,
|
||||
status: "completed" as const,
|
||||
outputDir: path.join(dir, "out"),
|
||||
urls: []
|
||||
};
|
||||
const old = { ...fresh, id: "old", name: "old", completedAt: now - 40 * day };
|
||||
|
||||
saveHistory(paths, [fresh, old], { maxEntries: 500, maxAgeDays: 30 });
|
||||
|
||||
const loaded = loadHistory(paths, { maxEntries: 500, maxAgeDays: 30 });
|
||||
expect(loaded.map((e) => e.id)).toEqual(["fresh"]);
|
||||
});
|
||||
|
||||
it("assigns and preserves bandwidth schedule ids", () => {
|
||||
const normalized = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
bandwidthSchedules: [{ id: "", startHour: 1, endHour: 6, speedLimitKbps: 1024, enabled: true }]
|
||||
});
|
||||
|
||||
const generatedId = normalized.bandwidthSchedules[0]?.id;
|
||||
expect(typeof generatedId).toBe("string");
|
||||
expect(generatedId?.length).toBeGreaterThan(0);
|
||||
|
||||
const normalizedAgain = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
bandwidthSchedules: normalized.bandwidthSchedules
|
||||
});
|
||||
expect(normalizedAgain.bandwidthSchedules[0]?.id).toBe(generatedId);
|
||||
});
|
||||
|
||||
it("resets stale active statuses to queued on session load", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
const session = emptySession();
|
||||
session.packages["pkg1"] = {
|
||||
id: "pkg1",
|
||||
name: "Test Package",
|
||||
outputDir: "/tmp/out",
|
||||
extractDir: "/tmp/extract",
|
||||
status: "downloading",
|
||||
itemIds: ["item1", "item2", "item3", "item4"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
downloadStartedAt: 0,
|
||||
downloadCompletedAt: 0,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
session.items["item1"] = {
|
||||
id: "item1",
|
||||
packageId: "pkg1",
|
||||
url: "https://example.com/file1.rar",
|
||||
provider: null,
|
||||
status: "downloading",
|
||||
retries: 0,
|
||||
speedBps: 1024,
|
||||
downloadedBytes: 5000,
|
||||
totalBytes: 10000,
|
||||
progressPercent: 50,
|
||||
fileName: "file1.rar",
|
||||
targetPath: "/tmp/out/file1.rar",
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "some error",
|
||||
fullStatus: "",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
session.items["item2"] = {
|
||||
id: "item2",
|
||||
packageId: "pkg1",
|
||||
url: "https://example.com/file2.rar",
|
||||
provider: null,
|
||||
status: "paused",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fileName: "file2.rar",
|
||||
targetPath: "/tmp/out/file2.rar",
|
||||
resumable: false,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
session.items["item3"] = {
|
||||
id: "item3",
|
||||
packageId: "pkg1",
|
||||
url: "https://example.com/file3.rar",
|
||||
provider: null,
|
||||
status: "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 10000,
|
||||
totalBytes: 10000,
|
||||
progressPercent: 100,
|
||||
fileName: "file3.rar",
|
||||
targetPath: "/tmp/out/file3.rar",
|
||||
resumable: false,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
session.items["item4"] = {
|
||||
id: "item4",
|
||||
packageId: "pkg1",
|
||||
url: "https://example.com/file4.rar",
|
||||
provider: null,
|
||||
status: "queued",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fileName: "file4.rar",
|
||||
targetPath: "/tmp/out/file4.rar",
|
||||
resumable: false,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
saveSession(paths, session);
|
||||
const loaded = loadSession(paths);
|
||||
|
||||
expect(loaded.items["item1"].status).toBe("queued");
|
||||
expect(loaded.items["item2"].status).toBe("queued");
|
||||
expect(loaded.items["item1"].speedBps).toBe(0);
|
||||
expect(loaded.items["item1"].lastError).toBe("");
|
||||
expect(loaded.items["item3"].status).toBe("completed");
|
||||
expect(loaded.items["item4"].status).toBe("queued");
|
||||
expect(loaded.items["item1"].downloadedBytes).toBe(5000);
|
||||
expect(loaded.packages["pkg1"].name).toBe("Test Package");
|
||||
});
|
||||
|
||||
it("returns empty session when session file contains invalid JSON", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
fs.writeFileSync(paths.sessionFile, "{{{corrupted json!!!", "utf8");
|
||||
|
||||
const loaded = loadSession(paths);
|
||||
const empty = emptySession();
|
||||
expect(loaded.packages).toEqual(empty.packages);
|
||||
expect(loaded.items).toEqual(empty.items);
|
||||
expect(loaded.packageOrder).toEqual(empty.packageOrder);
|
||||
});
|
||||
|
||||
it("loads backup session when primary session is corrupted", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
const backupSession = emptySession();
|
||||
backupSession.packageOrder = ["pkg-backup"];
|
||||
backupSession.packages["pkg-backup"] = {
|
||||
id: "pkg-backup",
|
||||
name: "Backup Package",
|
||||
outputDir: path.join(dir, "out"),
|
||||
extractDir: path.join(dir, "extract"),
|
||||
status: "queued",
|
||||
itemIds: ["item-backup"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
downloadStartedAt: 0,
|
||||
downloadCompletedAt: 0,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
backupSession.items["item-backup"] = {
|
||||
id: "item-backup",
|
||||
packageId: "pkg-backup",
|
||||
url: "https://example.com/backup-file",
|
||||
provider: null,
|
||||
status: "queued",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fileName: "backup-file.rar",
|
||||
targetPath: path.join(dir, "out", "backup-file.rar"),
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "Wartet",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(backupSession), "utf8");
|
||||
fs.writeFileSync(paths.sessionFile, "{broken-session-json", "utf8");
|
||||
|
||||
const loaded = loadSession(paths);
|
||||
expect(loaded.packageOrder).toEqual(["pkg-backup"]);
|
||||
expect(loaded.packages["pkg-backup"]?.name).toBe("Backup Package");
|
||||
expect(loaded.items["item-backup"]?.fileName).toBe("backup-file.rar");
|
||||
|
||||
const restoredPrimary = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { packages?: Record<string, unknown> };
|
||||
expect(restoredPrimary.packages && "pkg-backup" in restoredPrimary.packages).toBe(true);
|
||||
});
|
||||
|
||||
it("returns defaults when config file contains invalid JSON", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
fs.writeFileSync(paths.configFile, "{{{{not valid json!!!}", "utf8");
|
||||
|
||||
const loaded = loadSettings(paths);
|
||||
const defaults = defaultSettings();
|
||||
expect(loaded.providerPrimary).toBe(defaults.providerPrimary);
|
||||
expect(loaded.maxParallel).toBe(defaults.maxParallel);
|
||||
expect(loaded.retryLimit).toBe(defaults.retryLimit);
|
||||
expect(loaded.outputDir).toBe(defaults.outputDir);
|
||||
expect(loaded.cleanupMode).toBe(defaults.cleanupMode);
|
||||
});
|
||||
|
||||
it("loads backup config when primary config is corrupted", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
const backupSettings = {
|
||||
...defaultSettings(),
|
||||
outputDir: path.join(dir, "backup-output"),
|
||||
packageName: "from-backup"
|
||||
};
|
||||
fs.writeFileSync(`${paths.configFile}.bak`, JSON.stringify(backupSettings, null, 2), "utf8");
|
||||
fs.writeFileSync(paths.configFile, "{broken-json", "utf8");
|
||||
|
||||
const loaded = loadSettings(paths);
|
||||
expect(loaded.outputDir).toBe(backupSettings.outputDir);
|
||||
expect(loaded.packageName).toBe("from-backup");
|
||||
});
|
||||
|
||||
it("sanitizes malformed persisted session structures", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
fs.writeFileSync(paths.sessionFile, JSON.stringify({
|
||||
version: "invalid",
|
||||
packageOrder: [123, "pkg-valid"],
|
||||
packages: {
|
||||
"1": "bad-entry",
|
||||
"pkg-valid": {
|
||||
id: "pkg-valid",
|
||||
name: "Valid Package",
|
||||
outputDir: "C:/tmp/out",
|
||||
extractDir: "C:/tmp/extract",
|
||||
status: "downloading",
|
||||
itemIds: ["item-valid", 123],
|
||||
cancelled: false,
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
items: {
|
||||
"item-valid": {
|
||||
id: "item-valid",
|
||||
packageId: "pkg-valid",
|
||||
url: "https://example.com/file",
|
||||
status: "queued",
|
||||
fileName: "file.bin",
|
||||
targetPath: "C:/tmp/out/file.bin"
|
||||
},
|
||||
"item-bad": "broken"
|
||||
}
|
||||
}), "utf8");
|
||||
|
||||
const loaded = loadSession(paths);
|
||||
expect(Object.keys(loaded.packages)).toEqual(["pkg-valid"]);
|
||||
expect(Object.keys(loaded.items)).toEqual(["item-valid"]);
|
||||
expect(loaded.packageOrder).toEqual(["pkg-valid"]);
|
||||
});
|
||||
|
||||
it("drops unsafe session ids and target paths outside the package output directory", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
const outputDir = path.join(dir, "downloads", "safe");
|
||||
const safeTargetPath = path.join(outputDir, "safe.bin");
|
||||
const outsideTargetPath = path.join(dir, "outside.bin");
|
||||
|
||||
fs.writeFileSync(paths.sessionFile, JSON.stringify({
|
||||
version: 2,
|
||||
packageOrder: ["pkg-safe", "../pkg-evil"],
|
||||
packages: {
|
||||
"pkg-safe": {
|
||||
id: "pkg-safe",
|
||||
name: "Safe Package",
|
||||
outputDir,
|
||||
extractDir: path.join(dir, "extract", "safe"),
|
||||
status: "queued",
|
||||
itemIds: ["item-safe", "item-outside", "../item-evil"],
|
||||
cancelled: false,
|
||||
enabled: true
|
||||
},
|
||||
"../pkg-evil": {
|
||||
id: "../pkg-evil",
|
||||
name: "Unsafe Package",
|
||||
outputDir,
|
||||
extractDir: path.join(dir, "extract", "unsafe"),
|
||||
status: "queued",
|
||||
itemIds: ["item-evil"],
|
||||
cancelled: false,
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
items: {
|
||||
"item-safe": {
|
||||
id: "item-safe",
|
||||
packageId: "pkg-safe",
|
||||
url: "https://example.com/safe",
|
||||
status: "queued",
|
||||
fileName: "safe.bin",
|
||||
targetPath: safeTargetPath
|
||||
},
|
||||
"item-outside": {
|
||||
id: "item-outside",
|
||||
packageId: "pkg-safe",
|
||||
url: "https://example.com/outside",
|
||||
status: "queued",
|
||||
fileName: "outside.bin",
|
||||
targetPath: outsideTargetPath
|
||||
},
|
||||
"../item-evil": {
|
||||
id: "../item-evil",
|
||||
packageId: "pkg-safe",
|
||||
url: "https://example.com/evil",
|
||||
status: "queued",
|
||||
fileName: "evil.bin",
|
||||
targetPath: safeTargetPath
|
||||
}
|
||||
}
|
||||
}), "utf8");
|
||||
|
||||
const loaded = loadSession(paths);
|
||||
expect(Object.keys(loaded.packages)).toEqual(["pkg-safe"]);
|
||||
expect(Object.keys(loaded.items).sort()).toEqual(["item-outside", "item-safe"]);
|
||||
expect(loaded.packageOrder).toEqual(["pkg-safe"]);
|
||||
expect(path.resolve(loaded.items["item-safe"]?.targetPath || "")).toBe(path.resolve(safeTargetPath));
|
||||
expect(loaded.items["item-outside"]?.targetPath).toBe("");
|
||||
});
|
||||
|
||||
it("captures async session save payload before later mutations", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
const session = emptySession();
|
||||
session.summaryText = "before-mutation";
|
||||
|
||||
const pending = saveSessionAsync(paths, session);
|
||||
session.summaryText = "after-mutation";
|
||||
await pending;
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { summaryText: string };
|
||||
expect(persisted.summaryText).toBe("before-mutation");
|
||||
});
|
||||
|
||||
it("creates session backup before sync and async session overwrites", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
const first = emptySession();
|
||||
first.summaryText = "first";
|
||||
saveSession(paths, first);
|
||||
|
||||
const second = emptySession();
|
||||
second.summaryText = "second";
|
||||
saveSession(paths, second);
|
||||
|
||||
const backupAfterSync = JSON.parse(fs.readFileSync(`${paths.sessionFile}.bak`, "utf8")) as { summaryText?: string };
|
||||
expect(backupAfterSync.summaryText).toBe("first");
|
||||
|
||||
const third = emptySession();
|
||||
third.summaryText = "third";
|
||||
await saveSessionAsync(paths, third);
|
||||
|
||||
const backupAfterAsync = JSON.parse(fs.readFileSync(`${paths.sessionFile}.bak`, "utf8")) as { summaryText?: string };
|
||||
const primaryAfterAsync = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as { summaryText?: string };
|
||||
expect(backupAfterAsync.summaryText).toBe("second");
|
||||
expect(primaryAfterAsync.summaryText).toBe("third");
|
||||
});
|
||||
|
||||
it("applies defaults for missing fields when loading old config", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
|
||||
tempDirs.push(dir);
|
||||
const paths = createStoragePaths(dir);
|
||||
|
||||
fs.writeFileSync(
|
||||
paths.configFile,
|
||||
JSON.stringify({
|
||||
token: "my-token",
|
||||
rememberToken: true,
|
||||
outputDir: "/custom/output"
|
||||
}),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const loaded = loadSettings(paths);
|
||||
const defaults = defaultSettings();
|
||||
|
||||
expect(loaded.token).toBe("my-token");
|
||||
expect(loaded.outputDir).toBe(path.resolve("/custom/output"));
|
||||
|
||||
expect(loaded.autoProviderFallback).toBe(defaults.autoProviderFallback);
|
||||
expect(loaded.hybridExtract).toBe(defaults.hybridExtract);
|
||||
expect(loaded.completedCleanupPolicy).toBe(defaults.completedCleanupPolicy);
|
||||
expect(loaded.speedLimitMode).toBe(defaults.speedLimitMode);
|
||||
expect(loaded.clipboardWatch).toBe(defaults.clipboardWatch);
|
||||
expect(loaded.minimizeToTray).toBe(defaults.minimizeToTray);
|
||||
expect(loaded.retryLimit).toBe(defaults.retryLimit);
|
||||
expect(loaded.collectMkvToLibrary).toBe(defaults.collectMkvToLibrary);
|
||||
expect(loaded.mkvLibraryDir).toBe(defaults.mkvLibraryDir);
|
||||
expect(loaded.theme).toBe(defaults.theme);
|
||||
expect(loaded.bandwidthSchedules).toEqual(defaults.bandwidthSchedules);
|
||||
expect(loaded.updateRepo).toBe(defaults.updateRepo);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildSupportBundle } from "../src/main/support-bundle";
|
||||
import type { DownloadManager } from "../src/main/download-manager";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
|
||||
}
|
||||
});
|
||||
|
||||
function fakeManager(): DownloadManager {
|
||||
const snapshot = {
|
||||
stats: {},
|
||||
session: { packages: {}, items: {}, packageOrder: [] },
|
||||
speedText: "",
|
||||
etaText: "",
|
||||
canStart: false,
|
||||
canStop: false,
|
||||
canPause: false
|
||||
};
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
getPackageLogPath: () => null,
|
||||
getItemLogPath: () => null
|
||||
} as unknown as DownloadManager;
|
||||
}
|
||||
|
||||
describe("buildSupportBundle (async, non-blocking)", () => {
|
||||
it("returns a Promise and produces a valid zip with overview + a real on-disk file", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
||||
tempDirs.push(root);
|
||||
fs.writeFileSync(path.join(root, "debug_host.txt"), "host-info-test", "utf8");
|
||||
fs.writeFileSync(path.join(root, "debug_support_manifest.json"), JSON.stringify({ purpose: "support" }), "utf8");
|
||||
fs.writeFileSync(path.join(root, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8");
|
||||
|
||||
const promise = buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
|
||||
const buffer = await promise;
|
||||
expect(Buffer.isBuffer(buffer)).toBe(true);
|
||||
expect(buffer.length).toBeGreaterThan(0);
|
||||
|
||||
const entries = new AdmZip(buffer).getEntries().map((e) => e.entryName);
|
||||
expect(entries).toContain("overview/meta.json");
|
||||
expect(entries).toContain("overview/settings.json");
|
||||
expect(entries).toContain("runtime/debug_host.txt");
|
||||
expect(entries).toContain("runtime/debug_support_manifest.json");
|
||||
expect(entries).toContain("overview/support-manifest.json");
|
||||
expect(entries).not.toContain(`runtime/${legacyManifestFile}`);
|
||||
expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join(""));
|
||||
|
||||
const hostEntry = new AdmZip(buffer).getEntry("runtime/debug_host.txt");
|
||||
expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test");
|
||||
});
|
||||
|
||||
it("does not block the event loop while building (a concurrent timer still fires)", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
let timerFired = false;
|
||||
const timer = setTimeout(() => { timerFired = true; }, 0);
|
||||
await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
|
||||
clearTimeout(timer);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(timerFired).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { configureLogger, logger } from "../src/main/logger";
|
||||
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
|
||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
shutdownSessionLog();
|
||||
shutdownTraceLog();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("trace-log", () => {
|
||||
it("captures main log lines and explicit trace events when enabled", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
configureLogger(baseDir);
|
||||
initTraceLog(baseDir);
|
||||
initSessionLog(baseDir);
|
||||
setTraceEnabled(true, "test");
|
||||
|
||||
logger.info("TRACE-MAIN-CAPTURE");
|
||||
logTraceEvent("INFO", "audit", "TRACE-AUDIT-CAPTURE", { source: "test" });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
|
||||
const traceLogPath = getTraceLogPath();
|
||||
const sessionLogPath = getSessionLogPath();
|
||||
const traceConfigPath = getTraceConfigPath();
|
||||
expect(traceLogPath).not.toBeNull();
|
||||
expect(sessionLogPath).not.toBeNull();
|
||||
expect(traceConfigPath).not.toBeNull();
|
||||
|
||||
const traceContent = fs.readFileSync(traceLogPath!, "utf8");
|
||||
expect(traceContent).toContain("Trace-Log Start");
|
||||
expect(traceContent).toContain("TRACE-MAIN-CAPTURE");
|
||||
expect(traceContent).toContain("TRACE-AUDIT-CAPTURE");
|
||||
|
||||
const sessionContent = fs.readFileSync(sessionLogPath!, "utf8");
|
||||
expect(sessionContent).toContain("TRACE-MAIN-CAPTURE");
|
||||
|
||||
const traceConfig = getTraceConfig();
|
||||
expect(traceConfig.enabled).toBe(true);
|
||||
expect(traceConfig.autoDisableAt).toBeTruthy();
|
||||
expect(JSON.parse(fs.readFileSync(traceConfigPath!, "utf8")).enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-disables support trace after the requested duration", async () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-expire-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
configureLogger(baseDir);
|
||||
initTraceLog(baseDir);
|
||||
setTraceEnabled(true, "expire-test", 50);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
|
||||
const traceConfig = getTraceConfig();
|
||||
expect(traceConfig.enabled).toBe(false);
|
||||
expect(traceConfig.autoDisableAt).toBeNull();
|
||||
|
||||
const traceLogPath = getTraceLogPath();
|
||||
expect(traceLogPath).not.toBeNull();
|
||||
const traceContent = fs.readFileSync(traceLogPath!, "utf8");
|
||||
expect(traceContent).toContain("Support-Trace automatisch deaktiviert");
|
||||
});
|
||||
|
||||
it("rotates oversized trace logs on startup", () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-rotate-"));
|
||||
tempDirs.push(baseDir);
|
||||
|
||||
const oversizedPath = path.join(baseDir, "trace.log");
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8");
|
||||
|
||||
initTraceLog(baseDir);
|
||||
|
||||
expect(fs.existsSync(oversizedPath)).toBe(true);
|
||||
expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true);
|
||||
const currentContent = fs.readFileSync(oversizedPath, "utf8");
|
||||
expect(currentContent).toContain("Trace-Log Start");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { transientResolveRetryDelayMs, parseMegaDebridCooldownRetry, parseMegaDebridResetPark, parseMegaDebridSlowLinkRetry } from "../src/main/download-manager";
|
||||
|
||||
describe("transientResolveRetryDelayMs (fast, bounded retry for transient resolve failures)", () => {
|
||||
it("starts fast (<= 3s) instead of the 5s..120s exponential", () => {
|
||||
expect(transientResolveRetryDelayMs(1)).toBeLessThanOrEqual(3000);
|
||||
});
|
||||
|
||||
it("ramps gently and caps at 10s", () => {
|
||||
expect(transientResolveRetryDelayMs(2)).toBeLessThanOrEqual(7000);
|
||||
expect(transientResolveRetryDelayMs(3)).toBeLessThanOrEqual(10000);
|
||||
expect(transientResolveRetryDelayMs(10)).toBe(10000);
|
||||
expect(transientResolveRetryDelayMs(100)).toBe(10000);
|
||||
});
|
||||
|
||||
it("never schedules anywhere near the 5s..120s exponential cap", () => {
|
||||
for (let n = 1; n <= 50; n += 1) {
|
||||
expect(transientResolveRetryDelayMs(n)).toBeLessThanOrEqual(10000);
|
||||
expect(transientResolveRetryDelayMs(n)).toBeGreaterThanOrEqual(1000);
|
||||
}
|
||||
});
|
||||
|
||||
it("is monotonic non-decreasing", () => {
|
||||
let prev = 0;
|
||||
for (let n = 1; n <= 12; n += 1) {
|
||||
const d = transientResolveRetryDelayMs(n);
|
||||
expect(d).toBeGreaterThanOrEqual(prev);
|
||||
prev = d;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMegaDebridCooldownRetry (honor the encoded account-cooldown delay)", () => {
|
||||
it("parses the encoded delay from a bare mega_debrid_cooldown error", () => {
|
||||
const r = parseMegaDebridCooldownRetry("mega_debrid_cooldown:20330:Mega-Debrid (Account 2/2, Da******el): Token error");
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.delayMs).toBe(20330);
|
||||
expect(r!.detail).toContain("Mega-Debrid");
|
||||
});
|
||||
|
||||
it("parses it when embedded in the aggregated provider-chain error", () => {
|
||||
const aggregated = "Unrestrict fehlgeschlagen: Mega-Debrid API: mega_debrid_cooldown:20330:Mega-Debrid (Account 2/2): Token error";
|
||||
expect(parseMegaDebridCooldownRetry(aggregated)!.delayMs).toBe(20330);
|
||||
});
|
||||
|
||||
it("takes the SOONEST (min) cooldown when several accounts are cooled", () => {
|
||||
const both = "Mega-Debrid API: mega_debrid_cooldown:116285:web | Mega-Debrid API: mega_debrid_cooldown:20330:api";
|
||||
expect(parseMegaDebridCooldownRetry(both)!.delayMs).toBe(20330);
|
||||
});
|
||||
|
||||
it("clamps to [1s, 15min]", () => {
|
||||
expect(parseMegaDebridCooldownRetry("mega_debrid_cooldown:1:x")!.delayMs).toBe(1000);
|
||||
expect(parseMegaDebridCooldownRetry("mega_debrid_cooldown:99999999:x")!.delayMs).toBe(15 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("returns null when there is no mega cooldown marker", () => {
|
||||
expect(parseMegaDebridCooldownRetry("Datei beim Hoster gerade nicht abrufbar")).toBeNull();
|
||||
expect(parseMegaDebridCooldownRetry("debrid_link_cooldown:5000:x")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT swallow the until-Tagesreset park token", () => {
|
||||
expect(parseMegaDebridCooldownRetry("mega_debrid_reset_park:43200000:Alle Accounts bis zum Tagesreset gesperrt")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMegaDebridSlowLinkRetry (park only the slow link, never the account)", () => {
|
||||
it("parses the encoded delay from a slow-link error", () => {
|
||||
const r = parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:120000:Mega-Debrid (Account 1/1, Su******e3): aborted");
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.delayMs).toBe(120000);
|
||||
expect(r!.detail).toContain("Mega-Debrid");
|
||||
});
|
||||
|
||||
it("parses it when embedded in the aggregated provider-chain error", () => {
|
||||
const aggregated = "Provider-Kette: Mega-Debrid Web fehlgeschlagen (Error: mega_debrid_slow_link:90000:Mega-Debrid (Account 1/1): aborted)";
|
||||
expect(parseMegaDebridSlowLinkRetry(aggregated)!.delayMs).toBe(90000);
|
||||
});
|
||||
|
||||
it("clamps to [1s, 15min]", () => {
|
||||
expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:1:x")!.delayMs).toBe(1000);
|
||||
expect(parseMegaDebridSlowLinkRetry("mega_debrid_slow_link:99999999:x")!.delayMs).toBe(15 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("does not collide with the account-cooldown or reset-park tokens", () => {
|
||||
expect(parseMegaDebridSlowLinkRetry("mega_debrid_cooldown:20330:x")).toBeNull();
|
||||
expect(parseMegaDebridSlowLinkRetry("mega_debrid_reset_park:43200000:x")).toBeNull();
|
||||
expect(parseMegaDebridCooldownRetry("mega_debrid_slow_link:120000:x")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMegaDebridResetPark (park the item until the Tagesreset, not a ~2min generic retry)", () => {
|
||||
it("parses the encoded until-reset delay from the park token", () => {
|
||||
const r = parseMegaDebridResetPark("mega_debrid_reset_park:43200000:Mega-Debrid: Alle Accounts am Tageslimit (bis zum Tagesreset gesperrt)");
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.delayMs).toBe(43200000);
|
||||
expect(r!.detail).toContain("bis zum Tagesreset gesperrt");
|
||||
});
|
||||
|
||||
it("parses it when embedded in the aggregated provider-chain error", () => {
|
||||
const aggregated = "Unrestrict fehlgeschlagen: Mega-Debrid API: mega_debrid_reset_park:7200000:Alle Accounts bis zum Tagesreset gesperrt";
|
||||
expect(parseMegaDebridResetPark(aggregated)!.delayMs).toBe(7200000);
|
||||
});
|
||||
|
||||
it("is NOT clamped to the 15min cooldown ceiling (can park multiple hours)", () => {
|
||||
expect(parseMegaDebridResetPark("mega_debrid_reset_park:21600000:x")!.delayMs).toBe(21600000);
|
||||
});
|
||||
|
||||
it("clamps to a sane [1s, 26h] window and rejects junk", () => {
|
||||
expect(parseMegaDebridResetPark("mega_debrid_reset_park:1:x")!.delayMs).toBe(1000);
|
||||
expect(parseMegaDebridResetPark("mega_debrid_reset_park:999999999999:x")!.delayMs).toBe(26 * 60 * 60 * 1000);
|
||||
expect(parseMegaDebridResetPark("mega_debrid_cooldown:20330:x")).toBeNull();
|
||||
expect(parseMegaDebridResetPark("kein Token hier")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runInstallWithResume, InstallResumeManager } from "../src/main/update-install-flow";
|
||||
|
||||
function makeManager(running: boolean): InstallResumeManager & { startCalls: number; stopCalls: number; persistCalls: number; sessionRunning: boolean } {
|
||||
return {
|
||||
sessionRunning: running,
|
||||
startCalls: 0,
|
||||
stopCalls: 0,
|
||||
persistCalls: 0,
|
||||
isSessionRunning() {
|
||||
return this.sessionRunning;
|
||||
},
|
||||
stop() {
|
||||
this.stopCalls += 1;
|
||||
this.sessionRunning = false;
|
||||
},
|
||||
persistNowSync() {
|
||||
this.persistCalls += 1;
|
||||
},
|
||||
async start() {
|
||||
this.startCalls += 1;
|
||||
this.sessionRunning = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("runInstallWithResume", () => {
|
||||
it("resumes a running session when the install returns started:false", async () => {
|
||||
const m = makeManager(true);
|
||||
const result = await runInstallWithResume(m, async () => ({ started: false }));
|
||||
expect(result.started).toBe(false);
|
||||
expect(m.stopCalls).toBe(1);
|
||||
expect(m.startCalls).toBe(1);
|
||||
expect(m.isSessionRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it("resumes a running session when the install THROWS, then rethrows", async () => {
|
||||
const m = makeManager(true);
|
||||
await expect(
|
||||
runInstallWithResume(m, async () => {
|
||||
throw new Error("network down");
|
||||
})
|
||||
).rejects.toThrow("network down");
|
||||
expect(m.stopCalls).toBe(1);
|
||||
expect(m.startCalls).toBe(1);
|
||||
expect(m.isSessionRunning()).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT resume when the install succeeds (started:true) — the app is about to quit", async () => {
|
||||
const m = makeManager(true);
|
||||
const result = await runInstallWithResume(m, async () => ({ started: true }));
|
||||
expect(result.started).toBe(true);
|
||||
expect(m.startCalls).toBe(0);
|
||||
expect(m.isSessionRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT resume when no session was running before the install", async () => {
|
||||
const m = makeManager(false);
|
||||
await runInstallWithResume(m, async () => ({ started: false }));
|
||||
expect(m.stopCalls).toBe(0);
|
||||
expect(m.startCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import { once } from "node:events";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { DownloadManager } from "../src/main/download-manager";
|
||||
import { defaultSettings } from "../src/main/constants";
|
||||
import { createStoragePaths, emptySession, loadSession } from "../src/main/storage";
|
||||
import { shutdownItemLogs } from "../src/main/item-log";
|
||||
import { shutdownPackageLogs } from "../src/main/package-log";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
shutdownItemLogs();
|
||||
shutdownPackageLogs();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
break;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 20000): Promise<void> {
|
||||
const started = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - started > timeoutMs) {
|
||||
throw new Error("waitFor timeout");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
async function startTricklingServer(): Promise<{ directUrl: string; stop: () => Promise<void> }> {
|
||||
const openTimers = new Set<NodeJS.Timeout>();
|
||||
const openResponses = new Set<http.ServerResponse>();
|
||||
const server = http.createServer((req, res) => {
|
||||
if ((req.url || "") !== "/direct") {
|
||||
res.statusCode = 404;
|
||||
res.end("not-found");
|
||||
return;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Accept-Ranges", "bytes");
|
||||
res.setHeader("Content-Length", String(64 * 1024 * 1024));
|
||||
openResponses.add(res);
|
||||
res.write(Buffer.alloc(64 * 1024, 7));
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
res.write(Buffer.alloc(16 * 1024, 9));
|
||||
} catch {
|
||||
}
|
||||
}, 100);
|
||||
openTimers.add(timer);
|
||||
res.on("close", () => {
|
||||
clearInterval(timer);
|
||||
openTimers.delete(timer);
|
||||
openResponses.delete(res);
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("server address unavailable");
|
||||
}
|
||||
const directUrl = `http://127.0.0.1:${address.port}/direct`;
|
||||
const stop = async (): Promise<void> => {
|
||||
for (const timer of openTimers) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
openTimers.clear();
|
||||
for (const res of openResponses) {
|
||||
try {
|
||||
res.destroy();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
openResponses.clear();
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
};
|
||||
return { directUrl, stop };
|
||||
}
|
||||
|
||||
function mockUnrestrict(directUrl: string): void {
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("/unrestrict/link")) {
|
||||
return new Response(
|
||||
JSON.stringify({ download: directUrl, filename: "episode.mkv", filesize: 64 * 1024 * 1024 }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
}
|
||||
|
||||
async function driveActiveDownload(root: string): Promise<{ manager: DownloadManager; paths: ReturnType<typeof createStoragePaths>; serverStop: () => Promise<void> }> {
|
||||
const { directUrl, stop: serverStop } = await startTricklingServer();
|
||||
mockUnrestrict(directUrl);
|
||||
const paths = createStoragePaths(path.join(root, "state"));
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false,
|
||||
autoReconnect: false,
|
||||
retryLimit: 0
|
||||
},
|
||||
emptySession(),
|
||||
paths
|
||||
);
|
||||
manager.addPackages([{ name: "park", links: ["https://dummy/park"] }]);
|
||||
await manager.start();
|
||||
await waitFor(() => {
|
||||
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||
return item?.status === "downloading" && (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size > 0;
|
||||
});
|
||||
return { manager, paths, serverStop };
|
||||
}
|
||||
|
||||
describe("update restart resume", () => {
|
||||
it("characterization: a plain stop() leaves an in-flight item cancelled across a restart", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-update-resume-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, paths, serverStop } = await driveActiveDownload(root);
|
||||
try {
|
||||
manager.stop();
|
||||
manager.persistNowSync();
|
||||
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
|
||||
manager.prepareForShutdown();
|
||||
|
||||
const reloaded = loadSession(paths);
|
||||
const item = Object.values(reloaded.items)[0];
|
||||
expect(item).toBeTruthy();
|
||||
expect(item.status).toBe("cancelled");
|
||||
} finally {
|
||||
await serverStop();
|
||||
}
|
||||
});
|
||||
|
||||
it("parks an in-flight item as queued for an update restart so it auto-resumes", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-update-resume-"));
|
||||
tempDirs.push(root);
|
||||
const { manager, paths, serverStop } = await driveActiveDownload(root);
|
||||
try {
|
||||
manager.stop({ parkForRestart: true });
|
||||
manager.persistNowSync();
|
||||
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
|
||||
manager.prepareForShutdown();
|
||||
|
||||
const reloaded = loadSession(paths);
|
||||
const item = Object.values(reloaded.items)[0];
|
||||
expect(item).toBeTruthy();
|
||||
expect(Object.keys(reloaded.packages).length).toBe(1);
|
||||
expect(item.status).toBe("queued");
|
||||
} finally {
|
||||
await serverStop();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,629 @@
|
||||
import fs from "node:fs";
|
||||
import crypto from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { spawnMock, unrefMock, onceMock } = vi.hoisted(() => {
|
||||
const unref = vi.fn();
|
||||
const once = vi.fn((_event: string, _handler: (...args: unknown[]) => void) => ({
|
||||
unref
|
||||
}));
|
||||
const spawn = vi.fn(() => ({
|
||||
once,
|
||||
unref
|
||||
}));
|
||||
return {
|
||||
spawnMock: spawn,
|
||||
unrefMock: unref,
|
||||
onceMock: once
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: spawnMock
|
||||
}));
|
||||
|
||||
import { buildInstallerLaunchArgs, checkGitHubUpdate, installLatestUpdate, isRemoteNewer, normalizeUpdateRepo, parseVersionParts } from "../src/main/update";
|
||||
import { APP_VERSION } from "../src/main/constants";
|
||||
import { UpdateCheckResult, UpdateInstallProgress } from "../src/shared/types";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function sha256Hex(buffer: Buffer): string {
|
||||
return crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
function sha512Hex(buffer: Buffer): string {
|
||||
return crypto.createHash("sha512").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
spawnMock.mockClear();
|
||||
unrefMock.mockClear();
|
||||
onceMock.mockClear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("normalizes update repo input", () => {
|
||||
expect(normalizeUpdateRepo("")).toBe("Sucukdeluxe/multi-debrid-downloader");
|
||||
expect(normalizeUpdateRepo("owner/repo")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("https://github.com/owner/repo")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("https://github.com/owner/repo/releases/tag/v1.2.3")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("github.com/owner/repo.git")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("git@github.com:owner/repo.git")).toBe("owner/repo");
|
||||
});
|
||||
|
||||
it("uses normalized repo slug for API requests", async () => {
|
||||
let requestedUrl = "";
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
requestedUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
tag_name: `v${APP_VERSION}`,
|
||||
html_url: "https://github.com/owner/repo/releases/tag/v1.0.0",
|
||||
assets: []
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await checkGitHubUpdate("https://github.com/owner/repo/releases");
|
||||
expect(requestedUrl).toBe("https://api.github.com/repos/owner/repo/releases/latest");
|
||||
expect(result.currentVersion).toBe(APP_VERSION);
|
||||
expect(result.latestVersion).toBe(APP_VERSION);
|
||||
expect(result.updateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it("picks setup executable asset from release list", async () => {
|
||||
globalThis.fetch = (async (): Promise<Response> => new Response(
|
||||
JSON.stringify({
|
||||
tag_name: "v9.9.9",
|
||||
html_url: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
assets: [
|
||||
{
|
||||
name: "Real-Debrid-Downloader-9.9.9-portable.exe",
|
||||
browser_download_url: "https://example.invalid/portable.exe"
|
||||
},
|
||||
{
|
||||
name: "Real-Debrid-Downloader-Setup-9.9.9.exe",
|
||||
browser_download_url: "https://example.invalid/setup.exe",
|
||||
digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
]
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
)) as typeof fetch;
|
||||
|
||||
const result = await checkGitHubUpdate("owner/repo");
|
||||
expect(result.updateAvailable).toBe(true);
|
||||
expect(result.setupAssetUrl).toBe("https://example.invalid/setup.exe");
|
||||
expect(result.setupAssetName).toBe("Real-Debrid-Downloader-Setup-9.9.9.exe");
|
||||
});
|
||||
|
||||
it("uses silent NSIS install flags with auto-run after update", () => {
|
||||
expect(buildInstallerLaunchArgs()).toEqual(["/S", "--updated", "--force-run"]);
|
||||
});
|
||||
|
||||
it("falls back to alternate download URL when setup asset URL returns 404", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
const executableDigest = sha256Hex(executablePayload);
|
||||
const requestedUrls: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
requestedUrls.push(url);
|
||||
|
||||
if (url.includes("stale-setup.exe")) {
|
||||
return new Response("missing", { status: 404 });
|
||||
}
|
||||
if (url.includes("/releases/download/v9.9.9/")) {
|
||||
return new Response(executablePayload, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/octet-stream" }
|
||||
});
|
||||
}
|
||||
return new Response("missing", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const prechecked: UpdateCheckResult = {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/stale-setup.exe",
|
||||
setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe",
|
||||
setupAssetDigest: `sha256:${executableDigest}`
|
||||
};
|
||||
|
||||
const result = await installLatestUpdate("owner/repo", prechecked);
|
||||
expect(result.started).toBe(true);
|
||||
expect(requestedUrls.some((url) => url.includes("/releases/download/v9.9.9/"))).toBe(true);
|
||||
expect(requestedUrls.filter((url) => url.includes("stale-setup.exe"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips draft tag payload and resolves setup asset from stable latest release", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
const requestedUrls: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
requestedUrls.push(url);
|
||||
|
||||
if (url.endsWith("/releases/tags/v9.9.9")) {
|
||||
return new Response(JSON.stringify({
|
||||
tag_name: "v9.9.9",
|
||||
draft: true,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: "Draft Setup 9.9.9.exe",
|
||||
browser_download_url: "https://example.invalid/draft-setup.exe"
|
||||
}
|
||||
]
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
if (url.endsWith("/releases/latest")) {
|
||||
const stableDigest = sha256Hex(executablePayload);
|
||||
return new Response(JSON.stringify({
|
||||
tag_name: "v9.9.9",
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: "Stable Setup 9.9.9.exe",
|
||||
browser_download_url: "https://example.invalid/stable-setup.exe",
|
||||
digest: `sha256:${stableDigest}`
|
||||
}
|
||||
]
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("stable-setup.exe")) {
|
||||
return new Response(executablePayload, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/octet-stream" }
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("missing", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const prechecked: UpdateCheckResult = {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "",
|
||||
setupAssetName: ""
|
||||
};
|
||||
|
||||
const result = await installLatestUpdate("owner/repo", prechecked);
|
||||
expect(result.started).toBe(true);
|
||||
expect(requestedUrls.some((url) => url.endsWith("/releases/tags/v9.9.9"))).toBe(true);
|
||||
expect(requestedUrls.some((url) => url.endsWith("/releases/latest"))).toBe(true);
|
||||
expect(requestedUrls.some((url) => url.includes("stable-setup.exe"))).toBe(true);
|
||||
expect(requestedUrls.some((url) => url.includes("draft-setup.exe"))).toBe(false);
|
||||
});
|
||||
|
||||
it("times out hanging release JSON body reads", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const cancelSpy = vi.fn(async () => undefined);
|
||||
globalThis.fetch = (async (): Promise<Response> => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ "Content-Type": "application/json" }),
|
||||
json: () => new Promise(() => undefined),
|
||||
body: {
|
||||
cancel: cancelSpy
|
||||
}
|
||||
} as unknown as Response)) as typeof fetch;
|
||||
|
||||
const pending = checkGitHubUpdate("owner/repo");
|
||||
await vi.advanceTimersByTimeAsync(13000);
|
||||
const result = await pending;
|
||||
expect(result.updateAvailable).toBe(false);
|
||||
expect(String(result.error || "")).toMatch(/timeout/i);
|
||||
expect(cancelSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts hanging update body downloads on idle timeout", async () => {
|
||||
const previousTimeout = process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS;
|
||||
process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS = "1000";
|
||||
|
||||
try {
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("hang-setup.exe")) {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
}
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/octet-stream" }
|
||||
});
|
||||
}
|
||||
return new Response("missing", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const prechecked: UpdateCheckResult = {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/hang-setup.exe",
|
||||
setupAssetName: "",
|
||||
setupAssetDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
};
|
||||
|
||||
const result = await installLatestUpdate("owner/repo", prechecked);
|
||||
expect(result.started).toBe(false);
|
||||
expect(result.message).toMatch(/timeout/i);
|
||||
} finally {
|
||||
if (previousTimeout === undefined) {
|
||||
delete process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS;
|
||||
} else {
|
||||
process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS = previousTimeout;
|
||||
}
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
it("blocks installer start when SHA256 digest mismatches", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("mismatch-setup.exe")) {
|
||||
return new Response(executablePayload, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/octet-stream" }
|
||||
});
|
||||
}
|
||||
return new Response("missing", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const prechecked: UpdateCheckResult = {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/mismatch-setup.exe",
|
||||
setupAssetName: "setup.exe",
|
||||
setupAssetDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
};
|
||||
|
||||
const result = await installLatestUpdate("owner/repo", prechecked);
|
||||
expect(result.started).toBe(false);
|
||||
expect(result.message).toMatch(/integrit|sha256|mismatch/i);
|
||||
});
|
||||
|
||||
it("blocks installer start when no digest can be resolved", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("unsigned-setup.exe")) {
|
||||
return new Response(executablePayload, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/octet-stream" }
|
||||
});
|
||||
}
|
||||
return new Response("missing", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const prechecked: UpdateCheckResult = {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/unsigned-setup.exe",
|
||||
setupAssetName: "setup.exe",
|
||||
setupAssetDigest: ""
|
||||
};
|
||||
|
||||
const result = await installLatestUpdate("owner/repo", prechecked);
|
||||
expect(result.started).toBe(false);
|
||||
expect(result.message).toMatch(/digest|integrit|sha/i);
|
||||
});
|
||||
|
||||
it("uses latest.yml SHA512 digest when API asset digest is missing", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
const digestSha512Hex = sha512Hex(executablePayload);
|
||||
const digestSha512Base64 = Buffer.from(digestSha512Hex, "hex").toString("base64");
|
||||
const requestedUrls: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
requestedUrls.push(url);
|
||||
|
||||
if (url.endsWith("/releases/tags/v9.9.9")) {
|
||||
return new Response(JSON.stringify({
|
||||
tag_name: "v9.9.9",
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: "Real-Debrid-Downloader-Setup-9.9.9.exe",
|
||||
browser_download_url: "https://example.invalid/setup-no-digest.exe"
|
||||
},
|
||||
{
|
||||
name: "latest.yml",
|
||||
browser_download_url: "https://example.invalid/latest.yml"
|
||||
}
|
||||
]
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("latest.yml")) {
|
||||
return new Response(
|
||||
`version: 9.9.9\npath: Real-Debrid-Downloader-Setup-9.9.9.exe\nsha512: ${digestSha512Base64}\n`,
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/yaml" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes("setup-no-digest.exe")) {
|
||||
return new Response(executablePayload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": String(executablePayload.length)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("missing", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const prechecked: UpdateCheckResult = {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/setup-no-digest.exe",
|
||||
setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe",
|
||||
setupAssetDigest: ""
|
||||
};
|
||||
|
||||
const result = await installLatestUpdate("owner/repo", prechecked);
|
||||
expect(result.started).toBe(true);
|
||||
expect(requestedUrls.some((url) => url.endsWith("/releases/tags/v9.9.9"))).toBe(true);
|
||||
expect(requestedUrls.some((url) => url.includes("latest.yml"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects installer when latest.yml SHA512 digest does not match", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
const wrongDigestBase64 = Buffer.alloc(64, 0x13).toString("base64");
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
|
||||
if (url.endsWith("/releases/tags/v9.9.9")) {
|
||||
return new Response(JSON.stringify({
|
||||
tag_name: "v9.9.9",
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: "Real-Debrid-Downloader-Setup-9.9.9.exe",
|
||||
browser_download_url: "https://example.invalid/setup-no-digest.exe"
|
||||
},
|
||||
{
|
||||
name: "latest.yml",
|
||||
browser_download_url: "https://example.invalid/latest.yml"
|
||||
}
|
||||
]
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
if (url.includes("latest.yml")) {
|
||||
return new Response(
|
||||
`version: 9.9.9\npath: Real-Debrid-Downloader-Setup-9.9.9.exe\nsha512: ${wrongDigestBase64}\n`,
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/yaml" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes("setup-no-digest.exe")) {
|
||||
return new Response(executablePayload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": String(executablePayload.length)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("missing", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const prechecked: UpdateCheckResult = {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/setup-no-digest.exe",
|
||||
setupAssetName: "Real-Debrid-Downloader-Setup-9.9.9.exe",
|
||||
setupAssetDigest: ""
|
||||
};
|
||||
|
||||
const result = await installLatestUpdate("owner/repo", prechecked);
|
||||
expect(result.started).toBe(false);
|
||||
expect(result.message).toMatch(/sha512|integrit|mismatch/i);
|
||||
});
|
||||
|
||||
it("emits install progress events while downloading and launching update", async () => {
|
||||
const executablePayload = fs.readFileSync(process.execPath);
|
||||
const digest = sha256Hex(executablePayload);
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("progress-setup.exe")) {
|
||||
return new Response(executablePayload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": String(executablePayload.length)
|
||||
}
|
||||
});
|
||||
}
|
||||
return new Response("missing", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const prechecked: UpdateCheckResult = {
|
||||
updateAvailable: true,
|
||||
currentVersion: APP_VERSION,
|
||||
latestVersion: "9.9.9",
|
||||
latestTag: "v9.9.9",
|
||||
releaseUrl: "https://github.com/owner/repo/releases/tag/v9.9.9",
|
||||
setupAssetUrl: "https://example.invalid/progress-setup.exe",
|
||||
setupAssetName: "setup.exe",
|
||||
setupAssetDigest: `sha256:${digest}`
|
||||
};
|
||||
|
||||
const progressEvents: UpdateInstallProgress[] = [];
|
||||
const result = await installLatestUpdate("owner/repo", prechecked, (progress) => {
|
||||
progressEvents.push(progress);
|
||||
});
|
||||
|
||||
expect(result.started).toBe(true);
|
||||
expect(spawnMock).toHaveBeenCalledWith(expect.any(String), ["/S", "--updated", "--force-run"], expect.objectContaining({
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true
|
||||
}));
|
||||
expect(unrefMock).toHaveBeenCalledTimes(1);
|
||||
expect(progressEvents.some((entry) => entry.stage === "starting")).toBe(true);
|
||||
expect(progressEvents.some((entry) => entry.stage === "downloading")).toBe(true);
|
||||
expect(progressEvents.some((entry) => entry.stage === "verifying")).toBe(true);
|
||||
expect(progressEvents.some((entry) => entry.stage === "launching")).toBe(true);
|
||||
expect(progressEvents.some((entry) => entry.stage === "done")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeUpdateRepo extended", () => {
|
||||
it("handles trailing slashes and extra path segments", () => {
|
||||
expect(normalizeUpdateRepo("owner/repo/")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("/owner/repo/")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("https://github.com/owner/repo/tree/main/src")).toBe("owner/repo");
|
||||
});
|
||||
|
||||
it("handles ssh-style git URLs", () => {
|
||||
expect(normalizeUpdateRepo("git@github.com:user/project.git")).toBe("user/project");
|
||||
});
|
||||
|
||||
it("returns default for malformed inputs", () => {
|
||||
expect(normalizeUpdateRepo("just-one-part")).toBe("Sucukdeluxe/multi-debrid-downloader");
|
||||
expect(normalizeUpdateRepo(" ")).toBe("Sucukdeluxe/multi-debrid-downloader");
|
||||
});
|
||||
|
||||
it("rejects traversal-like owner or repo segments", () => {
|
||||
expect(normalizeUpdateRepo("../owner/repo")).toBe("Sucukdeluxe/multi-debrid-downloader");
|
||||
expect(normalizeUpdateRepo("owner/../repo")).toBe("Sucukdeluxe/multi-debrid-downloader");
|
||||
expect(normalizeUpdateRepo("https://github.com/owner/../../repo")).toBe("Sucukdeluxe/multi-debrid-downloader");
|
||||
});
|
||||
|
||||
it("handles www prefix", () => {
|
||||
expect(normalizeUpdateRepo("https://www.github.com/owner/repo")).toBe("owner/repo");
|
||||
expect(normalizeUpdateRepo("www.github.com/owner/repo")).toBe("owner/repo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRemoteNewer", () => {
|
||||
it("detects newer major version", () => {
|
||||
expect(isRemoteNewer("1.0.0", "2.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects newer minor version", () => {
|
||||
expect(isRemoteNewer("1.2.0", "1.3.0")).toBe(true);
|
||||
});
|
||||
|
||||
it("detects newer patch version", () => {
|
||||
expect(isRemoteNewer("1.2.3", "1.2.4")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for same version", () => {
|
||||
expect(isRemoteNewer("1.2.3", "1.2.3")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for older version", () => {
|
||||
expect(isRemoteNewer("2.0.0", "1.0.0")).toBe(false);
|
||||
expect(isRemoteNewer("1.3.0", "1.2.0")).toBe(false);
|
||||
expect(isRemoteNewer("1.2.4", "1.2.3")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles versions with different segment counts", () => {
|
||||
expect(isRemoteNewer("1.2", "1.2.1")).toBe(true);
|
||||
expect(isRemoteNewer("1.2.1", "1.2")).toBe(false);
|
||||
expect(isRemoteNewer("1", "1.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles v-prefix in version strings", () => {
|
||||
expect(isRemoteNewer("v1.0.0", "v2.0.0")).toBe(true);
|
||||
expect(isRemoteNewer("v1.0.0", "v1.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseVersionParts", () => {
|
||||
it("parses standard version strings", () => {
|
||||
expect(parseVersionParts("1.2.3")).toEqual([1, 2, 3]);
|
||||
expect(parseVersionParts("10.20.30")).toEqual([10, 20, 30]);
|
||||
});
|
||||
|
||||
it("strips v prefix", () => {
|
||||
expect(parseVersionParts("v1.2.3")).toEqual([1, 2, 3]);
|
||||
expect(parseVersionParts("V1.2.3")).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("handles single segment", () => {
|
||||
expect(parseVersionParts("5")).toEqual([5]);
|
||||
});
|
||||
|
||||
it("handles version with pre-release suffix", () => {
|
||||
expect(parseVersionParts("1.2.3-beta")).toEqual([1, 2, 3]);
|
||||
expect(parseVersionParts("1.2.3rc1")).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("handles empty and whitespace", () => {
|
||||
expect(parseVersionParts("")).toEqual([0]);
|
||||
expect(parseVersionParts(" ")).toEqual([0]);
|
||||
});
|
||||
|
||||
it("handles versions with extra dots", () => {
|
||||
expect(parseVersionParts("1.2.3.4")).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractHttpLinksFromText, parsePackagesFromLinksText, isHttpLink, sanitizeFilename, formatEta, filenameFromUrl, looksLikeOpaqueFilename } from "../src/main/utils";
|
||||
|
||||
describe("utils", () => {
|
||||
it("validates http links", () => {
|
||||
expect(isHttpLink("https://example.com/file")).toBe(true);
|
||||
expect(isHttpLink("http://example.com/file")).toBe(true);
|
||||
expect(isHttpLink("ftp://example.com")).toBe(false);
|
||||
expect(isHttpLink("foo bar")).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts links from text and trims trailing punctuation", () => {
|
||||
const links = extractHttpLinksFromText("See (https://example.com/test) and https://rapidgator.net/file/abc123, plus https://example.com/a.b.");
|
||||
expect(links).toEqual([
|
||||
"https://example.com/test",
|
||||
"https://rapidgator.net/file/abc123",
|
||||
"https://example.com/a.b"
|
||||
]);
|
||||
});
|
||||
|
||||
it("sanitizes filenames", () => {
|
||||
expect(sanitizeFilename("foo/bar:baz*")).toBe("foo bar baz");
|
||||
expect(sanitizeFilename(" ")).toBe("Paket");
|
||||
expect(sanitizeFilename("test\0file.txt")).toBe("testfile.txt");
|
||||
expect(sanitizeFilename("\0\0\0")).toBe("Paket");
|
||||
expect(sanitizeFilename("..")).toBe("Paket");
|
||||
expect(sanitizeFilename(".")).toBe("Paket");
|
||||
expect(sanitizeFilename("release... ")).toBe("release");
|
||||
expect(sanitizeFilename(" con ")).toBe("con_");
|
||||
});
|
||||
|
||||
it("parses package markers", () => {
|
||||
const parsed = parsePackagesFromLinksText(
|
||||
"# package: A\nhttps://a.com/1\nhttps://a.com/2\n# package: B\nhttps://b.com/1\n",
|
||||
"Default"
|
||||
);
|
||||
expect(parsed).toHaveLength(2);
|
||||
expect(parsed[0].name).toBe("A");
|
||||
expect(parsed[0].links).toHaveLength(2);
|
||||
expect(parsed[1].name).toBe("B");
|
||||
});
|
||||
|
||||
it("parses optional file markers for roundtrip imports", () => {
|
||||
const parsed = parsePackagesFromLinksText(
|
||||
"# rd-link-export: 1\n# package: Dave Staffel 1\n# file: Folge 001.rar\nhttps://a.com/1\n# file: Folge 002.rar\nhttps://a.com/2\n",
|
||||
"Default"
|
||||
);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0].name).toBe("Dave Staffel 1");
|
||||
expect(parsed[0].links).toEqual(["https://a.com/1", "https://a.com/2"]);
|
||||
expect(parsed[0].fileNames).toEqual(["Folge 001.rar", "Folge 002.rar"]);
|
||||
});
|
||||
|
||||
it("does not carry a file marker across package boundaries", () => {
|
||||
const parsed = parsePackagesFromLinksText(
|
||||
"# package: Dave Staffel 1\n# file: Folge 001.rar\n# package: Dave Staffel 2\nhttps://a.com/2\n",
|
||||
"Default"
|
||||
);
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0].name).toBe("Dave Staffel 2");
|
||||
expect(parsed[0].links).toEqual(["https://a.com/2"]);
|
||||
expect(parsed[0].fileNames).toBeUndefined();
|
||||
});
|
||||
|
||||
it("formats eta", () => {
|
||||
expect(formatEta(-1)).toBe("--");
|
||||
expect(formatEta(65)).toBe("01:05");
|
||||
expect(formatEta(3661)).toBe("01:01:01");
|
||||
});
|
||||
|
||||
it("normalizes filenames from links", () => {
|
||||
expect(filenameFromUrl("https://rapidgator.net/file/id/show.part1.rar.html")).toBe("show.part1.rar");
|
||||
expect(filenameFromUrl("https://debrid.example/dl/abc?filename=Movie.S01E01.mkv")).toBe("Movie.S01E01.mkv");
|
||||
expect(filenameFromUrl("https://debrid.example/dl/%E0%A4%A")).toBe("%E0%A4%A");
|
||||
expect(filenameFromUrl("https://debrid.example/dl/e51f6809bb6ca615601f5ac5db433737")).toBe("e51f6809bb6ca615601f5ac5db433737");
|
||||
expect(filenameFromUrl("data:text/plain;base64,SGVsbG8=")).toBe("download.bin");
|
||||
expect(filenameFromUrl("blob:https://example.com/12345678-1234-1234-1234-1234567890ab")).toBe("download.bin");
|
||||
expect(looksLikeOpaqueFilename("download.bin")).toBe(true);
|
||||
expect(looksLikeOpaqueFilename("e51f6809bb6ca615601f5ac5db433737")).toBe(true);
|
||||
expect(looksLikeOpaqueFilename("movie.part1.rar")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves unicode filenames", () => {
|
||||
expect(sanitizeFilename("日本語ファイル.txt")).toBe("日本語ファイル.txt");
|
||||
expect(sanitizeFilename("Ünïcödé Tëst.mkv")).toBe("Ünïcödé Tëst.mkv");
|
||||
expect(sanitizeFilename("파일이름.rar")).toBe("파일이름.rar");
|
||||
expect(sanitizeFilename("файл.zip")).toBe("файл.zip");
|
||||
});
|
||||
|
||||
it("handles very long filenames", () => {
|
||||
const longName = "a".repeat(300);
|
||||
const result = sanitizeFilename(longName);
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result).toBe(longName);
|
||||
});
|
||||
|
||||
it("formats eta with very large values without crashing", () => {
|
||||
const result = formatEta(999999);
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result).toBe("277:46:39");
|
||||
});
|
||||
|
||||
it("formats eta with edge cases", () => {
|
||||
expect(formatEta(0)).toBe("00:00");
|
||||
expect(formatEta(NaN)).toBe("--");
|
||||
expect(formatEta(Infinity)).toBe("--");
|
||||
expect(formatEta(Number.MAX_SAFE_INTEGER)).toMatch(/^\d+:\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it("extracts filenames from URLs with encoded characters", () => {
|
||||
expect(filenameFromUrl("https://example.com/file%20with%20spaces.rar")).toBe("file with spaces.rar");
|
||||
expect(filenameFromUrl("https://example.com/t%C3%A9st%20file.zip")).toBe("t\u00e9st file.zip");
|
||||
expect(filenameFromUrl("https://example.com/dl?filename=Movie%20Name%20S01E01.mkv")).toBe("Movie Name S01E01.mkv");
|
||||
const result = filenameFromUrl("https://example.com/%ZZ%invalid");
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("handles looksLikeOpaqueFilename edge cases", () => {
|
||||
expect(looksLikeOpaqueFilename("")).toBe(false);
|
||||
expect(looksLikeOpaqueFilename("a")).toBe(false);
|
||||
expect(looksLikeOpaqueFilename("ab")).toBe(false);
|
||||
expect(looksLikeOpaqueFilename("abc")).toBe(false);
|
||||
expect(looksLikeOpaqueFilename("download.bin")).toBe(true);
|
||||
expect(looksLikeOpaqueFilename("abcdef123456789012345678")).toBe(true);
|
||||
expect(looksLikeOpaqueFilename("abcdef1234567890abcdef12")).toBe(true);
|
||||
expect(looksLikeOpaqueFilename("abcdef12345")).toBe(false);
|
||||
expect(looksLikeOpaqueFilename("Show.S01E01.720p.mkv")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
stripDualLangMarker,
|
||||
hasDualLangMarker,
|
||||
isRemuxableVideoFile,
|
||||
looksLikeGermanRelease,
|
||||
pickAudioTrack,
|
||||
parseFfprobeAudioStreams,
|
||||
buildFfprobeArgs,
|
||||
buildFfmpegRemuxArgs,
|
||||
computeRemuxTimeoutMs,
|
||||
processVideoFile,
|
||||
renameWithRetry,
|
||||
type VideoSpawnResult
|
||||
} from "../src/main/video-processor";
|
||||
|
||||
describe("stripDualLangMarker", () => {
|
||||
it("strips a mid-name .DL. token", () => {
|
||||
expect(stripDualLangMarker("Show.S01E01.German.DL.720p.WEB.x264.mkv")).toBe("Show.S01E01.German.720p.WEB.x264.mkv");
|
||||
});
|
||||
it("strips a .DL. directly before the extension", () => {
|
||||
expect(stripDualLangMarker("Movie.DL.mkv")).toBe("Movie.mkv");
|
||||
});
|
||||
it("strips a trailing .DL token before extension", () => {
|
||||
expect(stripDualLangMarker("Movie.German.DL.mp4")).toBe("Movie.German.mp4");
|
||||
});
|
||||
it("is case-insensitive", () => {
|
||||
expect(stripDualLangMarker("Show.dl.1080p.mkv")).toBe("Show.1080p.mkv");
|
||||
});
|
||||
it("leaves files without the marker unchanged", () => {
|
||||
expect(stripDualLangMarker("Show.S01E01.German.1080p.mkv")).toBe("Show.S01E01.German.1080p.mkv");
|
||||
});
|
||||
it("does not strip unrelated tokens containing DL", () => {
|
||||
expect(stripDualLangMarker("Show.HANDLES.1080p.mkv")).toBe("Show.HANDLES.1080p.mkv");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasDualLangMarker", () => {
|
||||
it("detects the marker", () => {
|
||||
expect(hasDualLangMarker("X.German.DL.720p.mkv")).toBe(true);
|
||||
expect(hasDualLangMarker("X.DL.mkv")).toBe(true);
|
||||
});
|
||||
it("returns false without the marker", () => {
|
||||
expect(hasDualLangMarker("X.German.720p.mkv")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRemuxableVideoFile", () => {
|
||||
it("accepts mkv/mp4 only", () => {
|
||||
expect(isRemuxableVideoFile("a.mkv")).toBe(true);
|
||||
expect(isRemuxableVideoFile("a.MP4")).toBe(true);
|
||||
expect(isRemuxableVideoFile("a.avi")).toBe(false);
|
||||
expect(isRemuxableVideoFile("a.srt")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickAudioTrack", () => {
|
||||
const ger = { language: "ger", title: "" };
|
||||
const eng = { language: "eng", title: "" };
|
||||
const untagged = { language: "", title: "" };
|
||||
|
||||
it("no audio -> skip", () => {
|
||||
expect(pickAudioTrack([], "tag").action).toBe("skip");
|
||||
});
|
||||
|
||||
it("first mode keeps first of many", () => {
|
||||
const d = pickAudioTrack([eng, ger], "first");
|
||||
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0 });
|
||||
});
|
||||
|
||||
it("first mode with single audio -> single (no remux)", () => {
|
||||
expect(pickAudioTrack([eng], "first")).toMatchObject({ action: "single" });
|
||||
});
|
||||
|
||||
it("tag mode picks the German track even if not first", () => {
|
||||
const d = pickAudioTrack([eng, ger], "tag");
|
||||
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" });
|
||||
});
|
||||
|
||||
it("tag mode picks German via title when language untagged", () => {
|
||||
const d = pickAudioTrack([{ language: "", title: "Englisch" }, { language: "", title: "Deutsch" }], "tag");
|
||||
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1 });
|
||||
});
|
||||
|
||||
it("tag mode does NOT treat an ambiguous 3-letter title code as German (no false-positive pick)", () => {
|
||||
// Two untagged tracks whose titles are only "Ger"/"Deu" must not be mistaken
|
||||
// for a German track; with no real German signal this falls back to first.
|
||||
const d = pickAudioTrack([{ language: "", title: "Ger" }, { language: "", title: "Deu" }], "tag");
|
||||
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
|
||||
});
|
||||
|
||||
it("tag mode does NOT let an eng track titled 'German' beat a correctly-tagged German track", () => {
|
||||
const engTitledGerman = { language: "eng", title: "German Commentary" };
|
||||
const d = pickAudioTrack([engTitledGerman, ger], "tag");
|
||||
expect(d).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" });
|
||||
});
|
||||
|
||||
it("tag mode does NOT pick a non-German-tagged track just because its title contains 'Deutsch'", () => {
|
||||
const d = pickAudioTrack([{ language: "eng", title: "Deutsch entfernt" }, { language: "fre", title: "" }], "tag");
|
||||
expect(d).toMatchObject({ action: "skip", reason: "no-german-track" });
|
||||
});
|
||||
|
||||
it("tag mode with single German -> single (no remux)", () => {
|
||||
expect(pickAudioTrack([ger], "tag")).toMatchObject({ action: "single" });
|
||||
});
|
||||
|
||||
it("tag mode, fully untagged multi -> fallback to first", () => {
|
||||
const d = pickAudioTrack([untagged, untagged], "tag");
|
||||
expect(d).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" });
|
||||
});
|
||||
|
||||
it("tag mode, tagged but no German -> SKIP (never delete the only usable audio)", () => {
|
||||
expect(pickAudioTrack([eng, { language: "fre", title: "" }], "tag")).toMatchObject({ action: "skip", reason: "no-german-track" });
|
||||
});
|
||||
|
||||
it("tag mode, no German tag but GERMAN release -> fall back to first track (mislabeled dub)", () => {
|
||||
expect(pickAudioTrack([eng, eng], "tag", true)).toMatchObject({ action: "remux", audioRelIndex: 0, reason: "fallback-first-german-release" });
|
||||
});
|
||||
|
||||
it("tag mode, single mislabeled track on a German release -> keep it (no remux)", () => {
|
||||
expect(pickAudioTrack([eng], "tag", true)).toMatchObject({ action: "single", reason: "single-german-mislabeled" });
|
||||
});
|
||||
|
||||
it("tag mode, no German tag and NOT flagged German -> still SKIP (safety preserved)", () => {
|
||||
expect(pickAudioTrack([eng, eng], "tag", false)).toMatchObject({ action: "skip", reason: "no-german-track" });
|
||||
});
|
||||
|
||||
it("correctly tagged German still wins even on a German release (fallback not needed)", () => {
|
||||
expect(pickAudioTrack([eng, ger], "tag", true)).toMatchObject({ action: "remux", audioRelIndex: 1, reason: "german-tag" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeGermanRelease", () => {
|
||||
it("detects German/Dubbed release names", () => {
|
||||
expect(looksLikeGermanRelease("Desperate.Housewives.S02E01.German.DD51.Dubbed.DL.720p.WEB-DL.x264.mkv")).toBe(true);
|
||||
expect(looksLikeGermanRelease("1899.S01E01.German.DL.720p.WEB-x264-WvF.mkv")).toBe(true);
|
||||
expect(looksLikeGermanRelease("Show.S01E01.Deutsch.1080p.mkv")).toBe(true);
|
||||
});
|
||||
it("does not flag a bare .DL. name without an explicit German token", () => {
|
||||
expect(looksLikeGermanRelease("Show.S01E01.DL.720p.x264.mkv")).toBe(false);
|
||||
expect(looksLikeGermanRelease("Show.S01E01.MULTi.1080p.mkv")).toBe(false);
|
||||
});
|
||||
it("does not flag a non-German dub as a German release (bare 'Dubbed' is ambiguous)", () => {
|
||||
expect(looksLikeGermanRelease("Movie.2020.ITALIAN.Dubbed.DL.1080p.mkv")).toBe(false);
|
||||
expect(looksLikeGermanRelease("Movie.2020.FRENCH.DUBBED.DL.720p.mkv")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFfprobeAudioStreams", () => {
|
||||
it("parses language/title tags", () => {
|
||||
const json = JSON.stringify({ streams: [{ index: 1, tags: { language: "ger", title: "Deutsch" } }, { index: 2, tags: { language: "eng" } }] });
|
||||
expect(parseFfprobeAudioStreams(json)).toEqual([{ language: "ger", title: "Deutsch" }, { language: "eng", title: "" }]);
|
||||
});
|
||||
it("returns [] on invalid json", () => {
|
||||
expect(parseFfprobeAudioStreams("not json")).toEqual([]);
|
||||
});
|
||||
it("returns [] when streams missing", () => {
|
||||
expect(parseFfprobeAudioStreams("{}")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFfprobeArgs", () => {
|
||||
it("requests audio streams as json", () => {
|
||||
const args = buildFfprobeArgs("in.mkv");
|
||||
expect(args).toContain("-select_streams");
|
||||
expect(args).toContain("a");
|
||||
expect(args[args.length - 1]).toBe("in.mkv");
|
||||
expect(args).toContain("json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFfmpegRemuxArgs", () => {
|
||||
it("maps video + chosen audio, stream-copy, keeps metadata (language tag), no subs by default", () => {
|
||||
const args = buildFfmpegRemuxArgs({ input: "in.mkv", output: "out.mkv", audioRelIndex: 1 });
|
||||
expect(args).toEqual([
|
||||
"-i", "in.mkv", "-map", "0:v:0", "-map", "0:a:1",
|
||||
"-c", "copy", "-disposition:a:0", "default", "-y", "out.mkv"
|
||||
]);
|
||||
expect(args).not.toContain("-map_metadata"); // language tag of kept track must survive
|
||||
});
|
||||
it("adds optional German subtitle maps when keepSubs", () => {
|
||||
const args = buildFfmpegRemuxArgs({ input: "in.mkv", output: "out.mkv", audioRelIndex: 0, keepSubs: true });
|
||||
expect(args.join(" ")).toContain("0:s:m:language:ger?");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeRemuxTimeoutMs", () => {
|
||||
it("has a floor", () => {
|
||||
expect(computeRemuxTimeoutMs(0)).toBe(120_000);
|
||||
});
|
||||
it("scales with size and caps at 60 min", () => {
|
||||
expect(computeRemuxTimeoutMs(50 * 1024 * 1024 * 1024)).toBe(60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
// Exercises the REAL file-mutating body (temp -> replace -> utimes -> rm) with a
|
||||
// fake ffmpeg/ffprobe runner. This is the irreversible-overwrite path that the
|
||||
// download-manager integration test (which mocks processVideoFile wholesale)
|
||||
// cannot cover.
|
||||
describe("processVideoFile (real fs body, fake runner)", () => {
|
||||
const tempDirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const d of tempDirs.splice(0)) {
|
||||
try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
function makeFile(content: string, name = "Show.S01E01.German.DL.720p.mkv"): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-vp-"));
|
||||
tempDirs.push(dir);
|
||||
const file = path.join(dir, name);
|
||||
fs.writeFileSync(file, content);
|
||||
return file;
|
||||
}
|
||||
|
||||
function fakeRunner(opts: { probeJson: string; ffmpegOk?: boolean }): typeof import("../src/main/video-processor").runVideoProcess {
|
||||
return async (_command: string, args: string[]): Promise<VideoSpawnResult> => {
|
||||
const base = { aborted: false, timedOut: false, missing: false } as const;
|
||||
if (args.includes("-show_entries")) {
|
||||
return { ...base, ok: true, exitCode: 0, stdout: opts.probeJson, stderr: "" };
|
||||
}
|
||||
const output = args[args.length - 1];
|
||||
if (opts.ffmpegOk !== false) {
|
||||
fs.writeFileSync(output, "REMUXED-GERMAN-ONLY");
|
||||
return { ...base, ok: true, exitCode: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
return { ...base, ok: false, exitCode: 1, stdout: "", stderr: "ffmpeg boom" };
|
||||
};
|
||||
}
|
||||
|
||||
// Any sidecar the replace machinery may leave behind (unique "~rd…" temp names).
|
||||
function leftoverTemps(file: string): string[] {
|
||||
return fs.readdirSync(path.dirname(file)).filter((n) => n.startsWith("~rd"));
|
||||
}
|
||||
|
||||
const tooling = async (): Promise<{ ffmpeg: string; ffprobe: string }> => ({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" });
|
||||
const twoTracksGerSecond = JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "ger" } }] });
|
||||
|
||||
it("replaces the original in place and preserves mtime on success", async () => {
|
||||
const file = makeFile("ORIGINAL");
|
||||
const oldTime = new Date(Date.now() - 5 * 60 * 1000);
|
||||
fs.utimesSync(file, oldTime, oldTime);
|
||||
const beforeMtime = fs.statSync(file).mtimeMs;
|
||||
|
||||
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||
resolveTooling: tooling,
|
||||
runProcess: fakeRunner({ probeJson: twoTracksGerSecond })
|
||||
});
|
||||
|
||||
expect(result.action).toBe("remuxed");
|
||||
expect(result.keptTrackIndex).toBe(1); // German was second
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("REMUXED-GERMAN-ONLY"); // original overwritten
|
||||
expect(Math.abs(fs.statSync(file).mtimeMs - beforeMtime)).toBeLessThan(1500); // mtime preserved
|
||||
expect(leftoverTemps(file)).toEqual([]); // unique temp cleaned up
|
||||
});
|
||||
|
||||
it("leaves the original intact and removes temp when ffmpeg fails", async () => {
|
||||
const file = makeFile("ORIGINAL");
|
||||
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||
resolveTooling: tooling,
|
||||
runProcess: fakeRunner({ probeJson: twoTracksGerSecond, ffmpegOk: false })
|
||||
});
|
||||
|
||||
expect(result.action).toBe("error");
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); // never lost
|
||||
expect(leftoverTemps(file)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the original intact and cleans the temp when the atomic replace rename fails (no zero-copy window)", async () => {
|
||||
// Simulate a Windows file lock that defeats the replace even after retries.
|
||||
// The original must survive: the old rm-then-rename fallback could leave the
|
||||
// file with NEITHER the original nor the remux on disk.
|
||||
const file = makeFile("ORIGINAL");
|
||||
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||
resolveTooling: tooling,
|
||||
runProcess: fakeRunner({ probeJson: twoTracksGerSecond }),
|
||||
rename: async () => { throw Object.assign(new Error("locked"), { code: "EBUSY" }); }
|
||||
});
|
||||
|
||||
expect(result.action).toBe("error");
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL"); // original never destroyed
|
||||
expect(leftoverTemps(file)).toEqual([]); // remux temp removed
|
||||
});
|
||||
|
||||
it("does not touch a single-audio file (no remux)", async () => {
|
||||
const file = makeFile("ORIGINAL");
|
||||
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||
resolveTooling: tooling,
|
||||
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "ger" } }] }) })
|
||||
});
|
||||
expect(result.action).toBe("kept-single");
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
|
||||
});
|
||||
|
||||
it("remuxes a German-named release with MISLABELED audio tags (fallback to first track)", async () => {
|
||||
// Name says German, but both audio tracks are tagged eng/fre (the dub is
|
||||
// mislabeled). The fallback keeps the first track instead of skipping.
|
||||
const file = makeFile("ORIGINAL"); // name contains "German"
|
||||
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||
resolveTooling: tooling,
|
||||
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "fre" } }] }) })
|
||||
});
|
||||
expect(result.action).toBe("remuxed");
|
||||
expect(result.keptTrackIndex).toBe(0);
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("REMUXED-GERMAN-ONLY");
|
||||
});
|
||||
|
||||
it("leaves a NON-German-named file untouched when tagged but no German track (safety preserved)", async () => {
|
||||
const file = makeFile("ORIGINAL", "Show.S01E01.MULTi.DL.720p.mkv");
|
||||
const result = await processVideoFile(file, { mode: "tag" }, {
|
||||
resolveTooling: tooling,
|
||||
runProcess: fakeRunner({ probeJson: JSON.stringify({ streams: [{ tags: { language: "eng" } }, { tags: { language: "fre" } }] }) })
|
||||
});
|
||||
expect(result.action).toBe("skipped-no-german");
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
|
||||
});
|
||||
|
||||
it("returns skipped-no-tool when ffmpeg/ffprobe are absent", async () => {
|
||||
const file = makeFile("ORIGINAL");
|
||||
const result = await processVideoFile(file, { mode: "tag" }, { resolveTooling: async () => null });
|
||||
expect(result.action).toBe("skipped-no-tool");
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("ORIGINAL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renameWithRetry", () => {
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
const busy = (): NodeJS.ErrnoException => Object.assign(new Error("locked"), { code: "EBUSY" });
|
||||
|
||||
it("retries a transient EBUSY and then succeeds", async () => {
|
||||
let calls = 0;
|
||||
vi.spyOn(fs.promises, "rename").mockImplementation(async () => {
|
||||
calls += 1;
|
||||
if (calls <= 2) { throw busy(); }
|
||||
});
|
||||
await expect(renameWithRetry("a", "b")).resolves.toBeUndefined();
|
||||
expect(calls).toBe(3); // failed twice, succeeded on the third attempt
|
||||
});
|
||||
|
||||
it("gives up after exhausting retries on a persistent lock", async () => {
|
||||
let calls = 0;
|
||||
vi.spyOn(fs.promises, "rename").mockImplementation(async () => { calls += 1; throw busy(); });
|
||||
await expect(renameWithRetry("a", "b")).rejects.toThrow("locked");
|
||||
expect(calls).toBe(4); // initial attempt + 3 backoff retries
|
||||
});
|
||||
|
||||
it("does not retry a non-retryable error (e.g. EXDEV) — fails fast", async () => {
|
||||
let calls = 0;
|
||||
vi.spyOn(fs.promises, "rename").mockImplementation(async () => {
|
||||
calls += 1;
|
||||
throw Object.assign(new Error("cross-device"), { code: "EXDEV" });
|
||||
});
|
||||
await expect(renameWithRetry("a", "b")).rejects.toThrow("cross-device");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user