feat: add Deepbrid, daily scheduling, and notification center

Add encrypted Deepbrid API accounts with account validation, provider routing, fallback, usage tracking, safe error handling, and verified 1Fichier downloads. Restore persistent recurring daily starts with local-calendar deduplication and legacy schedule compatibility. Add durable Discord package, run, remaining-volume, stall, and recovery notifications with privacy-safe telemetry and disk-failure recovery.
This commit is contained in:
Sucukdeluxe
2026-08-24 07:37:55 +02:00
parent 06e5bf4340
commit 1b7caba2eb
83 changed files with 12769 additions and 1098 deletions
+133 -3
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts, checkRealDebridAccount, REAL_DEBRID_STATUS_ID, retainConfiguredRealDebridStatuses } from "../src/main/account-check";
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts, checkDeepbridAccount, checkRealDebridAccount, REAL_DEBRID_STATUS_ID, retainConfiguredRealDebridStatuses } from "../src/main/account-check";
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
import { getDebridLinkApiKeyId, type DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
import type { AppSettings } from "../src/shared/types";
@@ -15,14 +15,23 @@ 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 {
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);
}
}
function mockDeepbridResponse(status: number, body: unknown, contentType = "application/json"): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn(async () => new Response(
typeof body === "string" ? body : JSON.stringify(body),
{ status, headers: { "content-type": contentType } }
));
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
return fetchMock;
}
const NOW = 1_700_000_000_000;
@@ -179,8 +188,129 @@ describe("checkRealDebridAccount", () => {
});
});
});
describe("checkDeepbridAccount", () => {
const key = "fixture-deepbrid-check-key-4fG7";
it("reports a current premium account with safe identity metadata", async () => {
const expiration = new Date(NOW + 3 * 24 * 60 * 60 * 1000).toISOString();
mockDeepbridResponse(200, {
username: "deep-user",
email: "deep-user@example.test",
type: "premium",
expiration,
maxDownloads: 10,
maxConnections: 4
});
const status = await checkDeepbridAccount(key, undefined, NOW);
expect(status).toMatchObject({
accountId: "svc-deepbrid",
provider: "deepbrid",
label: "Deepbrid",
valid: true,
isPremium: true,
premiumUntilMs: Date.parse(expiration),
username: "deep-user",
email: "deep-user@example.test"
});
expect(status.message).toMatch(/Premium noch/);
expect(status.maskedLogin).not.toBe(key);
expect(JSON.stringify(status)).not.toContain(key);
});
it("keeps a free account valid without marking it as premium", async () => {
mockDeepbridResponse(200, {
username: "free-user",
email: "free-user@example.test",
type: "free",
expiration: "",
maxDownloads: 2,
maxConnections: 1
});
const status = await checkDeepbridAccount(key, undefined, NOW);
expect(status).toMatchObject({ valid: true, isPremium: false, premiumUntilMs: null });
expect(status.message).toMatch(/Free/);
});
it.each([
[401, /ungültig/i],
[403, /gesperrt/i]
])("reports HTTP %i as an honest invalid key status", async (httpStatus, message) => {
mockDeepbridResponse(httpStatus, { error: httpStatus });
const status = await checkDeepbridAccount(key, undefined, NOW);
expect(status.valid).toBe(false);
expect(status.message).toMatch(message);
expect(JSON.stringify(status)).not.toContain(key);
});
it("reports HTML instead of account JSON as a failed check", async () => {
mockDeepbridResponse(200, "<html>gateway</html>", "text/html");
const status = await checkDeepbridAccount(key, undefined, NOW);
expect(status.valid).toBe(false);
expect(status.message).toMatch(/Prüfung fehlgeschlagen/);
expect(status.message).not.toContain("gateway");
});
it.each([
new Error("ECONNRESET fixture-deepbrid-check-key-4fG7"),
new DOMException("Timeout fixture-deepbrid-check-key-4fG7", "TimeoutError")
])("reports transport failures without reflecting raw errors", async (error) => {
vi.stubGlobal("fetch", vi.fn(async () => { throw error; }) as unknown as typeof fetch);
const status = await checkDeepbridAccount(key, undefined, NOW);
expect(status.valid).toBe(false);
expect(status.message).toBe("Prüfung fehlgeschlagen");
expect(JSON.stringify(status)).not.toContain(key);
});
it("distinguishes a caller abort from a failed check", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const controller = new AbortController();
controller.abort(new Error(`cancel ${key}`));
const status = await checkDeepbridAccount(key, controller.signal, NOW);
expect(status.message).toBe("Prüfung abgebrochen");
expect(fetchMock).not.toHaveBeenCalled();
expect(JSON.stringify(status)).not.toContain(key);
});
});
describe("checkAllDebridAccounts", () => {
it("checks one configured Deepbrid account in all scope and only an enabled one in active scope", async () => {
const key = "fixture-deepbrid-bulk-key-7hJ2";
const fetchMock = mockDeepbridResponse(200, {
username: "bulk-user",
email: "bulk-user@example.test",
type: "free",
expiration: "",
maxDownloads: 2,
maxConnections: 1
});
const settings = {
...defaultSettings(),
deepbridApiKey: key,
disabledProviders: ["deepbrid" as const]
};
const active = await checkAllDebridAccounts(settings, undefined, undefined, "active");
const all = await checkAllDebridAccounts(settings, undefined, undefined, "all");
expect(active).toEqual([]);
expect(all).toEqual([expect.objectContaining({ accountId: "svc-deepbrid", provider: "deepbrid", valid: true })]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("discards a late Real-Debrid result after its account was removed", () => {
const removedId = "rda_removedAfterCheck";
const lateStatus = { accountId: removedId, provider: "realdebrid" as const, label: "API-Token 1", maskedLogin: "Geschützt", valid: true, isPremium: true, premiumUntilMs: null, message: "Premium aktiv", checkedAt: NOW };
+132
View File
@@ -22,6 +22,7 @@ const SECRET_RETAIN_CASES: Array<{
{ kind: "megadebrid-web", identity: "retain-mega-web@example.test", secret: "fixture-retain-mega-web-3cE5", retained: (settings) => settings.megaDebridWebCredentials === "retain-mega-web@example.test:fixture-retain-mega-web-3cE5" },
{ kind: "bestdebrid-api", identity: "", secret: "fixture-retain-best-4dF6", retained: (settings) => settings.bestToken === "fixture-retain-best-4dF6" },
{ kind: "alldebrid-api", identity: "", secret: "fixture-retain-all-5eG7", retained: (settings) => settings.allDebridToken === "fixture-retain-all-5eG7" },
{ kind: "deepbrid-api", identity: "", secret: "fixture-retain-deepbrid-6fH8", retained: (settings) => settings.deepbridApiKey === "fixture-retain-deepbrid-6fH8" },
{ kind: "ddownload-login", identity: "retain-dd@example.test", secret: "fixture-retain-dd-6fH8", retained: (settings) => settings.ddownloadPassword === "fixture-retain-dd-6fH8" },
{ kind: "onefichier-api", identity: "", secret: "fixture-retain-one-7gJ9", retained: (settings) => settings.oneFichierApiKey === "fixture-retain-one-7gJ9" },
{ kind: "debridlink-api", identity: "", secret: "fixture-retain-dl-8hK1", retained: (settings) => settings.debridLinkApiKeys === "fixture-retain-dl-8hK1" },
@@ -37,6 +38,7 @@ const ACCOUNT_KINDS: RendererAccountKind[] = [
"bestdebrid-web",
"alldebrid-api",
"alldebrid-web",
"deepbrid-api",
"ddownload-login",
"onefichier-api",
"debridlink-api",
@@ -115,6 +117,7 @@ describe("write-only account commands", () => {
debridLinkApiKeys: "fixture-reveal-dl-5eF6",
bestToken: "fixture-reveal-best-7gH8",
allDebridToken: "fixture-reveal-all-9iJ1",
deepbridApiKey: "fixture-reveal-deepbrid-1jK2",
ddownloadLogin: "reveal-dd@example.test",
ddownloadPassword: "fixture-reveal-dd-2kL3",
oneFichierApiKey: "fixture-reveal-one-4mN5",
@@ -134,6 +137,8 @@ describe("write-only account commands", () => {
expect(api.resolveStoredAccountSecret?.(settings, { kind: "debridlink-api", accountId: getDebridLinkApiKeyId("fixture-reveal-dl-5eF6") })).toBe("fixture-reveal-dl-5eF6");
expect(api.resolveStoredAccountSecret?.(settings, { kind: "bestdebrid-api", accountId: "svc-bestdebrid" })).toBe("fixture-reveal-best-7gH8");
expect(api.resolveStoredAccountSecret?.(settings, { kind: "alldebrid-api", accountId: "svc-alldebrid" })).toBe("fixture-reveal-all-9iJ1");
expect(api.resolveStoredAccountSecret?.(settings, { kind: "deepbrid-api", accountId: "svc-deepbrid" })).toBe("fixture-reveal-deepbrid-1jK2");
expect(() => api.resolveStoredAccountSecret?.(settings, { kind: "deepbrid-api", accountId: "svc-realdebrid" })).toThrow(/nicht gefunden/i);
expect(api.resolveStoredAccountSecret?.(settings, { kind: "ddownload-login", accountId: "svc-ddownload" })).toBe("fixture-reveal-dd-2kL3");
expect(api.resolveStoredAccountSecret?.(settings, { kind: "onefichier-api", accountId: "svc-onefichier" })).toBe("fixture-reveal-one-4mN5");
expect(api.resolveStoredAccountSecret?.(settings, { kind: "linksnappy-login", accountId: "svc-linksnappy" })).toBe("fixture-reveal-ls-6pQ7");
@@ -150,6 +155,23 @@ describe("write-only account commands", () => {
});
});
it("accepts an unsaved or stable Deepbrid account and rejects every other account ID for credential checks", () => {
expect(validateAccountCredentialCheckInput({ kind: "deepbrid-api" })).toEqual({
kind: "deepbrid-api",
accountId: undefined,
identity: undefined,
secret: undefined
});
expect(validateAccountCredentialCheckInput({ kind: "deepbrid-api", accountId: "svc-deepbrid" })).toEqual({
kind: "deepbrid-api",
accountId: "svc-deepbrid",
identity: undefined,
secret: undefined
});
expect(() => validateAccountCredentialCheckInput({ kind: "deepbrid-api", accountId: "svc-realdebrid" })).toThrow(/ungültig/i);
expect(() => validateAccountCredentialCheckInput({ kind: "deepbrid-api", accountId: "deepbrid-other" })).toThrow(/ungültig/i);
});
it.each([
["realdebrid-api", "", "fixture-rd-provider-secret-1fA4", "token"],
["realdebrid-web", "", "", "realDebridUseWebLogin"],
@@ -157,6 +179,7 @@ describe("write-only account commands", () => {
["bestdebrid-web", "", "", "bestDebridUseWebLogin"],
["alldebrid-api", "", "fixture-ad-provider-secret-3hC6", "allDebridToken"],
["alldebrid-web", "", "", "allDebridUseWebLogin"],
["deepbrid-api", "", "fixture-deepbrid-provider-secret-4jD7", "deepbridApiKey"],
["ddownload-login", "dd-safe@example.test", "fixture-dd-provider-secret-4jD7", "ddownloadPassword"],
["onefichier-api", "", "fixture-one-provider-secret-5kE8", "oneFichierApiKey"],
["linksnappy-login", "ls-safe@example.test", "fixture-ls-provider-secret-6mF9", "linkSnappyPassword"]
@@ -181,6 +204,115 @@ describe("write-only account commands", () => {
expect(deleted.settings[configuredKey]).toBe(secret ? "" : false);
});
it("manages the single Deepbrid account without exposing or retaining deleted state", () => {
const original = "fixture-deepbrid-original-key-7mN1";
const replacement = "fixture-deepbrid-replacement-key-8pQ2";
const created = applyAccountCommand(defaultSettings(), validateAccountCommand({
action: "create",
kind: "deepbrid-api",
secret: original,
dailyLimitBytes: 2 * GIB
}));
expect(created.response).toEqual({ accountId: "svc-deepbrid" });
expect(created.settings.deepbridApiKey).toBe(original);
expect(created.settings.providerDailyLimitBytes.deepbrid).toBe(2 * GIB);
expect(JSON.stringify(created.response)).not.toContain(original);
expect(() => applyAccountCommand(created.settings, validateAccountCommand({ action: "create", kind: "deepbrid-api", secret: replacement }))).toThrow(/ungültig/i);
const retained = applyAccountCommand(created.settings, validateAccountCommand({
action: "replace",
kind: "deepbrid-api",
accountId: "svc-deepbrid",
secret: "",
dailyLimitBytes: 3 * GIB
}));
expect(retained.settings.deepbridApiKey).toBe(original);
const updated = applyAccountCommand(retained.settings, validateAccountCommand({
action: "update-secret",
kind: "deepbrid-api",
accountId: "svc-deepbrid",
secret: replacement
}));
const populated = {
...updated.settings,
providerDailyUsageBytes: { ...updated.settings.providerDailyUsageBytes, deepbrid: GIB },
providerTotalUsageBytes: { ...updated.settings.providerTotalUsageBytes, deepbrid: 5 * GIB },
debridAccountStatuses: {
...updated.settings.debridAccountStatuses,
"svc-deepbrid": {
accountId: "svc-deepbrid",
provider: "deepbrid" as const,
label: "Deepbrid",
maskedLogin: "••••",
valid: true,
isPremium: true,
premiumUntilMs: null,
message: "OK",
checkedAt: 1
}
}
};
expect(updated.settings.deepbridApiKey).toBe(replacement);
expect(JSON.stringify(updated.response)).not.toContain(replacement);
const deleted = applyAccountCommand(populated, validateAccountCommand({
action: "delete",
kind: "deepbrid-api",
accountId: "svc-deepbrid"
}));
expect(deleted.response).toEqual({ accountId: null });
expect(deleted.settings.deepbridApiKey).toBe("");
expect(deleted.settings.providerDailyLimitBytes).not.toHaveProperty("deepbrid");
expect(deleted.settings.providerDailyUsageBytes).not.toHaveProperty("deepbrid");
expect(deleted.settings.providerTotalUsageBytes).not.toHaveProperty("deepbrid");
expect(deleted.settings.debridAccountStatuses).not.toHaveProperty("svc-deepbrid");
});
it.each([
{ kind: "bestdebrid-api", identity: "", secret: "fixture-status-best-1aB2", provider: "bestdebrid" },
{ kind: "alldebrid-api", identity: "", secret: "fixture-status-all-3cD4", provider: "alldebrid" },
{ kind: "ddownload-login", identity: "status-dd@example.test", secret: "fixture-status-dd-5eF6", provider: "ddownload" },
{ kind: "onefichier-api", identity: "", secret: "fixture-status-one-7gH8", provider: "onefichier" },
{ kind: "linksnappy-login", identity: "status-ls@example.test", secret: "fixture-status-ls-9jK1", provider: "linksnappy" },
{ kind: "deepbrid-api", identity: "", secret: "fixture-status-deepbrid-2mN3", provider: "deepbrid" }
] as const)("deletes only the selected $provider single-account status", ({ kind, identity, secret, provider }) => {
const created = applyAccountCommand(defaultSettings(), validateAccountCommand({ action: "create", kind, identity, secret }));
const accountId = created.response.accountId!;
const foreignStatus = {
accountId: "foreign-account",
provider: "realdebrid" as const,
label: "Foreign",
maskedLogin: "fo***gn",
valid: true,
isPremium: true,
premiumUntilMs: null,
message: "OK",
checkedAt: 2
};
const selectedStatus = {
accountId,
provider,
label: provider,
maskedLogin: "se***ed",
valid: true,
isPremium: true,
premiumUntilMs: null,
message: "OK",
checkedAt: 1
};
const deleted = applyAccountCommand({
...created.settings,
debridAccountStatuses: {
[accountId]: selectedStatus,
"foreign-account": foreignStatus
}
}, validateAccountCommand({ action: "delete", kind, accountId }));
expect(deleted.settings.debridAccountStatuses).toEqual({ "foreign-account": foreignStatus });
});
it("creates an account without returning submitted secrets", () => {
const command = validateAccountCommand({
action: "create",
+19
View File
@@ -112,4 +112,23 @@ describe("renderer-safe account editing", () => {
accountId: target.type === "mega" ? target.accountId : ""
});
});
it("uses the synthetic Deepbrid account id for edit, reveal and delete", () => {
const renderer = createRendererState({ ...defaultSettings(), deepbridApiKey: "synthetic-deepbrid-key" });
const target: AccountEditTarget = {
type: "single",
rowKey: "svc-deepbrid",
kind: "deepbrid-api",
service: "deepbrid",
provider: "deepbrid"
};
const edit = createAccountEditState(target, renderer.accounts);
expect(buildAccountReplaceCommand({ ...edit, token: "replacement-deepbrid-key" })).toEqual(expect.objectContaining({
kind: "deepbrid-api",
accountId: "svc-deepbrid",
secret: "replacement-deepbrid-key"
}));
expect(buildAccountDeleteCommand(target).accountId).toBe("svc-deepbrid");
});
});
+5
View File
@@ -9,6 +9,7 @@ const services = [
"megadebrid-web",
"bestdebrid",
"alldebrid",
"deepbrid",
"ddownload",
"onefichier",
"debridlink",
@@ -31,4 +32,8 @@ describe("account service icons", () => {
it("uses the same Mega-Debrid icon for API and Web accounts", () => {
expect(ACCOUNT_SERVICE_ICONS["megadebrid-api"]).toBe(ACCOUNT_SERVICE_ICONS["megadebrid-web"]);
});
it("uses the bundled Deepbrid provider icon", () => {
expect(ACCOUNT_SERVICE_ICONS.deepbrid).toBe("./provider-icons/deepbrid.png");
});
});
+258
View File
@@ -0,0 +1,258 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AppController } from "../src/main/app-controller";
import { defaultSettings } from "../src/main/constants";
import { configureCredentialProtector } from "../src/main/credential-protection";
import { prepareDailyStartSettingsPatch } from "../src/main/daily-start-scheduler";
import { createStoragePaths, emptySession } from "../src/main/storage";
import type { AppSettings, SessionState } from "../src/shared/types";
const appState = vi.hoisted(() => ({ userDataDir: "C:\\MDD\\Test" }));
const bootStorage = vi.hoisted(() => ({
settings: null as unknown,
loadResult: null as unknown
}));
vi.mock("electron", () => ({
app: {
getPath: (name: string) => name === "desktop" ? path.join(appState.userDataDir, "Desktop") : appState.userDataDir
},
BrowserWindow: class {},
clipboard: {},
dialog: {},
ipcMain: { handle: vi.fn(), on: vi.fn() },
Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() },
safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() },
shell: {},
Tray: class {}
}));
vi.mock("../src/main/debug-server", async () => {
const actual = await vi.importActual<typeof import("../src/main/debug-server")>("../src/main/debug-server");
return {
...actual,
startDebugServer: vi.fn(),
stopDebugServer: vi.fn()
};
});
vi.mock("../src/main/storage", async () => {
const actual = await vi.importActual<typeof import("../src/main/storage")>("../src/main/storage");
return {
...actual,
loadSettings: (...args: Parameters<typeof actual.loadSettings>) => bootStorage.settings
? bootStorage.settings as AppSettings
: actual.loadSettings(...args),
loadSessionWithStatus: (...args: Parameters<typeof actual.loadSessionWithStatus>) => bootStorage.loadResult
? bootStorage.loadResult as ReturnType<typeof actual.loadSessionWithStatus>
: actual.loadSessionWithStatus(...args)
};
});
const tempDirs: string[] = [];
const liveControllers: AppController[] = [];
function createController(settings: AppSettings): AppController {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-controller-"));
tempDirs.push(dir);
const controller = Object.create(AppController.prototype) as any;
controller.settings = settings;
controller.storagePaths = createStoragePaths(dir);
controller.manager = { setSettings: vi.fn() };
controller.audit = vi.fn();
controller.overlayLiveUsageCounters = vi.fn();
controller.pruneRealDebridWebFallbacks = vi.fn();
controller.realDebridWebFallbacks = new Map();
return controller as AppController;
}
function queuedSession(): SessionState {
return {
...emptySession(),
packageOrder: ["pkg"],
packages: {
pkg: {
id: "pkg",
name: "Daily queue",
outputDir: "C:\\Downloads",
extractDir: "C:\\Downloads",
status: "queued",
itemIds: ["item"],
cancelled: false,
enabled: true,
createdAt: 1,
updatedAt: 1
}
},
items: {
item: {
id: "item",
packageId: "pkg",
url: "https://example.test/file",
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: "file.bin",
targetPath: "C:\\Downloads\\file.bin",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "",
createdAt: 1,
updatedAt: 1
}
}
};
}
function tomorrowLocalDate(): string {
const now = new Date();
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 12, 0, 0, 0);
return `${tomorrow.getFullYear().toString().padStart(4, "0")}-${(tomorrow.getMonth() + 1).toString().padStart(2, "0")}-${tomorrow.getDate().toString().padStart(2, "0")}`;
}
afterEach(async () => {
for (const controller of liveControllers.splice(0)) {
await controller.shutdown();
}
bootStorage.settings = null;
bootStorage.loadResult = null;
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("AppController daily start settings", () => {
it("does not clear a legacy one-time schedule for an unprepared daily settings patch", () => {
configureCredentialProtector({
isEncryptionAvailable: () => false,
encryptString: (value) => Buffer.from(value, "utf8"),
decryptString: (value) => Buffer.from(value).toString("utf8")
});
const scheduledStartEpochMs = 1_800_000_000_000;
const controller = createController({
...defaultSettings(),
scheduledStartEpochMs
});
const updated = controller.updateSettings({
dailyStartEnabled: true,
dailyStartMinuteOfDay: 18 * 60 + 45,
dailyStartFirstLocalDate: "2026-08-23"
});
expect(updated.scheduledStartEpochMs).toBe(scheduledStartEpochMs);
});
it("clears a legacy one-time schedule when an explicit daily rule is saved", () => {
configureCredentialProtector({
isEncryptionAvailable: () => false,
encryptString: (value) => Buffer.from(value, "utf8"),
decryptString: (value) => Buffer.from(value).toString("utf8")
});
const controller = createController({
...defaultSettings(),
scheduledStartEpochMs: 1_800_000_000_000
});
const updated = controller.updateSettings(prepareDailyStartSettingsPatch({
dailyStartEnabled: true,
dailyStartMinuteOfDay: 18 * 60 + 45,
dailyStartFirstLocalDate: "2026-08-23"
}, controller.getSettings()));
expect(updated.scheduledStartEpochMs).toBe(0);
expect(updated).toMatchObject({
dailyStartEnabled: true,
dailyStartMinuteOfDay: 18 * 60 + 45,
dailyStartFirstLocalDate: "2026-08-23"
});
});
it("preserves a legacy one-time schedule when an account mutation saves a complete settings state", async () => {
configureCredentialProtector({
isEncryptionAvailable: () => false,
encryptString: (value) => Buffer.from(value, "utf8"),
decryptString: (value) => Buffer.from(value).toString("utf8")
});
const scheduledStartEpochMs = 1_800_000_000_000;
const controller = createController({
...defaultSettings(),
ddownloadLogin: "account@example.test",
ddownloadPassword: "secret",
scheduledStartEpochMs
});
await controller.executeAccountCommand({
action: "delete",
kind: "ddownload-login",
accountId: "svc-ddownload"
});
expect(controller.getSettings().scheduledStartEpochMs).toBe(scheduledStartEpochMs);
});
it("preserves a legacy one-time schedule when a complete unchanged settings payload is prepared", () => {
configureCredentialProtector({
isEncryptionAvailable: () => false,
encryptString: (value) => Buffer.from(value, "utf8"),
decryptString: (value) => Buffer.from(value).toString("utf8")
});
const scheduledStartEpochMs = 1_800_000_000_000;
const controller = createController({
...defaultSettings(),
dailyStartEnabled: false,
dailyStartMinuteOfDay: 18 * 60 + 45,
dailyStartFirstLocalDate: "2026-08-23",
scheduledStartEpochMs
});
const current = controller.getSettings();
const updated = controller.updateSettings(prepareDailyStartSettingsPatch({ ...current }, current));
expect(updated.scheduledStartEpochMs).toBe(scheduledStartEpochMs);
});
});
describe("AppController boot auto-resume", () => {
it.each([
{ wasRunning: false, expectedAutoResume: false },
{ wasRunning: true, expectedAutoResume: true }
])("uses persisted running evidence when a daily start is still in the future", async ({ wasRunning, expectedAutoResume }) => {
configureCredentialProtector({
isEncryptionAvailable: () => false,
encryptString: (value) => Buffer.from(value, "utf8"),
decryptString: (value) => Buffer.from(value).toString("utf8")
});
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-controller-boot-"));
tempDirs.push(root);
appState.userDataDir = root;
bootStorage.settings = {
...defaultSettings(),
token: "token",
autoResumeOnStart: true,
dailyStartEnabled: true,
dailyStartMinuteOfDay: 0,
dailyStartFirstLocalDate: tomorrowLocalDate()
} satisfies AppSettings;
bootStorage.loadResult = {
session: queuedSession(),
status: "ok",
wasRunning
};
const controller = new AppController();
liveControllers.push(controller);
const beginAutoResume = vi.spyOn(controller as any, "beginAutoResume").mockImplementation(() => {});
controller.onState = vi.fn();
expect(beginAutoResume).toHaveBeenCalledTimes(expectedAutoResume ? 1 : 0);
});
});
+17 -3
View File
@@ -1,11 +1,12 @@
import { describe, expect, it } from "vitest";
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
import { decryptBackup, encryptBackup } from "../src/main/backup-crypto";
import { readFileSync } from "node:fs";
import type { AppSettings, SessionState, HistoryEntry, StatisticsLedger } from "../src/shared/types";
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings;
}
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl", ...overrides } as unknown as AppSettings;
}
const session: SessionState = {
version: 2, packageOrder: ["p1"], packages: { p1: {} as never }, items: { i1: {} as never },
@@ -18,6 +19,19 @@ const statistics: StatisticsLedger = { version: 2, startedAt: 1, days: [], minut
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history, statistics };
describe("buildBackupPayload — default is settings-only", () => {
it("keeps the Deepbrid key only inside the encrypted backup payload", () => {
const key = "fixture-deepbrid-backup-key-4eF6";
const payload = buildBackupPayload({
...baseInput,
settings: settings({ deepbridApiKey: key })
});
const encrypted = encryptBackup(JSON.stringify(payload), "fixture-backup-passphrase-5gH7");
expect(encrypted.toString("utf8")).not.toContain(key);
const restored = JSON.parse(decryptBackup(encrypted, "fixture-backup-passphrase-5gH7")) as { settings: AppSettings };
expect(restored.settings.deepbridApiKey).toBe(key);
});
it("exports the full statistics ledger instead of the renderer projection", () => {
const source = readFileSync(new URL("../src/main/app-controller.ts", import.meta.url), "utf8");
const exportBlock = source.slice(source.indexOf("public exportBackup"), source.indexOf("public async exportOnlineBackup"));
+26
View File
@@ -5,6 +5,7 @@ import { collectAccountStatusRedactionValues, sanitizeAccountStatusText } from "
import {
configureCredentialProtector,
CredentialProtector,
needsPersistedSettingsRewrite,
protectPersistedSettings,
projectSettingsForRenderer,
restorePersistedSettings
@@ -52,6 +53,31 @@ describe("credential protection", () => {
});
});
it("protects, restores, clears and redacts the Deepbrid API key", () => {
const key = "fixture-deepbrid-protection-key-3cD5";
const input = { ...defaultSettings(), rememberToken: true, deepbridApiKey: key };
const persisted = protectPersistedSettings(input);
expect(JSON.stringify(persisted)).not.toContain(key);
expect(restorePersistedSettings(persisted).deepbridApiKey).toBe(key);
expect(projectSettingsForRenderer(input).deepbridApiKey).toBe("••••••••");
expect(protectPersistedSettings({ ...input, rememberToken: false }).deepbridApiKey).toBe("");
const redactions = collectAccountStatusRedactionValues(input);
expect(sanitizeAccountStatusText(`Deepbrid api_key=${key}`, redactions)).not.toContain(key);
});
it("requires a persisted settings rewrite only while the Deepbrid key is unprotected", () => {
const input = {
...defaultSettings(),
rememberToken: true,
deepbridApiKey: "fixture-deepbrid-rewrite-key-6rS7"
};
expect(needsPersistedSettingsRewrite(input)).toBe(true);
expect(needsPersistedSettingsRewrite(protectPersistedSettings(input))).toBe(false);
});
it("accepts plaintext values once so existing settings can be migrated", () => {
const input = {
...defaultSettings(),
+416
View File
@@ -0,0 +1,416 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, describe, expect, it, vi } from "vitest";
import type { AppSettings, DailyStartSettings } from "../src/shared/types";
import { DailyStartScheduler, nextDailyStartEpochMs, prepareDailyStartSettingsPatch, shouldDeferAutoResumeToDailyStart } from "../src/main/daily-start-scheduler";
import { defaultSettings } from "../src/main/constants";
import { configureCredentialProtector } from "../src/main/credential-protection";
import { createStoragePaths, loadSettings, saveSettings } from "../src/main/storage";
const originalTimezone = process.env.TZ;
process.env.TZ = "Europe/Berlin";
afterAll(() => {
if (originalTimezone === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTimezone;
}
});
function settings(overrides: Partial<DailyStartSettings> = {}): DailyStartSettings {
return {
dailyStartEnabled: true,
dailyStartMinuteOfDay: 10 * 60,
dailyStartFirstLocalDate: "2026-08-22",
dailyStartLastHandledLocalDate: "",
dailyStartPendingLocalDate: "",
dailyStartLastOutcome: "",
...overrides
};
}
class FakeDailyStartController {
public settings: DailyStartSettings;
public running = false;
public paused = false;
public canStart = true;
public items: Record<string, { status: string; packageId: string }> = { queued: { status: "queued", packageId: "package" } };
public packages: Record<string, { enabled: boolean; cancelled: boolean }> = {
package: { enabled: true, cancelled: false }
};
public events: string[] = [];
public start = vi.fn(async () => {
this.events.push("start");
});
public constructor(
value: DailyStartSettings,
private readonly persist?: (value: DailyStartSettings) => void
) {
this.settings = value;
}
public getSnapshot() {
return {
settings: this.settings,
session: {
running: this.running,
paused: this.paused,
items: this.items,
packages: this.packages
},
canStart: this.canStart
};
}
public updateSettings(partial: Partial<DailyStartSettings>): DailyStartSettings {
Object.assign(this.settings, partial);
if (partial.dailyStartPendingLocalDate) {
this.events.push(`pending:${partial.dailyStartPendingLocalDate}`);
}
if (partial.dailyStartLastHandledLocalDate) {
this.events.push(`handled:${partial.dailyStartLastHandledLocalDate}`);
}
this.persist?.(this.settings);
return this.settings;
}
}
describe("daily start scheduler", () => {
it("keeps today's future and past targets on the local calendar date", () => {
const futureNow = new Date(2026, 7, 22, 9, 15).getTime();
const pastNow = new Date(2026, 7, 22, 12, 30).getTime();
const expected = new Date(2026, 7, 22, 10, 0, 0, 0).getTime();
expect(nextDailyStartEpochMs(settings(), futureNow)).toBe(expected);
expect(nextDailyStartEpochMs(settings(), pastNow)).toBe(expected);
});
it("uses tomorrow as the first eligible local date", () => {
const now = new Date(2026, 7, 22, 8, 0).getTime();
expect(nextDailyStartEpochMs(settings({ dailyStartFirstLocalDate: "2026-08-23" }), now))
.toBe(new Date(2026, 7, 23, 10, 0, 0, 0).getTime());
});
it("dispatches once on each of five eligible local days", async () => {
let now = new Date(2026, 7, 22, 10, 5).getTime();
const controller = new FakeDailyStartController(settings());
const scheduler = new DailyStartScheduler(controller, () => now);
for (let day = 22; day <= 26; day += 1) {
now = new Date(2026, 7, day, 10, 5).getTime();
await scheduler.reconcile();
}
expect(controller.start).toHaveBeenCalledTimes(5);
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-26");
});
it("persists pending before dispatch and coalesces simultaneous reconciles", async () => {
const now = new Date(2026, 7, 22, 10, 5).getTime();
const controller = new FakeDailyStartController(settings());
const scheduler = new DailyStartScheduler(controller, () => now);
await Promise.all([scheduler.reconcile(), scheduler.reconcile()]);
expect(controller.start).toHaveBeenCalledTimes(1);
expect(controller.events).toEqual([
"pending:2026-08-22",
"start",
"handled:2026-08-22"
]);
expect(controller.settings.dailyStartPendingLocalDate).toBe("");
expect(controller.settings.dailyStartLastOutcome).toBe("started");
});
it("catches an overdue target after a clock jump without dispatching twice", async () => {
let now = new Date(2026, 7, 22, 9, 55).getTime();
const controller = new FakeDailyStartController(settings());
const scheduler = new DailyStartScheduler(controller, () => now);
expect(await scheduler.reconcile()).toBeNull();
now = new Date(2026, 7, 22, 11, 10).getTime();
expect(await scheduler.reconcile()).toBe("started");
expect(await scheduler.reconcile()).toBeNull();
expect(controller.start).toHaveBeenCalledTimes(1);
});
it("does not redispatch when the local calendar moves behind a handled or pending day", async () => {
const now = new Date(2026, 7, 22, 11, 0).getTime();
const handledController = new FakeDailyStartController(settings({
dailyStartLastHandledLocalDate: "2026-08-23"
}));
const pendingController = new FakeDailyStartController(settings({
dailyStartPendingLocalDate: "2026-08-23"
}));
expect(await new DailyStartScheduler(handledController, () => now).reconcile()).toBeNull();
expect(await new DailyStartScheduler(pendingController, () => now).reconcile()).toBeNull();
expect(handledController.start).not.toHaveBeenCalled();
expect(pendingController.start).not.toHaveBeenCalled();
expect(handledController.settings.dailyStartLastHandledLocalDate).toBe("2026-08-23");
expect(pendingController.settings.dailyStartPendingLocalDate).toBe("2026-08-23");
expect(nextDailyStartEpochMs(handledController.settings, now)).toBe(new Date(2026, 7, 24, 10, 0).getTime());
expect(nextDailyStartEpochMs(pendingController.settings, now)).toBe(new Date(2026, 7, 23, 10, 0).getTime());
});
it("does not regress last handled when an older pending receipt expires", async () => {
const now = new Date(2026, 7, 23, 9, 0).getTime();
const controller = new FakeDailyStartController(settings({
dailyStartLastHandledLocalDate: "2026-08-22",
dailyStartPendingLocalDate: "2026-08-21"
}));
expect(await new DailyStartScheduler(controller, () => now).reconcile()).toBe("missed");
expect(controller.start).not.toHaveBeenCalled();
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
expect(controller.settings.dailyStartPendingLocalDate).toBe("");
});
it("treats repeated suspend and resume reconciles as one daily dispatch", async () => {
const now = new Date(2026, 7, 22, 10, 5).getTime();
const controller = new FakeDailyStartController(settings());
const scheduler = new DailyStartScheduler(controller, () => now);
await scheduler.reconcile();
await scheduler.reconcile();
await scheduler.reconcile();
expect(controller.start).toHaveBeenCalledTimes(1);
});
it("finishes a persisted in-flight receipt after restart without redispatching", async () => {
const now = new Date(2026, 7, 22, 10, 5).getTime();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-daily-restart-"));
const paths = createStoragePaths(dir);
configureCredentialProtector({
isEncryptionAvailable: () => false,
encryptString: (value) => Buffer.from(value, "utf8"),
decryptString: (value) => Buffer.from(value).toString("utf8")
});
saveSettings(paths, {
...defaultSettings(),
...settings({
dailyStartPendingLocalDate: "2026-08-22",
dailyStartLastOutcome: "start_failed"
})
});
try {
const restartedController = new FakeDailyStartController(
loadSettings(paths),
(value) => saveSettings(paths, value as AppSettings)
);
await new DailyStartScheduler(restartedController, () => now).reconcile();
expect(restartedController.start).not.toHaveBeenCalled();
expect(restartedController.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
expect(restartedController.settings.dailyStartPendingLocalDate).toBe("");
expect(restartedController.settings.dailyStartLastOutcome).toBe("start_failed");
const secondRestartController = new FakeDailyStartController(loadSettings(paths));
await new DailyStartScheduler(secondRestartController, () => now).reconcile();
expect(secondRestartController.start).not.toHaveBeenCalled();
expect(secondRestartController.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
expect(secondRestartController.settings.dailyStartPendingLocalDate).toBe("");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("marks an empty queue handled without calling start", async () => {
const now = new Date(2026, 7, 22, 10, 5).getTime();
const controller = new FakeDailyStartController(settings());
controller.items = {};
expect(await new DailyStartScheduler(controller, () => now).reconcile()).toBe("empty_queue");
expect(controller.start).not.toHaveBeenCalled();
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
expect(controller.settings.dailyStartLastOutcome).toBe("empty_queue");
});
it("treats queued items in disabled packages as an empty startable queue", async () => {
const now = new Date(2026, 7, 22, 10, 5).getTime();
const controller = new FakeDailyStartController(settings());
controller.packages.package.enabled = false;
expect(await new DailyStartScheduler(controller, () => now).reconcile()).toBe("empty_queue");
expect(controller.start).not.toHaveBeenCalled();
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
expect(controller.settings.dailyStartLastOutcome).toBe("empty_queue");
});
it("keeps a missing-account occurrence retryable without creating a dispatch receipt", async () => {
const now = new Date(2026, 7, 22, 10, 5).getTime();
const controller = new FakeDailyStartController(settings());
controller.canStart = false;
const scheduler = new DailyStartScheduler(controller, () => now);
expect(await scheduler.reconcile()).toBe("missing_account");
expect(controller.start).not.toHaveBeenCalled();
expect(controller.settings.dailyStartPendingLocalDate).toBe("");
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("");
expect(controller.settings.dailyStartLastOutcome).toBe("missing_account");
controller.canStart = true;
expect(await scheduler.reconcile()).toBe("started");
expect(controller.start).toHaveBeenCalledTimes(1);
});
it("treats running and paused sessions as already active", async () => {
let now = new Date(2026, 7, 22, 10, 5).getTime();
const controller = new FakeDailyStartController(settings());
controller.running = true;
const scheduler = new DailyStartScheduler(controller, () => now);
expect(await scheduler.reconcile()).toBe("already_active");
controller.running = false;
controller.paused = true;
now = new Date(2026, 7, 23, 10, 5).getTime();
expect(await scheduler.reconcile()).toBe("already_active");
expect(controller.start).not.toHaveBeenCalled();
});
it("records a stale pending day as missed without disabling future days", async () => {
const now = new Date(2026, 7, 23, 9, 0).getTime();
const controller = new FakeDailyStartController(settings({ dailyStartPendingLocalDate: "2026-08-22" }));
const scheduler = new DailyStartScheduler(controller, () => now);
expect(await scheduler.reconcile()).toBe("missed");
expect(controller.settings.dailyStartEnabled).toBe(true);
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
expect(controller.settings.dailyStartLastOutcome).toBe("missed");
expect(nextDailyStartEpochMs(controller.settings, now)).toBe(new Date(2026, 7, 23, 10, 0).getTime());
});
it("clears failed dispatch receipts for a later retry", async () => {
const now = new Date(2026, 7, 22, 10, 5).getTime();
const controller = new FakeDailyStartController(settings());
controller.start.mockRejectedValueOnce(new Error("start rejected"));
const scheduler = new DailyStartScheduler(controller, () => now);
await expect(scheduler.reconcile()).rejects.toThrow("start rejected");
expect(controller.settings.dailyStartPendingLocalDate).toBe("");
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("");
expect(controller.settings.dailyStartLastOutcome).toBe("start_failed");
await scheduler.reconcile();
expect(controller.start).toHaveBeenCalledTimes(2);
});
it("keeps an in-flight receipt across shutdown and does not redispatch it after restart", async () => {
const now = new Date(2026, 7, 22, 10, 5).getTime();
const controller = new FakeDailyStartController(settings());
let resolveStart!: () => void;
controller.start.mockImplementationOnce(() => new Promise<void>((resolve) => {
resolveStart = resolve;
}));
const scheduler = new DailyStartScheduler(controller, () => now);
const reconcile = scheduler.reconcile();
expect(controller.start).toHaveBeenCalledTimes(1);
expect(controller.settings.dailyStartPendingLocalDate).toBe("2026-08-22");
scheduler.end();
resolveStart();
expect(await reconcile).toBeNull();
expect(controller.settings.dailyStartPendingLocalDate).toBe("2026-08-22");
expect(controller.settings.dailyStartLastHandledLocalDate).toBe("");
expect(controller.settings.dailyStartLastOutcome).toBe("start_failed");
const restartedController = new FakeDailyStartController({ ...controller.settings });
expect(await new DailyStartScheduler(restartedController, () => now).reconcile()).toBe("start_failed");
expect(restartedController.start).not.toHaveBeenCalled();
expect(restartedController.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22");
expect(restartedController.settings.dailyStartPendingLocalDate).toBe("");
});
it("constructs each DST target from local calendar fields instead of adding 24 hours", () => {
const beforeDst = settings({
dailyStartMinuteOfDay: 2 * 60 + 30,
dailyStartFirstLocalDate: "2026-03-29"
});
const firstNow = new Date(2026, 2, 28, 12, 0).getTime();
const firstTarget = nextDailyStartEpochMs(beforeDst, firstNow);
const nextTarget = nextDailyStartEpochMs({
...beforeDst,
dailyStartLastHandledLocalDate: "2026-03-29"
}, new Date(2026, 2, 29, 12, 0).getTime());
expect(firstTarget).toBe(new Date(2026, 2, 29, 2, 30, 0, 0).getTime());
expect(nextTarget).toBe(new Date(2026, 2, 30, 2, 30, 0, 0).getTime());
expect(nextTarget - firstTarget).toBe(23 * 60 * 60 * 1000);
});
it("clears a legacy one-time target only when a daily rule value changes", () => {
const current = settings({
dailyStartEnabled: false,
dailyStartMinuteOfDay: 600,
dailyStartFirstLocalDate: "2026-08-22"
});
expect(prepareDailyStartSettingsPatch({
dailyStartEnabled: true,
dailyStartMinuteOfDay: 600,
dailyStartFirstLocalDate: "2026-08-22"
}, current)).toEqual({
dailyStartEnabled: true,
dailyStartMinuteOfDay: 600,
dailyStartFirstLocalDate: "2026-08-22",
scheduledStartEpochMs: 0
});
expect(prepareDailyStartSettingsPatch({ dailyStartPendingLocalDate: "2026-08-22" }, current))
.toEqual({ dailyStartPendingLocalDate: "2026-08-22" });
expect(prepareDailyStartSettingsPatch({
dailyStartEnabled: false,
dailyStartMinuteOfDay: 600,
dailyStartFirstLocalDate: "2026-08-22",
scheduledStartEpochMs: 1_800_000_000_000
}, current)).toEqual({
dailyStartEnabled: false,
dailyStartMinuteOfDay: 600,
dailyStartFirstLocalDate: "2026-08-22",
scheduledStartEpochMs: 1_800_000_000_000
});
});
it("defers boot auto-resume for a future daily target unless the saved run was active", () => {
const now = new Date(2026, 7, 22, 9, 0).getTime();
const future = settings({ dailyStartMinuteOfDay: 10 * 60 });
expect(shouldDeferAutoResumeToDailyStart(future, false, now)).toBe(true);
expect(shouldDeferAutoResumeToDailyStart(future, true, now)).toBe(false);
expect(shouldDeferAutoResumeToDailyStart({ ...future, dailyStartEnabled: false }, false, now)).toBe(false);
expect(shouldDeferAutoResumeToDailyStart({ ...future, dailyStartMinuteOfDay: 8 * 60 }, false, now)).toBe(false);
});
it("reconciles at boot and at most sixty seconds after a target becomes due", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 7, 22, 9, 59, 30));
try {
const controller = new FakeDailyStartController(settings());
const scheduler = new DailyStartScheduler(controller);
scheduler.begin();
await vi.advanceTimersByTimeAsync(0);
expect(controller.start).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(60_000);
expect(controller.start).toHaveBeenCalledTimes(1);
scheduler.end();
await vi.advanceTimersByTimeAsync(120_000);
expect(controller.start).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
+205
View File
@@ -3109,6 +3109,211 @@ describe("Real-Debrid account rotation", () => {
});
});
describe("Deepbrid provider chain", () => {
const sourceLink = "https://hoster.example/files/synthetic-source";
function deepbridSuccessResponse(): Response {
return new Response(JSON.stringify({
error: 0,
filename: "deepbrid-file.bin",
link: "https://download.example/deepbrid-file.bin?signature=synthetic",
size: "2 KB"
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
function allDebridSuccessResponse(): Response {
return new Response(JSON.stringify({
status: "success",
data: {
link: "https://alldebrid.example/fallback.bin",
filename: "fallback.bin",
filesize: 4096
}
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
function deepbridSettings(overrides: Partial<ReturnType<typeof defaultSettings>> = {}) {
return {
...defaultSettings(),
deepbridApiKey: "synthetic-deepbrid-provider-key",
providerOrder: ["deepbrid"] as const,
providerPrimary: "deepbrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false,
...overrides
};
}
it("uses Deepbrid with stable source attribution", async () => {
globalThis.fetch = vi.fn(async () => deepbridSuccessResponse()) as unknown as typeof fetch;
const result = await new DebridService(deepbridSettings()).unrestrictLink(sourceLink);
expect(result).toMatchObject({
provider: "deepbrid",
providerLabel: "Deepbrid (API)",
sourceLabel: "API",
sourceAccountId: "svc-deepbrid",
sourceAccountLabel: "Deepbrid API",
fileName: "deepbrid-file.bin",
fileSize: 2048
});
});
it("uses Deepbrid through hoster routing before the configured provider order", async () => {
const calledUrls: string[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
calledUrls.push(url);
return url.includes("deepbrid.com/api/v1/generate/link") ? deepbridSuccessResponse() : allDebridSuccessResponse();
}) as unknown as typeof fetch;
const settings = deepbridSettings({
allDebridToken: "synthetic-alldebrid-token",
providerOrder: ["alldebrid"] as const,
providerPrimary: "alldebrid",
autoProviderFallback: true,
hosterRouting: { rapidgator: "deepbrid" }
});
const result = await new DebridService(settings).unrestrictLink("https://rapidgator.net/file/synthetic/routed.bin.html");
expect(result.provider).toBe("deepbrid");
expect(calledUrls).toHaveLength(1);
expect(calledUrls[0]).toContain("deepbrid.com/api/v1/generate/link");
});
it.each([
["auth", 401],
["link", 400],
["rate_limit", 429],
["temporary", 503]
])("falls back from Deepbrid %s errors when automatic fallback is enabled", async (_classification, status) => {
let allDebridCalls = 0;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("deepbrid.com/api/v1/generate/link")) {
return new Response(JSON.stringify({ error: status, message: "synthetic failure" }), {
status,
headers: { "Content-Type": "application/json", "Retry-After": "0" }
});
}
allDebridCalls += 1;
return allDebridSuccessResponse();
}) as unknown as typeof fetch;
const settings = deepbridSettings({
allDebridToken: "synthetic-alldebrid-token",
providerOrder: ["deepbrid", "alldebrid"] as const,
providerSecondary: "alldebrid",
autoProviderFallback: true
});
const result = await new DebridService(settings).unrestrictLink(sourceLink);
expect(result.provider).toBe("alldebrid");
expect(allDebridCalls).toBe(1);
});
it.each([
["auth", 401],
["link", 400],
["rate_limit", 429],
["temporary", 503]
])("keeps Deepbrid %s errors assigned to Deepbrid when fallback is disabled", async (classification, status) => {
let allDebridCalls = 0;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("deepbrid.com/api/v1/generate/link")) {
return new Response(JSON.stringify({ error: status, message: "synthetic failure" }), {
status,
headers: { "Content-Type": "application/json", "Retry-After": "0" }
});
}
allDebridCalls += 1;
return allDebridSuccessResponse();
}) as unknown as typeof fetch;
const settings = deepbridSettings({
allDebridToken: "synthetic-alldebrid-token",
providerOrder: ["deepbrid", "alldebrid"] as const,
providerSecondary: "alldebrid"
});
const error = await new DebridService(settings).unrestrictLink(sourceLink).then(() => null, (value: unknown) => value);
const message = String(error);
expect(message).toContain("Deepbrid");
expect(message).toContain(classification);
expect(message).not.toContain("synthetic-deepbrid-provider-key");
expect(message).not.toContain(sourceLink);
expect(message).not.toContain("download.example");
expect(allDebridCalls).toBe(0);
});
it("skips daily-limited Deepbrid and uses the next configured provider", async () => {
const calledUrls: string[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
calledUrls.push(url);
return allDebridSuccessResponse();
}) as unknown as typeof fetch;
const settings = deepbridSettings({
allDebridToken: "synthetic-alldebrid-token",
providerOrder: ["deepbrid", "alldebrid"] as const,
providerSecondary: "alldebrid",
autoProviderFallback: true,
providerDailyLimitBytes: { deepbrid: 100 },
providerDailyUsageBytes: { deepbrid: 100 },
providerDailyUsageDay: getProviderUsageDayKey()
});
const result = await new DebridService(settings).unrestrictLink(sourceLink);
expect(result.provider).toBe("alldebrid");
expect(calledUrls.some((url) => url.includes("deepbrid.com"))).toBe(false);
});
it("keeps configured 1Fichier and DDownload links on their direct special paths", async () => {
const calledUrls: string[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
calledUrls.push(url);
if (url.includes("api.1fichier.com")) {
return new Response(JSON.stringify({ url: "https://onefichier-cdn.example/file.bin" }), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
if (url.endsWith("/login.html")) {
return new Response('<input name="token" value="synthetic-login-token">', {
status: 200,
headers: { "Set-Cookie": "page=synthetic; Path=/" }
});
}
if (url === "https://ddownload.com/" && init?.method === "POST") {
return new Response("", { status: 200, headers: { "Set-Cookie": "xfss=synthetic-session; Path=/" } });
}
if (url.endsWith("/abcdefgh")) {
return new Response("", { status: 302, headers: { Location: "https://ddownload-cdn.example/file.bin" } });
}
return deepbridSuccessResponse();
}) as unknown as typeof fetch;
const settings = deepbridSettings({
oneFichierApiKey: "synthetic-onefichier-key",
ddownloadLogin: "synthetic-user",
ddownloadPassword: "synthetic-password",
autoProviderFallback: true
});
const service = new DebridService(settings);
const oneFichier = await service.unrestrictLink("https://1fichier.com/?abc12345xyz");
const ddownload = await service.unrestrictLink("https://ddownload.com/abcdefgh/file.bin");
expect(oneFichier.provider).toBe("onefichier");
expect(ddownload.provider).toBe("ddownload");
expect(calledUrls.some((url) => url.includes("deepbrid.com"))).toBe(false);
});
});
describe("filenameFromRapidgatorUrlPath", () => {
it("extracts filename from standard rapidgator URL", () => {
expect(filenameFromRapidgatorUrlPath("https://rapidgator.net/file/abc123/Show.S01E01.part01.rar.html"))
+59 -9
View File
@@ -53,7 +53,8 @@ import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/ma
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 { DownloadManager } from "../src/main/download-manager";
import type { NotificationSupportPayload } from "../src/main/support-data";
import type { UiSnapshot } from "../src/shared/types";
const tempDirs: string[] = [];
@@ -326,15 +327,25 @@ async function createFixture() {
getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null
} as unknown as DownloadManager;
startDebugServer(manager, baseDir);
const notificationStatus: NotificationSupportPayload & Record<string, unknown> = {
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "no_data",
incidentAgeMs: 30_000,
events: [{ payload: "PRIVATE_DEBUG_EVENT_PAYLOAD" }],
url: "https://private.example.test/webhook",
mention: "@private"
};
startDebugServer(manager, baseDir, () => notificationStatus);
const baseUrl = `http://127.0.0.1:${port}`;
await waitForReady(`${baseUrl}/health`);
await new Promise((resolve) => setTimeout(resolve, 300));
return {
baseUrl,
token,
baseDir
return {
baseUrl,
token,
baseDir,
notificationStatus
};
}
@@ -359,6 +370,36 @@ afterEach(() => {
});
describe("debug-server", () => {
it("serves the exact safe notification DTO in its endpoint and diagnostics", async () => {
const fixture = await createFixture();
const response = await authedFetch(`${fixture.baseUrl}/notifications`, fixture.token);
expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, unknown>;
expect(payload).toEqual({
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "no_data",
incidentAgeMs: 30_000
});
fixture.notificationStatus.queued = 9;
fixture.notificationStatus.lastSuccessAt = 1_700_000_060_000;
const currentResponse = await authedFetch(`${fixture.baseUrl}/notifications`, fixture.token);
const currentPayload = await currentResponse.json() as Record<string, unknown>;
expect(currentPayload).toEqual({
queued: 9,
lastSuccessAt: 1_700_000_060_000,
incidentType: "no_data",
incidentAgeMs: 30_000
});
const diagnosticsResponse = await authedFetch(`${fixture.baseUrl}/diagnostics`, fixture.token);
const diagnostics = await diagnosticsResponse.json() as Record<string, any>;
expect(diagnostics.notifications).toEqual(currentPayload);
expect(JSON.stringify([payload, currentPayload, diagnostics.notifications])).not.toMatch(/PRIVATE_|https:\/\/private|@private|events|payload/i);
});
it("serves diagnostics with main, session, and package log tails", async () => {
const fixture = await createFixture();
const response = await authedFetch(`${fixture.baseUrl}/diagnostics?package=server-package&lines=20`, fixture.token);
@@ -584,7 +625,8 @@ describe("debug-server", () => {
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("overview/trace-config.json");
expect(entries).toContain("overview/notifications.json");
expect(entries).toContain("logs/audit.log");
expect(entries).toContain("logs/rename.log");
expect(entries).toContain("logs/trace.log");
@@ -592,8 +634,16 @@ describe("debug-server", () => {
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");
});
expect(entries).not.toContain("runtime/debug_token.txt");
const notifications = JSON.parse(zip.getEntry("overview/notifications.json")?.getData().toString("utf8") || "null");
expect(notifications).toEqual({
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "no_data",
incidentAgeMs: 30_000
});
expect(buffer.toString("utf8")).not.toMatch(/PRIVATE_DEBUG_EVENT_PAYLOAD|https:\/\/private|@private/);
});
it("rejects unauthenticated requests", async () => {
const fixture = await createFixture();
+516
View File
@@ -0,0 +1,516 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
DEEPBRID_ACCOUNT_ID,
DeepbridApiError,
DeepbridClient,
parseDeepbridSize
} from "../src/main/deepbrid";
const originalFetch = globalThis.fetch;
const apiKey = "synthetic-deepbrid-key";
function jsonResponse(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "Content-Type": "application/json; charset=utf-8", ...headers }
});
}
function jsonBodyFailure(error: unknown): Response {
const response = jsonResponse({ unused: true });
Object.defineProperty(response, "json", {
value: async () => {
throw error;
}
});
return response;
}
afterEach(() => {
globalThis.fetch = originalFetch;
vi.useRealTimers();
});
describe("DeepbridClient", () => {
it("returns validated account information from the documented user endpoint", async () => {
const fetchMock = vi.fn(async () => jsonResponse({
username: "tester",
email: "tester@example.invalid",
type: "premium",
expiration: "2027-01-02",
maxDownloads: 5,
maxConnections: 2,
fidelity_points: 10
}));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const user = await new DeepbridClient(apiKey).getUser();
expect(DEEPBRID_ACCOUNT_ID).toBe("svc-deepbrid");
expect(user).toEqual({
username: "tester",
email: "tester@example.invalid",
type: "premium",
expiration: "2027-01-02",
maxDownloads: 5,
maxConnections: 2
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe("https://www.deepbrid.com/api/v1/user");
expect(init.method).toBe("GET");
expect(new Headers(init.headers)).toEqual(new Headers({
Accept: "application/json",
Authorization: `Bearer ${apiKey}`
}));
});
it("normalizes the real string host list", async () => {
const fetchMock = vi.fn(async () => jsonResponse([
"rapidgator.net",
"turbobit.net"
]));
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expect(new DeepbridClient(apiKey).getHosts()).resolves.toEqual([
{ domain: "rapidgator.net", status: "unknown" },
{ domain: "turbobit.net", status: "unknown" }
]);
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(new Headers(init.headers)).toEqual(new Headers({ Accept: "application/json" }));
});
it("loads public hosts without an API key", async () => {
const fetchMock = vi.fn(async () => jsonResponse(["rapidgator.net"]));
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expect(new DeepbridClient("").getHosts()).resolves.toEqual([
{ domain: "rapidgator.net", status: "unknown" }
]);
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(new Headers(init.headers).has("Authorization")).toBe(false);
});
it("normalizes the documented domain-to-status host objects", async () => {
globalThis.fetch = vi.fn(async () => jsonResponse([
{ "rapidgator.net": "up" },
{ "ddownload.com": "down (2026-03-01)" }
])) as unknown as typeof fetch;
await expect(new DeepbridClient(apiKey).getHosts()).resolves.toEqual([
{ domain: "rapidgator.net", status: "up" },
{ domain: "ddownload.com", status: "down (2026-03-01)" }
]);
});
it("generates a link with the exact headers and form fields", async () => {
const sourceUrl = "https://hoster.example/file/id?token=source-token";
const directUrl = "https://download.example/files/archive.zip?token=direct-token";
const fetchMock = vi.fn(async () => jsonResponse({
error: 0,
message: "OK",
original_link: sourceUrl,
hoster: "hoster",
filename: "archive.zip",
link: directUrl,
stream: "stream-value",
size: "1.50 GB"
}));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const result = await new DeepbridClient(apiKey).unrestrictLink(sourceUrl, undefined, "file password");
expect(result).toEqual({
fileName: "archive.zip",
directUrl,
fileSize: 1610612736,
retriesUsed: 0
});
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe("https://www.deepbrid.com/api/v1/generate/link");
expect(init.method).toBe("POST");
expect(new Headers(init.headers)).toEqual(new Headers({
Accept: "application/json",
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/x-www-form-urlencoded"
}));
expect(String(init.body)).toBe("link=https%3A%2F%2Fhoster.example%2Ffile%2Fid%3Ftoken%3Dsource-token&pass=file+password");
});
it("uses the final URL path segment when filename is absent", async () => {
globalThis.fetch = vi.fn(async () => jsonResponse({
error: 0,
message: "OK",
link: "https://download.example/files/My%20Archive.bin?signature=synthetic",
size: "512 KB"
})) as unknown as typeof fetch;
await expect(new DeepbridClient(apiKey).unrestrictLink("https://hoster.example/file/fallback")).resolves.toEqual({
fileName: "My Archive.bin",
directUrl: "https://download.example/files/My%20Archive.bin?signature=synthetic",
fileSize: 524288,
retriesUsed: 0
});
});
it("reduces an API filename to a control-free basename", async () => {
globalThis.fetch = vi.fn(async () => jsonResponse({
error: 0,
message: "OK",
filename: "folder/inner\\safe\u0000-name.bin\n",
link: "https://download.example/files/fallback.bin",
size: "1 KB"
})) as unknown as typeof fetch;
const result = await new DeepbridClient(apiKey).unrestrictLink("https://hoster.example/file/api-filename");
expect(result.fileName).toBe("safe-name.bin");
});
it.each([
["CON.txt", "_CON.txt"],
["NUL", "_NUL"],
["bad<name>?.rar. ", "bad_name__.rar"],
["normal release 01.zip", "normal release 01.zip"]
])("makes the API filename %j Windows-safe", async (filename, expected) => {
globalThis.fetch = vi.fn(async () => jsonResponse({
error: 0,
message: "OK",
filename,
link: "https://download.example/files/fallback.bin",
size: "1 KB"
})) as unknown as typeof fetch;
const result = await new DeepbridClient(apiKey).unrestrictLink("https://hoster.example/file/windows-api-name");
expect(result.fileName).toBe(expected);
});
it.each([
["https://download.example/files/COM9.log", "_COM9.log"],
["https://download.example/files/bad%3Cname%3E%3F.rar.%20", "bad_name__.rar"],
["https://download.example/files/normal-release.zip", "normal-release.zip"],
["https://download.example/files/%00%7F", "download.bin"]
])("makes the URL fallback from %s Windows-safe", async (directUrl, expected) => {
globalThis.fetch = vi.fn(async () => jsonResponse({
error: 0,
message: "OK",
link: directUrl,
size: "1 KB"
})) as unknown as typeof fetch;
const result = await new DeepbridClient(apiKey).unrestrictLink("https://hoster.example/file/windows-url-name");
expect(result.fileName).toBe(expected);
});
it("removes decoded path components from a fallback filename", async () => {
globalThis.fetch = vi.fn(async () => jsonResponse({
error: 0,
message: "OK",
link: "https://download.example/files/%2E%2E%2Fsafe.bin",
size: "1 KB"
})) as unknown as typeof fetch;
const result = await new DeepbridClient(apiKey).unrestrictLink("https://hoster.example/file/safe-fallback");
expect(result.fileName).toBe("safe.bin");
});
it.each([
["HTML", new Response("<html>challenge secret</html>", { status: 200, headers: { "Content-Type": "text/html" } })],
["invalid JSON", new Response("{not-json}", { status: 200, headers: { "Content-Type": "application/json" } })],
["wrong top-level type", jsonResponse("not-an-object")],
["missing link", jsonResponse({ error: 0, filename: "missing.bin", size: "1 KB" })],
["credential-bearing link", jsonResponse({ link: "https://user:password@download.example/file.bin", size: "1 KB" })]
])("classifies %s success responses as malformed", async (_name, response) => {
globalThis.fetch = vi.fn(async () => response) as unknown as typeof fetch;
const error = await new DeepbridClient(apiKey)
.unrestrictLink("https://hoster.example/file/malformed")
.then(() => null, (value) => value);
expect(error).toBeInstanceOf(DeepbridApiError);
expect(error).toMatchObject({ classification: "malformed" });
});
it.each(["text/notjson", "application/jsonp", "text/jsonp"])("rejects the non-JSON media type %s", async (contentType) => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
link: "https://download.example/file.bin",
filename: "file.bin",
size: "1 KB"
}), {
status: 200,
headers: { "Content-Type": contentType }
}));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const error = await new DeepbridClient(apiKey)
.unrestrictLink("https://hoster.example/file/media-type")
.then(() => null, (value) => value);
expect(error).toBeInstanceOf(DeepbridApiError);
expect(error).toMatchObject({ classification: "malformed" });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("accepts a standard +json media type", async () => {
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
username: "problem-json-user",
email: "problem-json@example.invalid",
type: "premium",
expiration: "2027-01-02",
maxDownloads: 5,
maxConnections: 1
}), {
status: 200,
headers: { "Content-Type": "application/problem+json; charset=utf-8" }
})) as unknown as typeof fetch;
await expect(new DeepbridClient(apiKey).getUser()).resolves.toMatchObject({ username: "problem-json-user" });
});
it.each([401, 403])("classifies HTTP %i as auth and does not retry", async (status) => {
const fetchMock = vi.fn(async () => jsonResponse({ error: status, message: "invalid synthetic key" }, status));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const error = await new DeepbridClient(apiKey).getUser().then(() => null, (value) => value);
expect(error).toBeInstanceOf(DeepbridApiError);
expect(error).toMatchObject({ status, code: status, classification: "auth" });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("returns a safe auth error for an empty key without making a request", async () => {
const fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
const error = await new DeepbridClient(" ").getUser().then(() => null, (value) => value);
expect(error).toBeInstanceOf(DeepbridApiError);
expect(error).toMatchObject({ status: 401, code: 401, classification: "auth" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("respects and caps Retry-After before retrying a 429 response", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn()
.mockResolvedValueOnce(jsonResponse({ error: 429, message: "slow down" }, 429, { "Retry-After": "60" }))
.mockResolvedValueOnce(jsonResponse({
username: "retry-user",
email: "retry@example.invalid",
type: "premium",
expiration: "2027-01-02",
maxDownloads: 5,
maxConnections: 1
}));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const pending = new DeepbridClient(apiKey).getUser();
await vi.advanceTimersByTimeAsync(29999);
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await expect(pending).resolves.toMatchObject({ username: "retry-user" });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("retries immediately when Retry-After is zero", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn()
.mockResolvedValueOnce(jsonResponse({ error: 429, message: "retry now" }, 429, { "Retry-After": "0" }))
.mockResolvedValueOnce(jsonResponse({
username: "immediate-user",
email: "immediate@example.invalid",
type: "premium",
expiration: "2027-01-02",
maxDownloads: 5,
maxConnections: 1
}));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const pending = new DeepbridClient(apiKey).getUser();
await vi.advanceTimersByTimeAsync(0);
await expect(pending).resolves.toMatchObject({ username: "immediate-user" });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it.each([
["HTTP 500", () => jsonResponse({ error: 500, message: "server failed" }, 500)],
["network failure", () => Promise.reject(new TypeError("fetch failed synthetic transport"))],
["timeout", () => Promise.reject(new DOMException("synthetic timeout", "TimeoutError"))]
])("limits %s retries to three total attempts", async (_name, resultFactory) => {
vi.useFakeTimers();
const fetchMock = vi.fn(resultFactory);
globalThis.fetch = fetchMock as unknown as typeof fetch;
const pending = new DeepbridClient(apiKey).getUser();
const rejection = pending.catch((error) => error);
await vi.runAllTimersAsync();
const error = await rejection;
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(error).toBeInstanceOf(DeepbridApiError);
expect(error).toMatchObject({ classification: "temporary" });
});
it("retries timeout failures while reading a successful JSON body", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn()
.mockResolvedValueOnce(jsonBodyFailure(new DOMException("body timeout one", "TimeoutError")))
.mockResolvedValueOnce(jsonBodyFailure(new DOMException("body timeout two", "TimeoutError")))
.mockResolvedValueOnce(jsonResponse({
username: "body-retry-user",
email: "body-retry@example.invalid",
type: "premium",
expiration: "2027-01-02",
maxDownloads: 5,
maxConnections: 1
}));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const pending = new DeepbridClient(apiKey).getUser();
await vi.runAllTimersAsync();
await expect(pending).resolves.toMatchObject({ username: "body-retry-user" });
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("propagates caller abort immediately without retrying", async () => {
const controller = new AbortController();
const fetchMock = vi.fn((_url: string | URL | Request, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true });
}));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const abortReason = new DOMException("caller cancelled", "AbortError");
const pending = new DeepbridClient(apiKey).getUser(controller.signal);
controller.abort(abortReason);
await expect(pending).rejects.toBe(abortReason);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("honors an abort that occurs as fetch resolves", async () => {
const controller = new AbortController();
const abortReason = new DOMException("caller cancelled during response", "AbortError");
const fetchMock = vi.fn(async () => {
controller.abort(abortReason);
return jsonResponse({
username: "too-late",
email: "too-late@example.invalid",
type: "premium",
expiration: "2027-01-02",
maxDownloads: 5,
maxConnections: 1
});
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
await expect(new DeepbridClient(apiKey).getUser(controller.signal)).rejects.toBe(abortReason);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("propagates caller abort while reading a successful response body", async () => {
const controller = new AbortController();
const abortReason = new DOMException("caller cancelled body", "AbortError");
let bodyStartedResolve: (() => void) | undefined;
const bodyStarted = new Promise<void>((resolve) => {
bodyStartedResolve = resolve;
});
const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
const response = jsonResponse({ unused: true });
Object.defineProperty(response, "json", {
value: () => new Promise<unknown>((_resolve, reject) => {
bodyStartedResolve?.();
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true });
})
});
return response;
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const pending = new DeepbridClient(apiKey).getUser(controller.signal);
await bodyStarted;
controller.abort(abortReason);
await expect(pending).rejects.toBe(abortReason);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("propagates caller abort during a retry pause without another request", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const abortReason = new DOMException("caller cancelled retry pause", "AbortError");
const fetchMock = vi.fn(async () => jsonResponse({ error: 500, message: "temporary" }, 500));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const pending = new DeepbridClient(apiKey).getUser(controller.signal);
const rejection = pending.catch((error) => error);
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
controller.abort(abortReason);
const error = await rejection;
await vi.runAllTimersAsync();
expect(error).toBe(abortReason);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("classifies a numeric HTTP 200 API error as a safe link error", async () => {
const sourceUrl = "https://hoster.example/file/http-200-secret";
const directUrl = "https://download.example/http-200-direct-secret";
globalThis.fetch = vi.fn(async () => jsonResponse({
error: 17,
message: `${apiKey} ${sourceUrl} ${directUrl}`,
link: directUrl
})) as unknown as typeof fetch;
const error = await new DeepbridClient(apiKey).unrestrictLink(sourceUrl).then(() => null, (value) => value);
expect(error).toBeInstanceOf(DeepbridApiError);
expect(error).toMatchObject({ status: 200, code: 17, classification: "link" });
expect(error.message).not.toContain(apiKey);
expect(error.message).not.toContain(sourceUrl);
expect(error.message).not.toContain(directUrl);
});
it("classifies structured generate failures without leaking secrets", async () => {
const sourceUrl = "https://hoster.example/file/source-secret";
const directUrl = "https://download.example/direct-secret";
const fetchMock = vi.fn(async () => jsonResponse({
error: 422,
message: `${apiKey} ${sourceUrl} ${directUrl}`,
link: directUrl
}, 422));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const error = await new DeepbridClient(apiKey).unrestrictLink(sourceUrl).then(() => null, (value) => value);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(error).toBeInstanceOf(DeepbridApiError);
expect(error).toMatchObject({ status: 422, code: 422, classification: "link" });
expect(error.message).not.toContain(apiKey);
expect(error.message).not.toContain(sourceUrl);
expect(error.message).not.toContain(directUrl);
expect(error.message).not.toContain("source-secret");
expect(error.message).not.toContain("direct-secret");
});
});
describe("parseDeepbridSize", () => {
it.each([
["0 B", 0],
["512 KB", 524288],
["1.50 GB", 1610612736],
["2 TB", 2199023255552],
[2048, 2048]
])("parses %j as bytes", (value, expected) => {
expect(parseDeepbridSize(value)).toBe(expected);
});
it.each(["", "unknown", "-1 GB", Number.NaN, null, undefined, {}])("returns null for invalid value %j", (value) => {
expect(parseDeepbridSize(value)).toBeNull();
});
});
+536
View File
@@ -0,0 +1,536 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
DownloadHealthMonitor,
createDownloadHealthState,
evaluateDownloadHealth,
loadDownloadHealthState,
saveDownloadHealthState,
type DownloadHealthSnapshot,
type DownloadHealthState
} from "../src/main/download-health-monitor";
import { NotificationOutbox } from "../src/main/notification-outbox";
const RUN_FINGERPRINT = "a".repeat(64);
const QUEUE_FINGERPRINT = "b".repeat(64);
const OTHER_RUN_FINGERPRINT = "c".repeat(64);
const OTHER_QUEUE_FINGERPRINT = "d".repeat(64);
const tempDirs: string[] = [];
function snapshot(overrides: Partial<DownloadHealthSnapshot> = {}): DownloadHealthSnapshot {
return {
runActive: true,
runFingerprint: RUN_FINGERPRINT,
queueFingerprint: QUEUE_FINGERPRINT,
openItems: 2,
openPackages: 1,
knownDownloadedBytes: 4096,
activeTasks: 1,
startableItems: 0,
lastSchedulerTickAt: 0,
downloadProgressSequence: 0,
itemCompletionSequence: 0,
lastPositiveByteAt: 0,
technicalRecoveryCount: 0,
paused: false,
reconnectUntil: 0,
nextRetryAt: 0,
providerCooldownUntil: 0,
blockedOnDisk: false,
blockedOnThrottleUntil: 0,
activePhaseDeadlineAt: 0,
terminalFailure: false,
manualStop: false,
shuttingDown: false,
currentSpeedBps: 0,
...overrides
};
}
function evaluate(
state: DownloadHealthState,
current: DownloadHealthSnapshot,
now: number,
overrides: Partial<Parameters<typeof evaluateDownloadHealth>[3]> = {}
) {
return evaluateDownloadHealth(state, current, now, {
stallAfterMs: 90_000,
cooldownMs: 600_000,
notifyOnStall: true,
notifyOnRecovery: true,
...overrides
});
}
function sampleTimes(
initial: DownloadHealthState,
times: number[],
current: DownloadHealthSnapshot = snapshot()
) {
let state = initial;
const events = [];
for (const now of times) {
const result = evaluate(state, current, now);
state = result.state;
events.push(...result.events);
}
return { state, events };
}
function alertedState(now = 90_000): DownloadHealthState {
return sampleTimes(createDownloadHealthState(), [0, 45_000, now]).state;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("evaluateDownloadHealth", () => {
it("keeps a 20 to 30 second silent interval below the alert boundary", () => {
const result = sampleTimes(createDownloadHealthState(), [0, 15_000, 30_000]);
expect(result.events).toEqual([]);
expect(result.state.status).toBe("suspect_no_data");
expect(result.state.suspiciousDurationMs).toBe(30_000);
expect(result.state.suspiciousSamples).toBe(3);
});
it("confirms a no-data stall only after 90 seconds and at least three suspicious samples", () => {
const before = sampleTimes(createDownloadHealthState(), [0, 45_000]);
const result = evaluate(before.state, snapshot(), 90_000);
expect(before.events).toEqual([]);
expect(result.state.status).toBe("alerted");
expect(result.events).toEqual([
expect.objectContaining({ type: "download_stalled", priority: "error" })
]);
});
it("does not alert from elapsed time until the third suspicious sample", () => {
const result = sampleTimes(createDownloadHealthState(), [0, 90_000]);
expect(result.events).toEqual([]);
expect(result.state.suspiciousDurationMs).toBe(90_000);
expect(result.state.suspiciousSamples).toBe(2);
});
it("classifies a startable queue with no scheduler as a scheduler suspicion", () => {
const result = evaluate(createDownloadHealthState(), snapshot({
activeTasks: 0,
startableItems: 2,
lastSchedulerTickAt: 0
}), 45_000);
expect(result.state.status).toBe("suspect_scheduler");
expect(result.events).toEqual([]);
});
it("treats a recent scheduler tick without an active task as healthy startup activity", () => {
const result = evaluate(createDownloadHealthState(), snapshot({
activeTasks: 0,
startableItems: 2,
lastSchedulerTickAt: 29_000
}), 30_000);
expect(result.state.status).toBe("healthy");
expect(result.state.suspiciousSamples).toBe(0);
});
it.each([
["pause", { paused: true }],
["reconnect", { reconnectUntil: 120_000 }],
["future retry", { activeTasks: 0, startableItems: 0, nextRetryAt: 120_000 }],
["provider cooldown", { activeTasks: 0, startableItems: 0, providerCooldownUntil: 120_000 }],
["disk wait", { blockedOnDisk: true }],
["bandwidth throttle", { blockedOnThrottleUntil: 120_000 }],
["valid phase deadline", { activePhaseDeadlineAt: 120_000 }]
])("freezes accumulated suspicion during %s", (_name, waitState) => {
const suspicious = sampleTimes(createDownloadHealthState(), [0, 30_000]);
const waiting = evaluate(suspicious.state, snapshot(waitState), 60_000);
const resumed = evaluate(waiting.state, snapshot(), 90_000);
expect(waiting.state.status).toBe("expected_wait");
expect(waiting.state.suspiciousDurationMs).toBe(30_000);
expect(waiting.state.suspiciousSamples).toBe(2);
expect(resumed.events).toEqual([]);
expect(resumed.state.suspiciousDurationMs).toBe(60_000);
});
it("resets suspicion after a positive byte sequence", () => {
const suspicious = sampleTimes(createDownloadHealthState(), [0, 30_000]);
const result = evaluate(suspicious.state, snapshot({
downloadProgressSequence: 1,
lastPositiveByteAt: 45_000
}), 45_000);
expect(result.events).toEqual([]);
expect(result.state.status).toBe("healthy");
expect(result.state.suspiciousDurationMs).toBe(0);
expect(result.state.suspiciousSamples).toBe(0);
});
it("resets suspicion after a successful item completion sequence", () => {
const suspicious = sampleTimes(createDownloadHealthState(), [0, 30_000]);
const result = evaluate(suspicious.state, snapshot({ itemCompletionSequence: 1 }), 45_000);
expect(result.events).toEqual([]);
expect(result.state.status).toBe("healthy");
expect(result.state.suspiciousDurationMs).toBe(0);
});
it("ignores speed, progress, item timestamps and global totals as progress evidence", () => {
const suspicious = sampleTimes(createDownloadHealthState(), [0, 45_000]);
const current = {
...snapshot({ currentSpeedBps: 900_000_000 }),
progressPercent: 99,
updatedAt: 90_000,
totalDownloadedBytes: 10_000_000_000
} as DownloadHealthSnapshot;
const result = evaluate(suspicious.state, current, 90_000);
expect(result.state.status).toBe("alerted");
expect(result.events).toHaveLength(1);
});
it("does not treat sequence decreases or a technical recovery attempt as progress", () => {
const initial = createDownloadHealthState({
downloadProgressSequence: 8,
itemCompletionSequence: 3
});
const result = sampleTimes(initial, [0, 45_000, 90_000], snapshot({
downloadProgressSequence: 2,
itemCompletionSequence: 1,
technicalRecoveryCount: 1
}));
expect(result.state.status).toBe("alerted");
expect(result.events).toHaveLength(1);
});
it("requires two positive-byte samples before recovering an alerted incident", () => {
const alerted = alertedState();
const first = evaluate(alerted, snapshot({
downloadProgressSequence: 1,
lastPositiveByteAt: 105_000
}), 105_000);
const second = evaluate(first.state, snapshot({
downloadProgressSequence: 2,
lastPositiveByteAt: 120_000
}), 120_000);
expect(first.state.status).toBe("recovering");
expect(first.events).toEqual([]);
expect(second.state.status).toBe("healthy");
expect(second.events).toEqual([
expect.objectContaining({ type: "download_recovered", priority: "success" })
]);
});
it("recovers immediately after a successful item completion", () => {
const result = evaluate(alertedState(), snapshot({ itemCompletionSequence: 1 }), 105_000);
expect(result.state.status).toBe("healthy");
expect(result.events).toEqual([
expect.objectContaining({ type: "download_recovered" })
]);
});
it.each([
["terminal failure", { terminalFailure: true }],
["manual stop", { runActive: false, manualStop: true }],
["shutdown", { runActive: false, shuttingDown: true }]
])("closes an alerted incident without recovery after %s", (_name, endState) => {
const result = evaluate(alertedState(), snapshot(endState), 105_000);
expect(result.state.status).toBe("idle");
expect(result.events).toEqual([]);
expect(result.state.alertedAt).toBe(0);
});
it("applies a ten-minute cooldown after a delivered incident event", () => {
const firstAlert = createDownloadHealthState({
...alertedState(),
lastAlertAt: 90_000,
cooldownUntil: 690_000,
lastDeliveredStallEventId: `health:stall:${RUN_FINGERPRINT.slice(0, 16)}:0`
});
const recovered = evaluate(firstAlert, snapshot({ itemCompletionSequence: 1 }), 105_000).state;
const duringCooldown = sampleTimes(recovered, [120_000, 165_000, 210_000]);
const afterCooldown = evaluate(duringCooldown.state, snapshot(), 690_000);
expect(duringCooldown.events).toEqual([]);
expect(duringCooldown.state.status).toBe("suspect_no_data");
expect(afterCooldown.events).toEqual([
expect.objectContaining({ type: "download_stalled" })
]);
});
it("starts the cooldown at actual Discord delivery after a buffered outage", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-delivery-ack-"));
tempDirs.push(root);
const healthFile = path.join(root, "health.json");
const outboxFile = path.join(root, "outbox.json");
const suspicious = sampleTimes(createDownloadHealthState(), [0, 45_000]).state;
const monitor = new DownloadHealthMonitor(healthFile, suspicious);
let now = 90_000;
let deliveryAvailable = false;
const deliveredIds: string[] = [];
const outbox = new NotificationOutbox({
filePath: outboxFile,
now: () => now,
send: async (queuedEvent) => {
if (deliveryAvailable) deliveredIds.push(queuedEvent.id);
return deliveryAvailable;
},
onDelivered: (queuedEvent, deliveredAt) => {
return monitor.acknowledgeDelivery(queuedEvent, deliveredAt, 600_000);
}
});
const confirmed = await monitor.sample(snapshot(), now, {
stallAfterMs: 90_000,
cooldownMs: 600_000,
notifyOnStall: true,
notifyOnRecovery: true
}, (event) => outbox.enqueue(event));
await outbox.drain();
now = 300_000;
const repeated = await monitor.sample(snapshot(), now, {
stallAfterMs: 90_000,
cooldownMs: 600_000,
notifyOnStall: true,
notifyOnRecovery: true
}, (event) => outbox.enqueue(event));
expect(confirmed.events).toHaveLength(1);
expect(repeated.events).toEqual([]);
expect(outbox.getStatus().queued).toBe(1);
expect(monitor.getState().cooldownUntil).toBe(0);
deliveryAvailable = true;
now = 420_000;
await outbox.drain(now);
expect(deliveredIds).toEqual([confirmed.events[0].id]);
expect(monitor.getState().lastAlertAt).toBe(420_000);
expect(monitor.getState().cooldownUntil).toBe(1_020_000);
});
it("serializes a delivery acknowledgement behind a concurrent recovery sample", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-ack-race-"));
tempDirs.push(root);
const filePath = path.join(root, "health.json");
const confirmed = sampleTimes(createDownloadHealthState(), [0, 45_000, 90_000]);
const monitor = new DownloadHealthMonitor(filePath, confirmed.state);
let releaseEnqueue = () => {};
const enqueueBlocked = new Promise<void>((resolve) => { releaseEnqueue = resolve; });
const recovery = monitor.sample(snapshot({ itemCompletionSequence: 1 }), 105_000, {
stallAfterMs: 90_000,
cooldownMs: 600_000,
notifyOnStall: true,
notifyOnRecovery: true
}, async () => enqueueBlocked);
await Promise.resolve();
const acknowledgement = monitor.acknowledgeDelivery(confirmed.events[0], 300_000, 600_000);
releaseEnqueue();
await Promise.all([recovery, acknowledgement]);
expect(monitor.getState().status).toBe("healthy");
expect(monitor.getState().lastAlertAt).toBe(300_000);
expect(monitor.getState().cooldownUntil).toBe(900_000);
expect(monitor.getState().lastDeliveredStallEventId).toBe(confirmed.events[0].id);
});
it("keeps the incident event id stable when outbox persistence rejects the state transition", () => {
const suspicious = sampleTimes(createDownloadHealthState(), [0, 45_000]).state;
const firstAttempt = evaluate(suspicious, snapshot(), 90_000);
const retryAttempt = evaluate(suspicious, snapshot(), 105_000);
expect(firstAttempt.events[0].id).toBe(retryAttempt.events[0].id);
});
it("omits identifiers, paths, URLs, providers and accounts from incident and recovery payloads", () => {
const incident = evaluate(sampleTimes(createDownloadHealthState(), [0, 45_000]).state, snapshot(), 90_000).events[0];
const recovered = evaluate(alertedState(), snapshot({ itemCompletionSequence: 1 }), 105_000).events[0];
const serialized = JSON.stringify([incident, recovered]);
expect(serialized).not.toMatch(/https?:|\\|\/downloads\/|provider|account|item-|package-/i);
expect(incident.payload.fields).toEqual(expect.arrayContaining([
expect.objectContaining({ name: "Offene Dateien", value: "2" }),
expect.objectContaining({ name: "Technische Wiederherstellungen", value: "0" })
]));
});
it("honors disabled incident and recovery settings independently", () => {
const suspicious = sampleTimes(createDownloadHealthState(), [0, 45_000]).state;
const disabledIncident = evaluate(suspicious, snapshot(), 90_000, { notifyOnStall: false });
const disabledRecovery = evaluate(alertedState(), snapshot({ itemCompletionSequence: 1 }), 105_000, { notifyOnRecovery: false });
expect(disabledIncident.events).toEqual([]);
expect(disabledIncident.state.status).toBe("suspect_no_data");
expect(disabledRecovery.events).toEqual([]);
expect(disabledRecovery.state.status).toBe("healthy");
});
});
describe("download health restart persistence", () => {
it("preserves a persisted incident while startup is still idle", () => {
const persisted = createDownloadHealthState({
status: "alerted",
runFingerprint: RUN_FINGERPRINT,
queueFingerprint: QUEUE_FINGERPRINT,
suspiciousDurationMs: 90_000,
suspiciousSamples: 3,
incidentStartedAt: 10_000,
alertedAt: 90_000,
restartPending: true
});
const result = evaluate(persisted, snapshot({ runActive: false, openItems: 0 }), 100_000);
expect(result.events).toEqual([]);
expect(result.state.status).toBe("suspended");
expect(result.state.runFingerprint).toBe(RUN_FINGERPRINT);
expect(result.state.queueFingerprint).toBe(QUEUE_FINGERPRINT);
expect(result.state.restartPending).toBe(true);
});
it("requires two fresh samples before re-alerting the same persisted fingerprint", () => {
const persisted = createDownloadHealthState({
status: "suspect_no_data",
runFingerprint: RUN_FINGERPRINT,
queueFingerprint: QUEUE_FINGERPRINT,
suspiciousDurationMs: 90_000,
suspiciousSamples: 3,
incidentStartedAt: 10_000,
restartPending: true
});
const first = evaluate(persisted, snapshot(), 100_000);
const second = evaluate(first.state, snapshot(), 115_000);
expect(first.events).toEqual([]);
expect(first.state.restartFreshSamples).toBe(1);
expect(second.events).toEqual([
expect.objectContaining({ type: "download_stalled" })
]);
});
it("discards a persisted incident when the queue fingerprint changes", () => {
const persisted = createDownloadHealthState({
status: "alerted",
runFingerprint: RUN_FINGERPRINT,
queueFingerprint: QUEUE_FINGERPRINT,
suspiciousDurationMs: 90_000,
suspiciousSamples: 4,
incidentStartedAt: 10_000,
alertedAt: 90_000,
restartPending: true
});
const result = evaluate(persisted, snapshot({
runFingerprint: OTHER_RUN_FINGERPRINT,
queueFingerprint: OTHER_QUEUE_FINGERPRINT
}), 100_000);
expect(result.events).toEqual([]);
expect(result.state.status).toBe("suspect_no_data");
expect(result.state.suspiciousDurationMs).toBe(0);
expect(result.state.alertedAt).toBe(0);
});
it("writes an allowlisted atomic state and reloads it with a fresh-sample gate", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-state-"));
tempDirs.push(root);
const filePath = path.join(root, "health.json");
const state = {
...alertedState(),
privateUrl: "https://private.example.test/file",
privatePath: "C:\\private\\download.bin",
privateAccount: "private@example.test",
lastDeliveredStallEventId: "https://private.example.test/stall"
} as DownloadHealthState;
saveDownloadHealthState(filePath, state);
const persisted = fs.readFileSync(filePath, "utf8");
const loaded = loadDownloadHealthState(filePath);
expect(persisted).not.toMatch(/private|example\.test|download\.bin/i);
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
expect(loaded.runFingerprint).toBe(RUN_FINGERPRINT);
expect(loaded.queueFingerprint).toBe(QUEUE_FINGERPRINT);
expect(loaded.restartPending).toBe(true);
expect(loaded.restartFreshSamples).toBe(0);
});
it("rejects malformed queue fingerprints instead of restoring an incident", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-invalid-"));
tempDirs.push(root);
const filePath = path.join(root, "health.json");
fs.writeFileSync(filePath, JSON.stringify({
...alertedState(),
queueFingerprint: "https://private.example.test/queue"
}), "utf8");
const loaded = loadDownloadHealthState(filePath);
expect(loaded).toEqual(createDownloadHealthState());
});
it("does not commit an alert until the outbox accepts the event", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-outbox-failure-"));
tempDirs.push(root);
const filePath = path.join(root, "health.json");
const suspicious = sampleTimes(createDownloadHealthState(), [0, 45_000]).state;
const monitor = new DownloadHealthMonitor(filePath, suspicious);
const eventIds: string[] = [];
await expect(monitor.sample(snapshot(), 90_000, {
stallAfterMs: 90_000,
cooldownMs: 600_000,
notifyOnStall: true,
notifyOnRecovery: true
}, async (event) => {
eventIds.push(event.id);
throw new Error("outbox unavailable");
})).rejects.toThrow("outbox unavailable");
await expect(monitor.sample(snapshot(), 105_000, {
stallAfterMs: 90_000,
cooldownMs: 600_000,
notifyOnStall: true,
notifyOnRecovery: true
}, async (event) => {
eventIds.push(event.id);
throw new Error("outbox unavailable");
})).rejects.toThrow("outbox unavailable");
expect(eventIds[0]).toBe(eventIds[1]);
expect(monitor.getState().status).toBe("suspect_no_data");
expect(fs.existsSync(filePath)).toBe(false);
});
it("persists the alerted state after the outbox accepts the event", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-outbox-success-"));
tempDirs.push(root);
const filePath = path.join(root, "health.json");
const suspicious = sampleTimes(createDownloadHealthState(), [0, 45_000]).state;
const monitor = new DownloadHealthMonitor(filePath, suspicious);
const result = await monitor.sample(snapshot(), 90_000, {
stallAfterMs: 90_000,
cooldownMs: 600_000,
notifyOnStall: true,
notifyOnRecovery: true
}, async () => undefined);
expect(result.events).toHaveLength(1);
expect(monitor.getState().status).toBe("alerted");
expect(loadDownloadHealthState(filePath)).toEqual(expect.objectContaining({
status: "alerted",
restartPending: true
}));
});
});
+632 -29
View File
@@ -22,7 +22,8 @@ import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accoun
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
import { UnrestrictedLink } from "../src/main/realdebrid";
import { resetVideoToolingCache } from "../src/main/video-processor";
import type { AppSettings, HistoryEntry, PackageEntry } from "../src/shared/types";
import { createDownloadHealthState, evaluateDownloadHealth } from "../src/main/download-health-monitor";
import type { AppSettings, DownloadItem, HistoryEntry, PackageEntry } from "../src/shared/types";
const tempDirs: string[] = [];
const originalFetch = globalThis.fetch;
@@ -126,6 +127,88 @@ describe("resolveUnrestrictTimeoutBudgetMs", () => {
});
});
describe("Deepbrid download lifecycle", () => {
it("treats Deepbrid as the only usable download account", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-deepbrid-only-"));
tempDirs.push(root);
const settings = {
...defaultSettings(),
deepbridApiKey: "synthetic-deepbrid-only-key",
providerOrder: ["deepbrid"] as const,
providerPrimary: "deepbrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const
};
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
const internal = manager as unknown as { hasUsableDownloadAccount: () => boolean };
expect(internal.hasUsableDownloadAccount()).toBe(true);
});
it.each([
["disabled", { disabledProviders: ["deepbrid"] }],
["daily-limited", { providerDailyLimitBytes: { deepbrid: 100 }, providerDailyUsageBytes: { deepbrid: 100 } }]
])("does not start with %s Deepbrid", (_label, override) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-deepbrid-blocked-"));
tempDirs.push(root);
const settings = {
...defaultSettings(),
deepbridApiKey: "synthetic-deepbrid-blocked-key",
providerOrder: ["deepbrid"] as const,
providerPrimary: "deepbrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
providerDailyUsageDay: getProviderUsageDayKey(),
...override
} as ReturnType<typeof defaultSettings>;
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
const internal = manager as unknown as { hasUsableDownloadAccount: () => boolean };
expect(internal.hasUsableDownloadAccount()).toBe(false);
});
it("records actual Deepbrid bytes in provider totals and rolling account statistics", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-deepbrid-statistics-"));
tempDirs.push(root);
const settings = {
...defaultSettings(),
deepbridApiKey: "synthetic-deepbrid-statistics-key",
providerDailyUsageBytes: { deepbrid: 100 },
providerTotalUsageBytes: { deepbrid: 1000 },
debridAccountStatuses: {
"svc-deepbrid": {
accountId: "svc-deepbrid",
provider: "deepbrid" as const,
label: "Deepbrid",
maskedLogin: "sy******ey",
valid: true,
isPremium: true,
premiumUntilMs: null,
username: "Synthetic Deepbrid User",
message: "Valid",
checkedAt: Date.now()
}
}
};
const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")));
const internal = manager as unknown as {
recordProviderDownloadedBytes: (provider: "deepbrid", bytes: number, accountId: string, accountLabel: string) => void;
settings: typeof settings;
};
internal.recordProviderDownloadedBytes("deepbrid", 256, "svc-deepbrid", "Deepbrid API");
expect(internal.settings.providerDailyUsageBytes.deepbrid).toBe(356);
expect(internal.settings.providerTotalUsageBytes.deepbrid).toBe(1256);
expect(manager.getStats().rolling24Hours?.accounts).toContainEqual(expect.objectContaining({
id: "svc-deepbrid",
provider: "deepbrid",
label: "Synthetic Deepbrid User",
bytes: 256
}));
});
});
describe("disk write recovery", () => {
it("classifies retryable disk write stalls without treating permission errors as temporary", () => {
expect(getDiskWriteWaitReason(Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" }))).toMatch(/Festplatte voll/);
@@ -611,6 +694,14 @@ describe("disk write recovery", () => {
deficitBytes: 640
}));
expect((manager as any).diskReservations.getReservedBytesByVolume().get("remux-volume") ?? 0).toBe(0);
expect(pkg.remuxOperations).toHaveLength(1);
expect(pkg.remuxOperations?.[0]).toMatchObject({
fileName: "Show.S01E01.German.DL.720p.mkv",
status: "failed",
errorCategory: "Speicherplatz"
});
expect(pkg.remuxOperations?.[0].completedAt).toBeGreaterThanOrEqual(pkg.remuxOperations?.[0].startedAt || 0);
expect(pkg.remuxOperations?.[0].durationMs).toBeGreaterThanOrEqual(0);
});
});
@@ -4112,9 +4203,10 @@ describe("download manager", () => {
return originalFetch(input, init);
};
let releaseBlockedPostProcess: ((value?: void | PromiseLike<void>) => void) | undefined;
try {
const manager = new DownloadManager(
let releaseBlockedPostProcess: ((value?: void | PromiseLike<void>) => void) | undefined;
try {
const history: HistoryEntry[] = [];
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
@@ -4123,14 +4215,15 @@ describe("download manager", () => {
autoExtract: false,
maxParallel: 1
},
emptySession(),
createStoragePaths(path.join(root, "state"))
emptySession(),
createStoragePaths(path.join(root, "state")),
{ onHistoryEntry: (entry) => history.push(entry) }
);
const blocker = new Promise<void>((resolve) => {
releaseBlockedPostProcess = resolve;
});
(manager as unknown as { packagePostProcessQueue: Promise<void> }).packagePostProcessQueue = blocker;
const blocker = new Promise<void>((resolve) => {
releaseBlockedPostProcess = resolve;
});
(manager as any).handlePackagePostProcessing = vi.fn(async () => blocker);
manager.addPackages([
{ name: "first", links: ["https://dummy/first"] },
@@ -4146,19 +4239,31 @@ describe("download manager", () => {
manager.start();
await waitFor(() => manager.getSnapshot().session.items[firstItem]?.status === "completed", 12000);
await waitFor(() => {
const state = manager.getSnapshot().session.items[secondItem]?.status;
return state === "validating" || state === "downloading" || state === "integrity_check" || state === "completed";
}, 6000);
await waitFor(() => {
const state = manager.getSnapshot().session.items[secondItem]?.status;
return state === "validating" || state === "downloading" || state === "integrity_check" || state === "completed";
}, 6000);
const pendingPackage = manager.getSnapshot().session.packages[firstPackage];
expect(pendingPackage?.downloadStartedAt).toBeGreaterThan(0);
expect(pendingPackage?.downloadEndedAt).toBeGreaterThanOrEqual(pendingPackage?.downloadStartedAt || 0);
expect(pendingPackage?.downloadEndedAt).toBe(pendingPackage?.downloadCompletedAt);
const downloadEndedAt = pendingPackage?.downloadEndedAt || 0;
await new Promise((resolve) => setTimeout(resolve, 1_200));
expect(manager.getSnapshot().session.packages[firstPackage]?.downloadEndedAt).toBe(downloadEndedAt);
if (releaseBlockedPostProcess) {
releaseBlockedPostProcess();
}
await waitFor(() => !manager.getSnapshot().session.running, 25000);
const done = manager.getSnapshot();
expect(done.session.items[firstItem]?.status).toBe("completed");
expect(done.session.items[secondItem]?.status).toBe("completed");
}
await waitFor(() => !manager.getSnapshot().session.running, 25000);
await waitFor(() => history.some((entry) => entry.name === "first"), 6000);
const done = manager.getSnapshot();
expect(done.session.items[firstItem]?.status).toBe("completed");
expect(done.session.items[secondItem]?.status).toBe("completed");
const firstHistory = history.find((entry) => entry.name === "first");
expect(firstHistory?.downloadEndedAt).toBe(downloadEndedAt);
expect(firstHistory?.totalDurationSeconds).toBeGreaterThan(firstHistory?.downloadDurationSeconds || 0);
} finally {
if (releaseBlockedPostProcess) {
releaseBlockedPostProcess();
@@ -10706,10 +10811,20 @@ describe("download manager", () => {
manager.getSnapshot().session.packages[packageId]?.status === "completed",
25000
);
const snapshot = manager.getSnapshot();
expect(snapshot.session.packages[packageId]?.status).toBe("completed");
expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
}, 30000);
const snapshot = manager.getSnapshot();
expect(snapshot.session.packages[packageId]?.status).toBe("completed");
expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
expect(snapshot.session.packages[packageId]?.archiveOperations).toHaveLength(1);
expect(snapshot.session.packages[packageId]?.archiveOperations?.[0]).toMatchObject({
name: "episode.zip",
itemIds: [itemId],
partCount: 1,
status: "completed",
errorCategory: ""
});
expect(snapshot.session.packages[packageId]?.archiveOperations?.[0].durationMs).toBeGreaterThanOrEqual(0);
expect(snapshot.session.packages[packageId]?.outputCount).toBe(1);
}, 30000);
it("does not fail startup post-processing when source package dir is missing but extract output exists", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
@@ -14187,7 +14302,7 @@ describe("mega-debrid api/web resolution overlap gate", () => {
});
});
describe("package priority ordering", () => {
describe("package priority ordering", () => {
function buildPriorityManager(priorities: Array<[string, "high" | "normal" | "low"]>): {
manager: DownloadManager;
session: ReturnType<typeof emptySession>;
@@ -14257,7 +14372,7 @@ describe("package priority ordering", () => {
expect(session.packageOrder).toEqual(["high-a", "normal-a", "target", "low-a"]);
});
it("keeps an unchanged priority in place", () => {
it("keeps an unchanged priority in place", () => {
const { manager, session } = buildPriorityManager([
["high-a", "high"],
["high-b", "high"],
@@ -14266,6 +14381,494 @@ describe("package priority ordering", () => {
manager.setPackagePriority("high-a", "high");
expect(session.packageOrder).toEqual(["high-a", "high-b", "normal-a"]);
});
});
expect(session.packageOrder).toEqual(["high-a", "high-b", "normal-a"]);
});
});
describe("package lifecycle telemetry boundaries", () => {
it("uses item-path provenance for archive identity and leaves unknown part counts at zero", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-identity-"));
tempDirs.push(root);
const session = emptySession();
const manager = new DownloadManager(defaultSettings(), session, createStoragePaths(path.join(root, "state")));
const pkg: PackageEntry = {
id: "archive-identity-package",
name: "Archive identity",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
status: "completed" as const,
itemIds: ["item-a", "item-b"],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
};
const item = (id: string, directory: string) => ({
id,
packageId: pkg.id,
url: `https://example.test/${id}`,
provider: "realdebrid" as const,
status: "completed" as const,
retries: 0,
speedBps: 0,
downloadedBytes: 1,
totalBytes: 1,
progressPercent: 100,
fileName: "episode.rar",
targetPath: path.join(pkg.outputDir, directory, "episode.rar"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig",
createdAt: 1_000,
updatedAt: 1_000
});
const progress = (current: number) => ({
current,
total: 3,
percent: 100,
archiveName: "episode.rar",
archivePercent: 100,
elapsedMs: 1_000,
archiveDone: true,
archiveSuccess: true
});
const state = manager as any;
state.recordArchiveOperation(pkg, progress(0), [item("item-a", "season-a")]);
state.recordArchiveOperation(pkg, progress(1), [item("item-b", "season-b")]);
state.recordArchiveOperation(pkg, { ...progress(2), archiveName: "unresolved.rar" }, []);
const operations = pkg.archiveOperations || [];
expect(operations).toHaveLength(3);
expect(new Set(operations.map((operation) => operation.id))).toHaveLength(3);
expect(operations.map((operation) => operation.partCount)).toEqual([1, 1, 0]);
});
it("projects archive failure details before storing operation telemetry", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-archive-failure-category-"));
tempDirs.push(root);
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
const pkg: PackageEntry = {
id: "archive-failure-package",
name: "Archive failure",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
status: "failed",
itemIds: [],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: 1_000
};
const progress = (current: number, archiveName: string) => ({
current,
total: 2,
percent: 100,
archiveName,
archivePercent: 100,
elapsedMs: 1_000,
archiveDone: true,
archiveSuccess: false
});
const state = manager as unknown as {
recordArchiveOperation: (entry: PackageEntry, update: ReturnType<typeof progress>, items: DownloadItem[], errorCategory: string) => void;
};
state.recordArchiveOperation(pkg, progress(0, "private.rar"), [], "C:\\Users\\Alice\\private.rar https://private.example.test/file");
state.recordArchiveOperation(pkg, progress(1, "disk-full.rar"), [], "ENOSPC: no space left on device C:\\Users\\Alice\\disk-full.rar");
expect(pkg.archiveOperations?.map((operation) => operation.errorCategory)).toEqual(["Entpacken", "Speicherplatz"]);
});
it("records queued, slot start and terminal timestamps around real post-processing", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-lifecycle-boundaries-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "lifecycle-package";
const itemId = "lifecycle-item";
const createdAt = Date.now() - 5_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Lifecycle",
outputDir: path.join(root, "downloads", "lifecycle"),
extractDir: path.join(root, "extract", "lifecycle"),
status: "completed",
itemIds: [itemId],
cancelled: false,
enabled: true,
downloadStartedAt: createdAt,
downloadCompletedAt: createdAt + 1_000,
downloadEndedAt: createdAt + 1_000,
createdAt,
updatedAt: createdAt + 1_000
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://dummy/lifecycle",
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 65_536,
totalBytes: 65_536,
progressPercent: 100,
fileName: "lifecycle.txt",
targetPath: path.join(root, "downloads", "lifecycle", "lifecycle.txt"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig",
createdAt,
updatedAt: createdAt + 1_000
};
fs.mkdirSync(path.dirname(session.items[itemId].targetPath), { recursive: true });
fs.writeFileSync(session.items[itemId].targetPath, Buffer.alloc(65_536, 1));
const manager = new DownloadManager(
{ ...defaultSettings(), autoExtract: false },
session,
createStoragePaths(path.join(root, "state"))
);
const state = manager as any;
state.runPackageIds.add(packageId);
state.handlePackagePostProcessing = vi.fn(async () => undefined);
await state.runPackagePostProcessing(packageId);
const pkg = session.packages[packageId];
expect(session.items[itemId].status).toBe("completed");
expect(pkg.postProcessQueuedAt).toBeGreaterThan(0);
expect(pkg.postProcessStartedAt).toBeGreaterThanOrEqual(pkg.postProcessQueuedAt || 0);
expect(pkg.postProcessCompletedAt).toBeGreaterThanOrEqual(pkg.postProcessStartedAt || 0);
expect(pkg.terminalAt).toBe(pkg.postProcessCompletedAt);
});
});
describe("download health snapshot", () => {
function createHealthManager(root: string, settings = defaultSettings()) {
const session = emptySession();
const packageId = "private-package-id";
const itemId = "private-item-id";
const now = 100_000;
session.running = true;
session.runStartedAt = 10_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Private package name",
outputDir: path.join(root, "private-output"),
extractDir: path.join(root, "private-extract"),
status: "downloading",
itemIds: [itemId],
cancelled: false,
enabled: true,
createdAt: 1_000,
updatedAt: now
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://private.example.test/file",
provider: "realdebrid",
providerLabel: "Private provider label",
providerAccountId: "private-account-id",
providerAccountLabel: "private@example.test",
status: "downloading",
retries: 0,
speedBps: 8192,
downloadedBytes: 4096,
totalBytes: 16384,
progressPercent: 25,
fileName: "private-file.bin",
targetPath: path.join(root, "private-output", "private-file.bin"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Download läuft",
createdAt: 2_000,
updatedAt: now
};
const manager = new DownloadManager(settings, session, createStoragePaths(path.join(root, "state")));
const state = manager as any;
session.running = true;
session.items[itemId].status = "downloading";
session.items[itemId].speedBps = 8192;
session.packages[packageId].status = "downloading";
state.runItemIds.add(itemId);
state.runPackageIds.add(packageId);
return { manager, session, state, packageId, itemId };
}
it("exposes only aggregate run-scope health data and opaque fingerprints", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-snapshot-"));
tempDirs.push(root);
const { manager, state, packageId, itemId } = createHealthManager(root);
state.lastSchedulerTickAt = 99_000;
state.activeTasks.set(itemId, {
itemId,
packageId,
abortController: new AbortController(),
abortReason: "none",
resumable: true,
nonResumableCounted: false,
phase: "downloading",
phaseStartedAt: 90_000,
phaseDeadlineAt: 0,
blockedOnDiskWrite: false,
blockedOnDiskSince: 0,
blockedOnThrottleUntil: 0
});
const health = manager.getDownloadHealthSnapshot(100_000);
const serialized = JSON.stringify(health);
expect(health).toMatchObject({
runActive: true,
openItems: 1,
openPackages: 1,
knownDownloadedBytes: 4096,
activeTasks: 1,
startableItems: 0,
lastSchedulerTickAt: 99_000,
currentSpeedBps: 8192
});
expect(health.runFingerprint).toMatch(/^[a-f0-9]{64}$/);
expect(health.queueFingerprint).toMatch(/^[a-f0-9]{64}$/);
expect(serialized).not.toMatch(/private|example\.test|realdebrid|file\.bin/i);
});
it("keeps the run fingerprint stable across restart time changes for the same generation scope", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-fingerprint-"));
tempDirs.push(root);
const { manager, session } = createHealthManager(root);
session.packages[session.packageOrder[0]].resultGeneration = 4;
const before = manager.getDownloadHealthSnapshot(100_000);
session.runStartedAt = 200_000;
const after = manager.getDownloadHealthSnapshot(210_000);
expect(after.runFingerprint).toBe(before.runFingerprint);
expect(after.queueFingerprint).toBe(before.queueFingerprint);
});
it("advances progress sequences only for positive byte events and successful item completions", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-sequences-"));
tempDirs.push(root);
const { manager, session, state, packageId, itemId } = createHealthManager(root);
state.recordSpeed(1024, packageId);
state.recordSpeed(0, packageId);
state.recordSpeed(-512, packageId);
session.totalDownloadedBytes = 10_000_000;
session.totalDownloadedBytes = 0;
state.recordRunOutcome(itemId, "completed");
state.recordRunOutcome(itemId, "completed");
const health = manager.getDownloadHealthSnapshot(100_000);
expect(health.downloadProgressSequence).toBe(1);
expect(health.lastPositiveByteAt).toBeGreaterThan(0);
expect(health.itemCompletionSequence).toBe(1);
});
it("projects retry, cooldown, disk, throttle and valid phase waits without stale disk events", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-waits-"));
tempDirs.push(root);
const { manager, session, state, packageId, itemId } = createHealthManager(root);
const active = {
itemId,
packageId,
abortController: new AbortController(),
abortReason: "none",
resumable: true,
nonResumableCounted: false,
phase: "validating",
phaseStartedAt: 90_000,
phaseDeadlineAt: 130_000,
blockedOnDiskWrite: true,
blockedOnDiskSince: 95_000,
blockedOnThrottleUntil: 120_000
};
state.activeTasks.set(itemId, active);
state.diskWaitEvents = [{
phase: "download",
targetPath: "C:\\private\\disk",
requiredBytes: 100,
availableBytes: 0,
reserveBytes: 0,
retryAt: 80_000,
itemId,
packageId
}];
const activeWait = manager.getDownloadHealthSnapshot(100_000);
expect(activeWait.blockedOnDisk).toBe(true);
expect(activeWait.blockedOnThrottleUntil).toBe(120_000);
expect(activeWait.activePhaseDeadlineAt).toBe(130_000);
state.activeTasks.clear();
session.items[itemId].status = "queued";
state.retryAfterByItem.set(itemId, 140_000);
state.providerFailures.set("realdebrid", { count: 20, lastFailAt: 99_000, cooldownUntil: 150_000 });
const queuedWait = manager.getDownloadHealthSnapshot(100_000);
expect(queuedWait.startableItems).toBe(0);
expect(queuedWait.nextRetryAt).toBe(140_000);
expect(queuedWait.providerCooldownUntil).toBe(150_000);
expect(queuedWait.blockedOnDisk).toBe(false);
});
it("does not project a partial retry as a global wait while another download is active", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-partial-wait-"));
tempDirs.push(root);
const { manager, session, state, packageId, itemId } = createHealthManager(root);
const queuedId = "private-queued-id";
session.items[queuedId] = {
...session.items[itemId],
id: queuedId,
status: "queued",
downloadedBytes: 0,
speedBps: 0
};
session.packages[packageId].itemIds.push(queuedId);
state.runItemIds.add(queuedId);
state.activeTasks.set(itemId, {
itemId,
packageId,
abortController: new AbortController(),
abortReason: "none",
resumable: true,
nonResumableCounted: false,
phase: "downloading",
phaseStartedAt: 90_000,
phaseDeadlineAt: 0,
blockedOnDiskWrite: false,
blockedOnDiskSince: 0,
blockedOnThrottleUntil: 0
});
state.retryAfterByItem.set(queuedId, 140_000);
const health = manager.getDownloadHealthSnapshot(100_000);
expect(health.activeTasks).toBe(1);
expect(health.nextRetryAt).toBe(0);
});
it("freezes health while two active downloads wait in the real global speed-limit queue", async () => {
vi.useFakeTimers();
vi.setSystemTime(10_000);
try {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-global-throttle-"));
tempDirs.push(root);
const settings = {
...defaultSettings(),
speedLimitEnabled: true,
speedLimitMode: "global" as const,
speedLimitKbps: 1
};
const { manager, session, state, packageId, itemId } = createHealthManager(root, settings);
const secondItemId = "private-throttled-item-2";
session.items[secondItemId] = {
...session.items[itemId],
id: secondItemId
};
session.packages[packageId].itemIds.push(secondItemId);
state.runItemIds.add(secondItemId);
const firstActive = {
itemId,
packageId,
abortController: new AbortController(),
abortReason: "none",
resumable: true,
nonResumableCounted: false,
phase: "downloading",
phaseStartedAt: 10_000,
phaseDeadlineAt: 0,
blockedOnDiskWrite: false,
blockedOnDiskSince: 0,
blockedOnThrottleUntil: 0
};
const secondActive = {
...firstActive,
itemId: secondItemId,
abortController: new AbortController()
};
state.activeTasks.set(itemId, firstActive);
state.activeTasks.set(secondItemId, secondActive);
await state.applySpeedLimit(100 * 1024, 0, 10_000, firstActive);
const secondWait = state.applySpeedLimit(100 * 1024, 0, 10_000, secondActive);
const firstWait = state.applySpeedLimit(100 * 1024, 0, 10_000, firstActive);
const waitsSettled = Promise.allSettled([secondWait, firstWait]);
await Promise.resolve();
let healthState = createDownloadHealthState();
const events = [];
for (const now of [10_000, 55_000, 105_000]) {
vi.setSystemTime(now);
const result = evaluateDownloadHealth(
healthState,
manager.getDownloadHealthSnapshot(now),
now,
{
stallAfterMs: 90_000,
cooldownMs: 600_000,
notifyOnStall: true,
notifyOnRecovery: true
}
);
healthState = result.state;
events.push(...result.events);
}
expect(firstActive.blockedOnThrottleUntil).toBeGreaterThan(105_000);
expect(secondActive.blockedOnThrottleUntil).toBeGreaterThan(105_000);
expect(manager.getDownloadHealthSnapshot(105_000).blockedOnThrottleUntil).toBeGreaterThan(105_000);
expect(healthState.status).toBe("expected_wait");
expect(healthState.suspiciousDurationMs).toBe(0);
expect(events).toEqual([]);
firstActive.abortController.abort("test-finished");
secondActive.abortController.abort("test-finished");
await vi.runAllTimersAsync();
await waitsSettled;
expect(firstActive.blockedOnThrottleUntil).toBe(0);
expect(secondActive.blockedOnThrottleUntil).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("counts one technical recovery when the existing global watchdog restarts stalled tasks", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-watchdog-"));
tempDirs.push(root);
const { manager, state, packageId, itemId } = createHealthManager(root);
const active = {
itemId,
packageId,
abortController: new AbortController(),
abortReason: "none",
resumable: true,
nonResumableCounted: false,
phase: "downloading",
phaseStartedAt: 1,
phaseDeadlineAt: 0,
blockedOnDiskWrite: false,
blockedOnDiskSince: 0,
blockedOnThrottleUntil: 0
};
state.activeTasks.set(itemId, active);
state.lastGlobalProgressBytes = 0;
state.lastGlobalProgressAt = 1;
state.runGlobalStallWatchdog(90_000);
expect(active.abortController.signal.aborted).toBe(true);
expect(active.abortReason).toBe("stall");
expect(manager.getDownloadHealthSnapshot(90_000).technicalRecoveryCount).toBe(1);
});
});
+77 -2
View File
@@ -42,7 +42,8 @@ import {
DownloadsSidebarStatus,
DownloadsToolbar,
DownloadsView,
type DownloadsViewActions
type DownloadsViewActions,
type DownloadsViewModel
} from "../src/renderer/views/downloads/DownloadsView";
import {
DownloadsTableHeader,
@@ -688,6 +689,7 @@ function createActions(overrides: Partial<DownloadsViewActions> = {}): Downloads
onStopDownloads: () => {},
onToggleSchedule: () => {},
onScheduleTimeChange: () => {},
onScheduleStartDayChange: () => {},
onActivateSchedule: () => {},
onCancelSchedule: () => {},
onMoveSelectionUp: () => {},
@@ -780,7 +782,7 @@ function dispatchColumnSortPointerGesture(header: ReactElement, label: string, c
if (deliverPointerClick) sortButton.props.onClick({ detail: 1 });
}
function withRuntime(input: DownloadsModelInput, overrides: Record<string, unknown> = {}) {
function withRuntime(input: DownloadsModelInput, overrides: Partial<DownloadsViewModel> = {}): DownloadsViewModel {
return {
...buildDownloadsViewModel(input),
running: true,
@@ -795,6 +797,8 @@ function withRuntime(input: DownloadsModelInput, overrides: Record<string, unkno
scheduleActive: false,
scheduleOpen: false,
scheduleTime: "23:30",
scheduleTimeValid: true,
scheduleStartDay: "today",
scheduleLabel: "",
packageSpeedBps: { "package-a": 12_000_000 },
disclosureRevision: 0,
@@ -1192,6 +1196,77 @@ describe("downloads view", () => {
expect(findButton(toolbar, "Abbrechen").props.disabled).toBe(false);
});
it("keeps closed schedule controls mounted, hidden and unreachable", () => {
const toolbar = DownloadsToolbar({
actions: createActions(),
model: withRuntime(createInput(), { scheduleActive: false, scheduleOpen: false })
});
const slot = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-schedule-slot"));
const controls = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-schedule-controls"));
const timeInput = findElement(toolbar, (element) => element.type === "input" && element.props["aria-label"] === "Startzeit");
const daySelect = findElement(toolbar, (element) => element.type === "select" && element.props["aria-label"] === "Starttag");
expect(findButton(toolbar, "Zeitplan").props["aria-expanded"]).toBe(false);
expect(slot.props.className).toContain("is-closed");
expect(controls.props["aria-hidden"]).toBe(true);
expect(controls.props.inert).toBe("true");
expect(renderToStaticMarkup(toolbar)).toContain('inert="true"');
expect(timeInput.props.disabled).toBe(true);
expect(daySelect.props.disabled).toBe(true);
expect(findButton(toolbar, "Planen").props.disabled).toBe(true);
});
it("collects local time and the selected first start day from the open schedule slot", () => {
const calls: string[] = [];
const toolbar = DownloadsToolbar({
actions: createActions({
onScheduleTimeChange: (value) => calls.push(`time:${value}`),
onScheduleStartDayChange: (value) => calls.push(`day:${value}`)
}),
model: withRuntime(createInput(), { scheduleOpen: true, scheduleStartDay: "tomorrow", scheduleTime: "08:15" })
});
const controls = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-schedule-controls"));
const timeInput = findElement(toolbar, (element) => element.type === "input" && element.props["aria-label"] === "Startzeit");
const daySelect = findElement(toolbar, (element) => element.type === "select" && element.props["aria-label"] === "Starttag");
timeInput.props.onChange({ target: { value: "09:45" } });
daySelect.props.onChange({ target: { value: "today" } });
expect(findButton(toolbar, "Zeitplan").props["aria-expanded"]).toBe(true);
expect(controls.props["aria-hidden"]).toBe(false);
expect(controls.props.inert).toBeUndefined();
expect(timeInput.props.value).toBe("08:15");
expect(daySelect.props.value).toBe("tomorrow");
expect(renderToStaticMarkup(toolbar)).toContain("Ab heute");
expect(renderToStaticMarkup(toolbar)).toContain("Ab morgen");
expect(calls).toEqual(["time:09:45", "day:today"]);
});
it.each(["", "8:15", "24:00", "12:60"])("disables schedule activation for the invalid time %j", (scheduleTime) => {
const toolbar = DownloadsToolbar({
actions: createActions(),
model: withRuntime(createInput(), { scheduleOpen: true, scheduleTime, scheduleTimeValid: false })
});
expect(findButton(toolbar, "Planen").props.disabled).toBe(true);
});
it("animates the persistent schedule slot horizontally and disables it through the global motion setting", () => {
const closed = renderToStaticMarkup(<DownloadsToolbar actions={createActions()} model={withRuntime(createInput())} />);
const open = renderToStaticMarkup(<DownloadsToolbar actions={createActions()} model={withRuntime(createInput(), { scheduleOpen: true })} />);
const motionDisabled = renderToStaticMarkup(<DownloadsToolbar actions={createActions()} model={withRuntime(createInput(), { animationsEnabled: false, scheduleOpen: true })} />);
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
expect(closed).toContain("downloads-schedule-slot is-closed");
expect(open).toContain("downloads-schedule-slot is-open");
expect(motionDisabled).toContain("downloads-schedule-slot is-open is-motion-disabled");
expect(css).toMatch(/\.downloads-schedule-slot\s*\{[^}]*grid-template-columns:\s*0fr;[^}]*opacity:\s*0;[^}]*transition:/s);
expect(css).toMatch(/\.downloads-schedule-slot\.is-open\s*\{[^}]*grid-template-columns:\s*1fr;[^}]*opacity:\s*1;/s);
expect(css).toMatch(/\.downloads-schedule-controls\s*\{[^}]*transform:\s*translateX\(-\d+px\);[^}]*transition:\s*transform/s);
expect(css).toMatch(/\.downloads-schedule-slot\.is-open \.downloads-schedule-controls\s*\{[^}]*transform:\s*translateX\(0\);/s);
expect(css).toMatch(/\.downloads-schedule-slot\.is-motion-disabled[^\{]*\{[^}]*transition:\s*none !important;/s);
});
it("keeps the table header and all rows in one horizontal scroll context with exact dense geometry", () => {
const html = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput())} />);
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
+7
View File
@@ -53,6 +53,13 @@ describe("revealHistoryEntry", () => {
expect(deps.openPath).toHaveBeenCalledWith("C:\\Downloads\\Paket");
});
it.each(["partial", "failed", "cancelled"] as const)("opens a %s history result from the same authoritative directory", async (status) => {
const deps = dependencies({ loadHistory: () => [historyEntry({ status })] });
await expect(revealHistoryEntry({ entryId: "known-id" }, deps)).resolves.toEqual({ ok: true });
expect(deps.openPath).toHaveBeenCalledWith("C:\\Downloads\\Paket");
});
it("ignores every renderer-supplied field except entryId", async () => {
const deps = dependencies();
+118 -7
View File
@@ -133,10 +133,19 @@ describe("history model", () => {
for (const [filter, ids] of Object.entries(expected) as Array<[HistoryFilter, string[]]>) {
expect(filterHistoryRows(entries, filter, "", now).map((row) => row.id)).toEqual(ids);
}
});
it("uses local calendar midnights for the six previous days across both daylight-saving transitions", () => {
}
});
it("labels partial and cancelled package results", () => {
const rows = filterHistoryRows([
entry({ id: "partial", name: "Teilweise", status: "partial" }),
entry({ id: "cancelled", name: "Abgebrochen", status: "cancelled" })
], "all", "", now);
expect(rows.map((row) => row.statusLabel)).toEqual(["Teilweise", "Abgebrochen"]);
});
it("uses local calendar midnights for the six previous days across both daylight-saving transitions", () => {
const springNow = new Date(2026, 2, 30, 12, 0, 0, 0).getTime();
const springBoundary = new Date(2026, 2, 24, 0, 0, 0, 0).getTime();
const autumnNow = new Date(2026, 9, 26, 12, 0, 0, 0).getTime();
@@ -176,7 +185,7 @@ describe("history model", () => {
expect(filterHistoryRows(searchable, "all", "d", now).map((row) => row.id)).toEqual(["new", "old"]);
});
it("derives hosters only from valid URL hostnames and clamps the calculated start time", () => {
it("derives hosters only from valid URL hostnames and clamps the calculated start time", () => {
expect(deriveHistoryHoster(["https://rapidgator.net/a", "https://rapidgator.net/b", "https://ddownload.com/c", "not a url"])).toBe("rapidgator.net, ddownload.com");
expect(deriveHistoryHoster([])).toBe("—");
expect(deriveHistoryHoster(undefined)).toBe("—");
@@ -185,8 +194,22 @@ describe("history model", () => {
const row = filterHistoryRows([entry({ id: "provider", name: "Provider", provider: "realdebrid", urls: [] })], "all", "", now)[0];
expect(row.hoster).toBe("—");
expect(row.providerLabel).toBe("Real-Debrid");
});
expect(row.providerLabel).toBe("Real-Debrid");
});
it("uses the authoritative lifecycle start instead of reconstructing it from completion", () => {
const lifecycleStartedAt = todayStart - 45_000;
const lifecycle = entry({
id: "lifecycle-start",
name: "Lifecycle",
startedAt: lifecycleStartedAt,
completedAt: todayStart + 60_000,
durationSeconds: 5
});
expect(deriveHistoryStartAt(lifecycle)).toBe(lifecycleStartedAt);
expect(filterHistoryRows([lifecycle], "all", "", now)[0].startAt).toBe(lifecycleStartedAt);
});
it("prunes removed ids and preserves the original set instance when every id survives", () => {
const stable = new Set(["today", "week"]);
@@ -588,6 +611,94 @@ describe("HistoryView", () => {
expect(html).toContain("https://rapidgator.net/file/test");
});
it("renders authoritative lifecycle timings, counts, failure phase and operation details", () => {
const structured = entry({
id: "structured",
name: "Strukturiert",
status: "partial",
startedAt: todayStart,
downloadEndedAt: todayStart + 60_000,
postProcessStartedAt: todayStart + 65_000,
completedAt: todayStart + 90_000,
downloadDurationSeconds: 60,
extractionDurationSeconds: 12,
remuxDurationSeconds: 8,
postProcessDurationSeconds: 25,
totalDurationSeconds: 90,
successfulFiles: 3,
failedFiles: 1,
cancelledFiles: 0,
archiveCount: 1,
partCount: 16,
outputCount: 10,
failurePhase: "remux",
archiveOperations: [{
id: "archive-1",
name: "show.part01.rar",
itemIds: ["item-1"],
partCount: 16,
startedAt: todayStart + 65_000,
completedAt: todayStart + 77_000,
durationMs: 12_000,
status: "completed",
errorCategory: ""
}],
remuxOperations: [{
id: "remux-1",
fileName: "episode.mkv",
startedAt: todayStart + 77_000,
completedAt: todayStart + 85_000,
durationMs: 8_000,
status: "failed",
errorCategory: "ffmpeg"
}]
});
const html = renderToStaticMarkup(
<HistoryView
actions={createActions()}
model={buildHistoryViewModel([structured], "all", "", [], [structured.id], false, "", now)}
/>
);
for (const label of [
"Download gestartet",
"Download beendet",
"Nachbearbeitung gestartet",
"Abgeschlossen",
"Downloaddauer",
"Entpackdauer",
"Remuxdauer",
"Nachbearbeitungsdauer",
"Gesamtdauer",
"Erfolgreich / Fehlgeschlagen / Abgebrochen",
"Archive / Parts / Ausgaben",
"Fehlerphase",
"Archivvorgänge",
"Remuxvorgänge"
]) {
expect(html).toContain(label);
}
expect(html).toContain("show.part01.rar");
expect(html).toContain("16 Parts");
expect(html).toContain("episode.mkv");
expect(html).toContain("ffmpeg");
expect(html).not.toContain("Downloaddauer (Altbestand)");
});
it("labels durationSeconds honestly for legacy entries", () => {
const legacy = entry({ id: "legacy", name: "Altbestand" });
const html = renderToStaticMarkup(
<HistoryView
actions={createActions()}
model={buildHistoryViewModel([legacy], "all", "", [], [legacy.id], false, "", now)}
/>
);
expect(html).toContain("Downloaddauer (Altbestand)");
expect(html).not.toContain("Download beendet");
expect(html).not.toContain("Nachbearbeitung gestartet");
});
it("uses the global animation setting for the history disclosure surface", () => {
const animated = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], ["today"], false, "", now, true);
const immediate = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], ["today"], false, "", now, false);
+1 -1
View File
@@ -40,7 +40,7 @@ describe("1Fichier public metadata", () => {
expect(isOneFichierLink("https://desfichiers.net/?abc12345")).toBe(true);
expect(isOneFichierLink("https://piecejointe.net/?abc12345")).toBe(true);
expect(extractHosterFromUrl("https://dl4free.com/?abc12345")).toBe("1fichier");
expect(formatHosterLabel("1fichier")).toEqual({ compact: "1F", title: "1Fichier" });
expect(formatHosterLabel("1fichier")).toEqual({ compact: "1Fichier", title: "1Fichier", iconSrc: "./provider-icons/onefichier.png" });
});
it("distinguishes online, missing and private links without inventing metadata", async () => {
+34
View File
@@ -16,6 +16,40 @@ describe("renderer localization", () => {
expect(translateUiText("Animations", "de")).toBe("Animationen");
});
it.each([
["Deepbrid API", "Deepbrid API"],
["Direkter Zugriff über API-Key.", "Direct access via API key."],
["Deepbrid gespeichert", "Deepbrid saved"]
])("translates Deepbrid account UI text %s", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
});
it.each([
["Erfolgsmeldungen senden", "Send success notifications"],
["Gesammelt (alle 2 Minuten)", "Grouped (every 2 minutes)"],
["Jedes Paket einzeln", "Each package individually"],
["Melden, wenn der gesamte Lauf fertig ist", "Notify when the entire run completes"],
["Melden, wenn die Restmenge unterschritten wird", "Notify when the remaining amount falls below the threshold"],
["Restmengenschwelle (GB)", "Remaining amount threshold (GB)"],
["Melden, wenn Downloads stillstehen", "Notify when downloads stall"],
["Stillstand bestätigen nach (Sek.)", "Confirm stall after (sec.)"],
["Frühestens erneut melden nach (Min.)", "Notify again after at least (min.)"],
["Melden, wenn Downloads wieder laufen", "Notify when downloads resume"]
])("translates notification center setting %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Starttag", "Start day"],
["Ab heute", "Starting today"],
["Ab morgen", "Starting tomorrow"],
["Bitte eine gültige Startzeit auswählen.", "Select a valid start time."]
])("translates daily schedule text %s in both directions", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it("translates dynamic update and pagination text", () => {
expect(translateUiText("v2.0.14 ist verfügbar. Installierte Version: 2.0.13.", "en"))
.toBe("v2.0.14 is available. Installed version: 2.0.13.");
+381
View File
@@ -0,0 +1,381 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const electron = vi.hoisted(() => {
const handlers = new Map<string, (...args: unknown[]) => void>();
const powerMonitor = { on: vi.fn(), removeListener: vi.fn() };
return {
handlers,
powerMonitor,
app: {
isPackaged: false,
getPath: vi.fn(() => "C:\\MDD\\Test"),
getAppPath: vi.fn(() => "C:\\MDD\\App"),
requestSingleInstanceLock: vi.fn(() => true),
on: vi.fn((name: string, handler: (...args: unknown[]) => void) => { handlers.set(name, handler); }),
whenReady: vi.fn(() => new Promise<void>(() => {})),
quit: vi.fn(),
exit: vi.fn(),
setPath: vi.fn()
}
};
});
vi.mock("electron", () => ({
app: electron.app,
BrowserWindow: class {
public static getAllWindows(): unknown[] { return []; }
},
clipboard: {},
dialog: {},
ipcMain: { handle: vi.fn(), on: vi.fn() },
Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() },
nativeTheme: { themeSource: "system" },
powerMonitor: electron.powerMonitor,
safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() },
shell: { openExternal: vi.fn() },
Tray: class {}
}));
import { AppController } from "../src/main/app-controller";
import {
DownloadHealthMonitor,
createDownloadHealthState,
loadDownloadHealthState,
saveDownloadHealthState,
type DownloadHealthSnapshot
} from "../src/main/download-health-monitor";
import { NotificationOutbox, type NotificationEvent } from "../src/main/notification-outbox";
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve = () => {};
const promise = new Promise<void>((done) => { resolve = done; });
return { promise, resolve };
}
function shutdownEvent(id: string, priority: "success" | "error" = "success"): NotificationEvent {
return {
id,
type: "package_completed",
priority,
createdAt: Date.now(),
expiresAt: Date.now() + 6 * 60 * 60 * 1000,
attempts: 0,
nextAttemptAt: Date.now(),
payload: { title: "Paket-Digest", fields: [] }
};
}
afterEach(() => {
vi.useRealTimers();
});
describe("main shutdown lifecycle", () => {
it("AppController waits for the bounded outbox drain before disposing runtime owners", async () => {
const drain = deferred();
const manager = { prepareForShutdown: vi.fn() };
const controller = Object.create(AppController.prototype) as any;
controller.runtimeStatsTimer = null;
controller.notificationOutbox = { drainForShutdown: vi.fn(() => drain.promise) };
controller.manager = manager;
controller.megaWebFallback = { dispose: vi.fn() };
controller.realDebridWebFallbacks = new Map();
controller.pendingRealDebridWebAccountIds = new Map();
controller.allDebridWebFallback = { dispose: vi.fn() };
controller.bestDebridWebFallback = { dispose: vi.fn() };
controller.shutdownLogStorage = vi.fn();
controller.audit = vi.fn();
controller.settings = { historyRetentionMode: "never" };
const shutdown = controller.shutdown();
expect(shutdown).toBeInstanceOf(Promise);
const drainBudget = controller.notificationOutbox.drainForShutdown.mock.calls[0][0];
expect(drainBudget).toBeGreaterThan(0);
expect(drainBudget).toBeLessThanOrEqual(3000);
expect(manager.prepareForShutdown).toHaveBeenCalledTimes(1);
drain.resolve();
await shutdown;
});
it("uses one three-second deadline even when a running health evaluation never settles", async () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
const runningEvaluation = deferred();
const controller = Object.create(AppController.prototype) as any;
controller.downloadHealthTimer = setInterval(() => {}, 60_000);
controller.downloadHealthEvaluation = runningEvaluation.promise;
controller.downloadHealthMonitor = null;
controller.runtimeStatsTimer = null;
controller.notificationOutbox = { drainForShutdown: vi.fn(async () => undefined) };
controller.manager = {
suspendDownloadHealthMonitoring: vi.fn(),
prepareForShutdown: vi.fn(),
flushNotificationsForShutdown: vi.fn(async () => undefined)
};
controller.megaWebFallback = { dispose: vi.fn() };
controller.realDebridWebFallbacks = new Map();
controller.pendingRealDebridWebAccountIds = new Map();
controller.allDebridWebFallback = { dispose: vi.fn() };
controller.bestDebridWebFallback = { dispose: vi.fn() };
controller.shutdownLogStorage = vi.fn();
controller.audit = vi.fn();
controller.settings = { historyRetentionMode: "never" };
let completed = false;
const shutdown = controller.shutdown().then(() => { completed = true; });
await vi.advanceTimersByTimeAsync(2999);
expect(completed).toBe(false);
await vi.advanceTimersByTimeAsync(1);
const completedAtDeadline = completed;
runningEvaluation.resolve();
await vi.runAllTimersAsync();
await shutdown;
expect(completedAtDeadline).toBe(true);
expect(controller.manager.prepareForShutdown).toHaveBeenCalledTimes(1);
});
it("persists a digest completed inside the shared shutdown window while an earlier send is blocked", async () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-shutdown-outbox-"));
const filePath = path.join(root, "outbox.json");
let releaseSend = (_sent: boolean) => {};
let markSendStarted = () => {};
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
const blockedSend = new Promise<boolean>((resolve) => { releaseSend = resolve; });
const lateEnqueued = deferred();
const outbox = new NotificationOutbox({
filePath,
now: Date.now,
send: async (event) => {
if (event.id === "already-sending") {
markSendStarted();
return blockedSend;
}
return true;
}
});
await outbox.enqueue(shutdownEvent("already-sending", "error"));
const activeDrain = outbox.drain();
await sendStarted;
const controller = Object.create(AppController.prototype) as any;
controller.downloadHealthTimer = null;
controller.downloadHealthEvaluation = null;
controller.downloadHealthMonitor = null;
controller.runtimeStatsTimer = null;
controller.notificationOutbox = outbox;
controller.manager = {
suspendDownloadHealthMonitoring: vi.fn(),
prepareForShutdown: vi.fn(),
flushNotificationsForShutdown: vi.fn(() => new Promise<void>((resolve) => {
setTimeout(() => {
void outbox.enqueue(shutdownEvent("late-digest")).then(() => {
lateEnqueued.resolve();
resolve();
});
}, 500);
}))
};
controller.megaWebFallback = { dispose: vi.fn() };
controller.realDebridWebFallbacks = new Map();
controller.pendingRealDebridWebAccountIds = new Map();
controller.allDebridWebFallback = { dispose: vi.fn() };
controller.bestDebridWebFallback = { dispose: vi.fn() };
controller.shutdownLogStorage = vi.fn();
controller.audit = vi.fn();
controller.settings = { historyRetentionMode: "never" };
let completed = false;
const shutdown = controller.shutdown().then(() => { completed = true; });
await vi.advanceTimersByTimeAsync(500);
await lateEnqueued.promise;
const stateDuringWindow = JSON.parse(fs.readFileSync(filePath, "utf8")) as { events: NotificationEvent[] };
await vi.advanceTimersByTimeAsync(2500);
const completedAtDeadline = completed;
releaseSend(true);
await vi.runAllTimersAsync();
await activeDrain;
await shutdown;
expect(stateDuringWindow.events.map((event) => event.id)).toContain("late-digest");
expect(completedAtDeadline).toBe(true);
expect(controller.manager.prepareForShutdown.mock.invocationCallOrder[0])
.toBeLessThan(controller.manager.flushNotificationsForShutdown.mock.invocationCallOrder[0]);
fs.rmSync(root, { recursive: true, force: true });
});
it("prevents quit once, waits for shutdown, then allows exactly one loop-free quit", async () => {
const main = await import("../src/main/main");
const shutdown = deferred();
const cleanup = vi.fn();
const continueQuit = vi.fn();
const onError = vi.fn();
const optionsShutdown = vi.fn(() => shutdown.promise);
const handler = main.createBeforeQuitHandler({
cleanup,
shutdown: optionsShutdown,
continueQuit,
onError
});
const first = { preventDefault: vi.fn() };
const repeated = { preventDefault: vi.fn() };
const resumed = { preventDefault: vi.fn() };
handler(first);
handler(repeated);
expect(first.preventDefault).toHaveBeenCalledTimes(1);
expect(repeated.preventDefault).toHaveBeenCalledTimes(1);
expect(cleanup).toHaveBeenCalledTimes(1);
expect(cleanup.mock.invocationCallOrder[0]).toBeLessThan(optionsShutdown.mock.invocationCallOrder[0]);
expect(continueQuit).not.toHaveBeenCalled();
shutdown.resolve();
await vi.waitFor(() => expect(continueQuit).toHaveBeenCalledTimes(1));
handler(resumed);
expect(resumed.preventDefault).not.toHaveBeenCalled();
expect(cleanup).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalled();
});
it("stops the daily scheduler, clears the legacy timer, and removes power listeners before controller shutdown", async () => {
vi.useFakeTimers();
vi.resetModules();
electron.powerMonitor.removeListener.mockClear();
const main = await import("../src/main/main");
const scheduler = { end: vi.fn() };
const timer = setTimeout(() => {}, 60_000);
const shutdown = vi.fn(async () => undefined);
const handler = main.createBeforeQuitHandler({
cleanup: () => main.cleanupSchedulerLifecycle(scheduler, timer),
shutdown,
continueQuit: vi.fn(),
onError: vi.fn()
});
handler({ preventDefault: vi.fn() });
await vi.waitFor(() => expect(shutdown).toHaveBeenCalledTimes(1));
expect(scheduler.end).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
expect(electron.powerMonitor.removeListener).toHaveBeenCalledWith("suspend", expect.any(Function));
expect(electron.powerMonitor.removeListener).toHaveBeenCalledWith("resume", expect.any(Function));
expect(scheduler.end.mock.invocationCallOrder[0]).toBeLessThan(shutdown.mock.invocationCallOrder[0]);
expect(electron.powerMonitor.removeListener.mock.invocationCallOrder[1]).toBeLessThan(shutdown.mock.invocationCallOrder[0]);
});
it("waits for a running health sample and persistently closes an alerted incident before shutdown returns", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-shutdown-"));
try {
const filePath = path.join(root, "health.json");
const runFingerprint = "a".repeat(64);
const queueFingerprint = "b".repeat(64);
saveDownloadHealthState(filePath, createDownloadHealthState({
status: "alerted",
runFingerprint,
queueFingerprint,
suspiciousDurationMs: 90_000,
suspiciousSamples: 3,
incidentStartedAt: 10_000,
alertedAt: 90_000,
lastAlertAt: 90_000,
cooldownUntil: 690_000
}));
let shuttingDown = false;
const healthSnapshot = (completionSequence: number): DownloadHealthSnapshot => ({
runActive: true,
runFingerprint,
queueFingerprint,
openItems: 1,
openPackages: 1,
knownDownloadedBytes: 4096,
activeTasks: 1,
startableItems: 0,
lastSchedulerTickAt: 100_000,
downloadProgressSequence: 0,
itemCompletionSequence: completionSequence,
lastPositiveByteAt: 0,
technicalRecoveryCount: 0,
paused: false,
reconnectUntil: 0,
nextRetryAt: 0,
providerCooldownUntil: 0,
blockedOnDisk: false,
blockedOnThrottleUntil: 0,
activePhaseDeadlineAt: 0,
terminalFailure: false,
manualStop: false,
shuttingDown,
currentSpeedBps: 0
});
const runningEvaluation = deferred();
const controller = Object.create(AppController.prototype) as any;
controller.downloadHealthTimer = setInterval(() => {}, 60_000);
controller.downloadHealthTimer.unref?.();
controller.downloadHealthMonitor = new DownloadHealthMonitor(filePath);
controller.downloadHealthEvaluation = runningEvaluation.promise.finally(() => {
controller.downloadHealthEvaluation = null;
});
controller.runtimeStatsTimer = null;
controller.notificationOutbox = {
enqueue: vi.fn(async () => undefined),
drainForShutdown: vi.fn(async () => undefined)
};
controller.manager = {
suspendDownloadHealthMonitoring: vi.fn(() => { shuttingDown = true; }),
getDownloadHealthSnapshot: vi.fn(() => healthSnapshot(1)),
flushNotificationsForShutdown: vi.fn(async () => undefined),
prepareForShutdown: vi.fn()
};
controller.megaWebFallback = { dispose: vi.fn() };
controller.realDebridWebFallbacks = new Map();
controller.pendingRealDebridWebAccountIds = new Map();
controller.allDebridWebFallback = { dispose: vi.fn() };
controller.bestDebridWebFallback = { dispose: vi.fn() };
controller.shutdownLogStorage = vi.fn();
controller.audit = vi.fn();
controller.settings = {
historyRetentionMode: "never",
notifyUrl: "https://discord.example.test/webhook",
notifyOnDownloadStall: true,
notifyOnDownloadRecovery: true,
notifyStallAfterSeconds: 90,
notifyStallCooldownMinutes: 10
};
const shutdown = controller.shutdown();
expect(controller.downloadHealthTimer).toBeNull();
expect(controller.notificationOutbox.drainForShutdown).not.toHaveBeenCalled();
runningEvaluation.resolve();
await shutdown;
const closed = loadDownloadHealthState(filePath);
expect(closed.status).toBe("idle");
expect(closed.alertedAt).toBe(0);
expect(closed.restartPending).toBe(false);
const recoveredEvents: unknown[] = [];
const restarted = new DownloadHealthMonitor(filePath);
await restarted.sample(healthSnapshot(1), 120_000, {
stallAfterMs: 90_000,
cooldownMs: 600_000,
notifyOnStall: true,
notifyOnRecovery: true
}, async (event) => { recoveredEvents.push(event); });
await restarted.sample(healthSnapshot(2), 135_000, {
stallAfterMs: 90_000,
cooldownMs: 600_000,
notifyOnStall: true,
notifyOnRecovery: true
}, async (event) => { recoveredEvents.push(event); });
expect(recoveredEvents).toEqual([]);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
+973
View File
@@ -0,0 +1,973 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { NotificationEvent, NotificationOutbox } from "../src/main/notification-outbox";
import { buildPackageNotificationEvent } from "../src/main/notification-events";
import { buildNotifyRequest, sendNotification } from "../src/main/notify";
import { finalizePackageResult } from "../src/main/package-telemetry";
import type { DownloadItem, PackageEntry, PackageResult, PackageTelemetry, RemuxOperationMetric } from "../src/shared/types";
const tempDirs: string[] = [];
afterEach(() => {
vi.useRealTimers();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function createOutboxFile(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-notification-outbox-"));
tempDirs.push(dir);
return path.join(dir, "notification-outbox.json");
}
function event(id: string, overrides: Partial<NotificationEvent> = {}): NotificationEvent {
return {
id,
type: "package_failed",
priority: "error",
createdAt: 1000,
expiresAt: 86401000,
attempts: 0,
nextAttemptAt: 1000,
payload: {
title: "Paket fehlgeschlagen",
description: "Eine Datei ist fehlgeschlagen.",
fields: []
},
...overrides
};
}
function persisted(filePath: string): { events: NotificationEvent[]; lastSuccessAt: number; lastFailureAt: number } {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as { events: NotificationEvent[]; lastSuccessAt: number; lastFailureAt: number };
}
const privateFailureDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET";
const privateFailureValues = [
"https://private.example.test/hook",
"C:/Private/target",
"alice@example.test",
"token=SUPERSECRET"
];
function privateFailureTelemetry(source: "fullStatus" | "remux" | "cleanup"): PackageTelemetry {
const item: DownloadItem = {
id: "item-private",
packageId: "pkg-private",
url: "https://download.example.test/file",
provider: "realdebrid",
status: source === "fullStatus" ? "failed" : "completed",
retries: 0,
speedBps: 0,
downloadedBytes: source === "fullStatus" ? 0 : 1000,
totalBytes: 1000,
progressPercent: source === "fullStatus" ? 0 : 100,
fileName: "file.bin",
targetPath: "C:\\Downloads\\Paket\\file.bin",
resumable: true,
attempts: 1,
lastError: "",
fullStatus: source === "fullStatus" ? privateFailureDetails : "Fertig",
createdAt: 1000,
updatedAt: 2000
};
const packageEntry: PackageEntry = {
id: "pkg-private",
name: "Paket",
outputDir: "C:\\Downloads\\Paket",
extractDir: "C:\\Downloads\\Paket",
status: "failed",
itemIds: [item.id],
cancelled: false,
enabled: true,
downloadStartedAt: 1000,
downloadCompletedAt: 2000,
downloadEndedAt: 2000,
postProcessQueuedAt: 2000,
postProcessStartedAt: 2000,
postProcessCompletedAt: 3000,
terminalAt: 3000,
createdAt: 1000,
updatedAt: 3000
};
const remuxOperations: RemuxOperationMetric[] = source === "remux" ? [{
id: "remux-private",
fileName: "file.mkv",
startedAt: 2000,
completedAt: 3000,
durationMs: 1000,
status: "failed",
errorCategory: privateFailureDetails
}] : [];
return {
package: packageEntry,
items: [item],
archiveOperations: [],
remuxOperations,
outputCount: 0,
cleanupErrorCategory: source === "cleanup" ? privateFailureDetails : ""
};
}
describe("NotificationOutbox", () => {
it("sends due events serially in stable enqueue order and removes each success", async () => {
const filePath = createOutboxFile();
const sent: string[] = [];
const outbox = new NotificationOutbox({
filePath,
now: () => 1000,
send: async (queuedEvent) => {
sent.push(queuedEvent.id);
return true;
}
});
await outbox.enqueue(event("first"));
await outbox.enqueue(event("second", { createdAt: 900 }));
await outbox.enqueue(event("third"));
await outbox.drain(1000);
expect(sent).toEqual(["first", "second", "third"]);
expect(outbox.getStatus()).toEqual({ queued: 0, lastSuccessAt: 1000, lastFailureAt: 0 });
expect(persisted(filePath).events).toEqual([]);
});
it("backs off a failed event without allowing later events to overtake it", async () => {
const filePath = createOutboxFile();
let now = 1000;
const outcomes = [false, true, true];
const sent: string[] = [];
const outbox = new NotificationOutbox({
filePath,
now: () => now,
send: async (queuedEvent) => {
sent.push(queuedEvent.id);
return outcomes.shift() ?? true;
}
});
await outbox.enqueue(event("first"));
await outbox.enqueue(event("second"));
await outbox.drain();
expect(sent).toEqual(["first"]);
expect(persisted(filePath).events[0]).toMatchObject({ id: "first", attempts: 1, nextAttemptAt: 2000 });
expect(outbox.getStatus()).toEqual({ queued: 2, lastSuccessAt: 0, lastFailureAt: 1000 });
now = 1999;
await outbox.drain();
expect(sent).toEqual(["first"]);
now = 2000;
await outbox.drain();
expect(sent).toEqual(["first", "first", "second"]);
});
it("uses the actual failure time for retry backoff", async () => {
const filePath = createOutboxFile();
let now = 1000;
const outbox = new NotificationOutbox({
filePath,
now: () => now,
send: async () => {
now = 4500;
return false;
}
});
await outbox.enqueue(event("late-failure"));
await outbox.drain();
expect(persisted(filePath).events[0]).toMatchObject({ attempts: 1, nextAttemptAt: 5500 });
expect(outbox.getStatus().lastFailureAt).toBe(4500);
});
it("rechecks expiration after each send before delivering the next event", async () => {
const filePath = createOutboxFile();
let now = 1000;
const sent: string[] = [];
const outbox = new NotificationOutbox({
filePath,
now: () => now,
send: async (queuedEvent) => {
sent.push(queuedEvent.id);
now = 2000;
return true;
}
});
await outbox.enqueue(event("first", { expiresAt: 5000 }));
await outbox.enqueue(event("expires-during-send", { expiresAt: 1500 }));
await outbox.drain();
expect(sent).toEqual(["first"]);
expect(outbox.getStatus()).toEqual({ queued: 0, lastSuccessAt: 2000, lastFailureAt: 0 });
});
it("caps exponential retry backoff at ten minutes after many attempts", async () => {
const filePath = createOutboxFile();
let now = 1000;
const outbox = new NotificationOutbox({
filePath,
now: () => now,
send: async () => {
now = 2000;
return false;
}
});
await outbox.enqueue(event("many-attempts", { attempts: 20 }));
await outbox.drain();
expect(persisted(filePath).events[0]).toMatchObject({ attempts: 21, nextAttemptAt: 602000 });
});
it("restores a future retry timer and reads changed URL and mention only when retrying", async () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
const filePath = createOutboxFile();
const firstUrl = "https://discord.example.test/api/webhooks/first";
const secondUrl = "https://discord.example.test/api/webhooks/second";
let settings = { url: firstUrl, mention: "111111" };
const fetchFn = vi.fn()
.mockResolvedValueOnce(new Response("", { status: 404 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
const sender = (queuedEvent: NotificationEvent): Promise<boolean> => sendNotification(settings.url, {
title: queuedEvent.payload.title,
message: queuedEvent.payload.description || "",
mention: settings.mention,
fields: queuedEvent.payload.fields,
timestamp: queuedEvent.createdAt
}, fetchFn, async () => {});
const firstProcess = new NotificationOutbox({ filePath, send: sender });
await firstProcess.enqueue(event("restart-retry"));
await firstProcess.drain();
expect(persisted(filePath).events[0]).toMatchObject({ attempts: 1, nextAttemptAt: 2000 });
settings = { url: secondUrl, mention: "222222" };
const restartedProcess = new NotificationOutbox({ filePath, send: sender, autoDrain: true });
await vi.advanceTimersByTimeAsync(999);
expect(fetchFn).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(fetchFn).toHaveBeenCalledTimes(2);
expect(fetchFn.mock.calls[0][0]).toBe(firstUrl);
expect(fetchFn.mock.calls[1][0]).toBe(secondUrl);
expect(JSON.parse(String(fetchFn.mock.calls[0][1]?.body)).content).toBe("<@111111>");
expect(JSON.parse(String(fetchFn.mock.calls[1][1]?.body)).content).toBe("<@222222>");
await restartedProcess.drain();
expect(persisted(filePath).events).toEqual([]);
});
it("automatically drains new events and retries them at the persisted deadline", async () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
const filePath = createOutboxFile();
const outcomes = [false, true];
const send = vi.fn().mockImplementation(async () => outcomes.shift() ?? true);
const outbox = new NotificationOutbox({ filePath, send, autoDrain: true });
await outbox.enqueue(event("automatic"));
await vi.advanceTimersByTimeAsync(0);
await outbox.drain(1000);
expect(send).toHaveBeenCalledTimes(1);
expect(outbox.getStatus().queued).toBe(1);
await vi.advanceTimersByTimeAsync(999);
expect(send).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(send).toHaveBeenCalledTimes(2);
expect(outbox.getStatus().queued).toBe(0);
});
it("retries a temporary persistence failure before sending without another enqueue", async () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
const filePath = createOutboxFile();
const renameFile = fsp.rename.bind(fsp);
let renameAttempts = 0;
let markRetryStarted = () => {};
const retryStarted = new Promise<void>((resolve) => { markRetryStarted = resolve; });
const rename = vi.spyOn(fsp, "rename").mockImplementation(async (oldPath, newPath) => {
renameAttempts += 1;
if (renameAttempts === 1) {
throw new Error("temporary rename failure");
}
if (renameAttempts === 2) {
markRetryStarted();
}
await renameFile(oldPath, newPath);
});
const persistedBeforeSend: string[][] = [];
let markSendStarted = () => {};
let releaseSend = (_sent: boolean) => {};
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
const sendResult = new Promise<boolean>((resolve) => { releaseSend = resolve; });
const send = vi.fn().mockImplementation(async () => {
persistedBeforeSend.push(fs.existsSync(filePath)
? persisted(filePath).events.map((queuedEvent) => queuedEvent.id)
: []);
markSendStarted();
return sendResult;
});
const outbox = new NotificationOutbox({ filePath, send, autoDrain: true });
try {
await expect(outbox.enqueue(event("persistence-retry"))).rejects.toThrow("temporary rename failure");
expect(outbox.getStatus().queued).toBe(1);
expect(send).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(999);
expect(send).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await retryStarted;
await sendStarted;
const retryDrain = outbox.drain();
releaseSend(true);
await retryDrain;
expect(send).toHaveBeenCalledTimes(1);
expect(persistedBeforeSend).toEqual([["persistence-retry"]]);
expect(outbox.getStatus()).toEqual({ queued: 0, lastSuccessAt: 2000, lastFailureAt: 0 });
expect(persisted(filePath).events).toEqual([]);
} finally {
rename.mockRestore();
}
});
it("backs off repeated persistence retries without a busy loop", async () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
const filePath = createOutboxFile();
const renameFile = fsp.rename.bind(fsp);
const removeFile = fsp.rm.bind(fsp);
let renameAttempts = 0;
let cleanupAttempts = 0;
let markSecondAttemptStarted = () => {};
let markSecondCleanupCompleted = () => {};
const secondAttemptStarted = new Promise<void>((resolve) => { markSecondAttemptStarted = resolve; });
const secondCleanupCompleted = new Promise<void>((resolve) => { markSecondCleanupCompleted = resolve; });
const rename = vi.spyOn(fsp, "rename").mockImplementation(async (oldPath, newPath) => {
renameAttempts += 1;
if (renameAttempts === 1) {
throw new Error("first temporary rename failure");
}
if (renameAttempts === 2) {
markSecondAttemptStarted();
throw new Error("second temporary rename failure");
}
await renameFile(oldPath, newPath);
});
const remove = vi.spyOn(fsp, "rm").mockImplementation(async (targetPath, options) => {
await removeFile(targetPath, options);
cleanupAttempts += 1;
if (cleanupAttempts === 2) {
markSecondCleanupCompleted();
}
});
const sentAttempts: number[] = [];
let markSendStarted = () => {};
let releaseSend = (_sent: boolean) => {};
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
const sendResult = new Promise<boolean>((resolve) => { releaseSend = resolve; });
const send = vi.fn().mockImplementation(async (queuedEvent: NotificationEvent) => {
sentAttempts.push(queuedEvent.attempts);
markSendStarted();
return sendResult;
});
const outbox = new NotificationOutbox({ filePath, send, autoDrain: true });
try {
await expect(outbox.enqueue(event("persistence-backoff"))).rejects.toThrow("first temporary rename failure");
expect(rename).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(999);
expect(rename).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await secondAttemptStarted;
await secondCleanupCompleted;
await Promise.resolve();
expect(rename).toHaveBeenCalledTimes(2);
expect(send).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1999);
expect(rename).toHaveBeenCalledTimes(2);
expect(send).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await sendStarted;
const successfulRetryDrain = outbox.drain();
releaseSend(true);
await successfulRetryDrain;
expect(send).toHaveBeenCalledTimes(1);
expect(sentAttempts).toEqual([0]);
expect(persisted(filePath).events).toEqual([]);
} finally {
remove.mockRestore();
rename.mockRestore();
}
});
it("persists a retry while an earlier delivery remains blocked", async () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
const filePath = createOutboxFile();
const renameFile = fsp.rename.bind(fsp);
let renameAttempts = 0;
let markRecoveryPersisted = () => {};
const recoveryPersisted = new Promise<void>((resolve) => { markRecoveryPersisted = resolve; });
const rename = vi.spyOn(fsp, "rename").mockImplementation(async (oldPath, newPath) => {
const attempt = ++renameAttempts;
if (attempt === 2) {
throw new Error("temporary blocked rename failure");
}
await renameFile(oldPath, newPath);
if (attempt === 3) {
markRecoveryPersisted();
}
});
const sent: string[] = [];
let markSendStarted = () => {};
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
const blockedSend = new Promise<boolean>(() => {});
const outbox = new NotificationOutbox({
filePath,
autoDrain: true,
send: async (queuedEvent) => {
sent.push(queuedEvent.id);
markSendStarted();
return blockedSend;
}
});
try {
await outbox.enqueue(event("blocked-a"));
await vi.advanceTimersByTimeAsync(0);
await sendStarted;
await expect(outbox.enqueue(event("late-b"))).rejects.toThrow("temporary blocked rename failure");
await vi.advanceTimersByTimeAsync(999);
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual(["blocked-a"]);
await vi.advanceTimersByTimeAsync(1);
await recoveryPersisted;
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual(["blocked-a", "late-b"]);
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
expect(sent).toEqual(["blocked-a"]);
expect(outbox.getStatus().queued).toBe(2);
} finally {
rename.mockRestore();
}
});
it("persists through a temporary file and atomic rename", async () => {
const filePath = createOutboxFile();
const rename = vi.spyOn(fsp, "rename");
try {
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
await outbox.enqueue(event("atomic"));
expect(rename).toHaveBeenCalledWith(`${filePath}.tmp`, filePath);
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual(["atomic"]);
} finally {
rename.mockRestore();
}
});
it("persists a cleaned empty legacy file atomically during load", () => {
const filePath = createOutboxFile();
const rename = vi.spyOn(fs, "renameSync");
fs.writeFileSync(filePath, JSON.stringify({
version: 1,
events: [
event("expired-private", {
expiresAt: 999,
payload: {
title: "Paket fehlgeschlagen",
fields: [{ name: "Fehler", value: `Download · ${privateFailureDetails}`, inline: false }]
}
}),
{ id: "", privateSentinel: privateFailureDetails }
],
lastSuccessAt: 0,
lastFailureAt: 0,
privateSentinel: privateFailureDetails
}), "utf8");
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
const raw = fs.readFileSync(filePath, "utf8");
expect(outbox.getStatus().queued).toBe(0);
expect(JSON.parse(raw).events).toEqual([]);
expect(raw).not.toContain("private.example.test");
expect(raw).not.toContain("SUPERSECRET");
expect(rename).toHaveBeenCalledWith(`${filePath}.tmp`, filePath);
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
rename.mockRestore();
});
it("drops expired events before persisting or sending", async () => {
const filePath = createOutboxFile();
const send = vi.fn().mockResolvedValue(true);
const outbox = new NotificationOutbox({ filePath, send, now: () => 2000 });
await outbox.enqueue(event("expired", { expiresAt: 1999 }));
await outbox.drain(2000);
expect(send).not.toHaveBeenCalled();
expect(outbox.getStatus().queued).toBe(0);
expect(persisted(filePath).events).toEqual([]);
});
it("caps the queue at 250 and evicts the oldest success before errors", async () => {
const filePath = createOutboxFile();
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
for (let index = 0; index < 249; index += 1) {
await outbox.enqueue(event(`error-${index}`, { createdAt: 1000 + index }));
}
await outbox.enqueue(event("success-old", { type: "package_completed", priority: "success", createdAt: 500 }));
await outbox.enqueue(event("success-new", { type: "package_completed", priority: "success", createdAt: 2000 }));
const ids = persisted(filePath).events.map((queuedEvent) => queuedEvent.id);
expect(ids).toHaveLength(250);
expect(ids).not.toContain("success-old");
expect(ids).toContain("success-new");
expect(ids.filter((id) => id.startsWith("error-"))).toHaveLength(249);
});
it("never persists webhook or mention fields supplied outside the event contract", async () => {
const filePath = createOutboxFile();
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
const unsafe = {
...event("safe"),
url: "https://discord.example.test/private-webhook",
mention: "@private",
payload: {
...event("safe").payload,
url: "https://discord.example.test/nested-private-webhook",
mention: "@nested-private"
}
} as unknown as NotificationEvent;
await outbox.enqueue(unsafe);
const raw = fs.readFileSync(filePath, "utf8");
expect(raw).not.toContain("private-webhook");
expect(raw).not.toContain("@private");
expect(persisted(filePath).events[0]).toEqual(event("safe"));
});
it("keeps private package failure details out of events, requests, and persisted state", async () => {
const filePath = createOutboxFile();
const privateDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET";
const result: PackageResult = {
packageId: "pkg-private",
name: "Paket",
status: "failed",
startedAt: 1000,
downloadEndedAt: 2000,
postProcessStartedAt: 0,
completedAt: 2000,
downloadDurationSeconds: 1,
extractionDurationSeconds: 0,
remuxDurationSeconds: 0,
postProcessDurationSeconds: 0,
totalDurationSeconds: 1,
totalBytes: 1000,
downloadedBytes: 0,
averageDownloadSpeedBps: 0,
successfulFiles: 0,
failedFiles: 1,
cancelledFiles: 0,
downloadFailures: 1,
offlineFailures: 0,
extractionFailures: 0,
remuxFailures: 0,
cleanupFailures: 0,
archiveCount: 0,
partCount: 0,
outputCount: 0,
failurePhase: "download",
errorCategory: privateDetails,
archiveOperations: [],
remuxOperations: []
};
const notificationEvent = buildPackageNotificationEvent({ generation: 1, result }, 1000);
const request = buildNotifyRequest("https://discord.com/api/webhooks/123/abc", {
title: notificationEvent.payload.title,
message: notificationEvent.payload.description || "",
color: notificationEvent.payload.color,
fields: notificationEvent.payload.fields,
timestamp: notificationEvent.createdAt
});
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
await outbox.enqueue(notificationEvent);
const eventText = JSON.stringify(notificationEvent);
const requestText = String(request.init.body);
const persistedText = fs.readFileSync(filePath, "utf8");
const sensitiveValues = [
"https://private.example.test/hook",
"C:/Private/target",
"alice@example.test",
"token=SUPERSECRET"
];
expect(notificationEvent.payload.fields.find((field) => field.name === "Fehler")?.value).toBe("Download · Download");
for (const sensitiveValue of sensitiveValues) {
expect(eventText).not.toContain(sensitiveValue);
expect(requestText).not.toContain(sensitiveValue);
expect(persistedText).not.toContain(sensitiveValue);
}
});
it.each([
["fullStatus fallback", "fullStatus", "Download · Download"],
["remux operation", "remux", "Remux · Remux"],
["cleanup failure", "cleanup", "Aufräumen · Cleanup"]
] as const)("redacts private %s details through telemetry, Discord request, and persisted outbox", async (_label, source, expectedFailure) => {
const filePath = createOutboxFile();
const result = finalizePackageResult(privateFailureTelemetry(source));
const notificationEvent = buildPackageNotificationEvent({ generation: 1, result }, 3000);
const request = buildNotifyRequest("https://discord.com/api/webhooks/123/abc", {
title: notificationEvent.payload.title,
message: notificationEvent.payload.description || "",
color: notificationEvent.payload.color,
fields: notificationEvent.payload.fields,
timestamp: notificationEvent.createdAt
});
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 3000 });
await outbox.enqueue(notificationEvent);
const eventText = JSON.stringify(notificationEvent);
const requestText = String(request.init.body);
const persistedText = fs.readFileSync(filePath, "utf8");
expect(notificationEvent.payload.fields.find((field) => field.name === "Fehler")?.value).toBe(expectedFailure);
for (const sensitiveValue of privateFailureValues) {
expect(eventText).not.toContain(sensitiveValue);
expect(requestText).not.toContain(sensitiveValue);
expect(persistedText).not.toContain(sensitiveValue);
}
});
it("projects private failure details from an existing outbox before persisting again", async () => {
const filePath = createOutboxFile();
const privateDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET";
const legacyEvent = event("legacy-private", {
payload: {
title: "Paket fehlgeschlagen",
description: "Paket",
fields: [{ name: "Fehler", value: `Download · ${privateDetails}`, inline: false }]
}
});
fs.writeFileSync(filePath, JSON.stringify({
version: 1,
events: [legacyEvent],
lastSuccessAt: 0,
lastFailureAt: 0
}), "utf8");
const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 });
await outbox.enqueue(event("safe"));
const persistedText = fs.readFileSync(filePath, "utf8");
expect(persisted(filePath).events[0].payload.fields[0]?.value).toBe("Download · Download");
expect(persistedText).not.toContain("https://private.example.test/hook");
expect(persistedText).not.toContain("C:/Private/target");
expect(persistedText).not.toContain("alice@example.test");
expect(persistedText).not.toContain("token=SUPERSECRET");
});
it("redacts a legacy persisted failure before automatic drain and Discord request creation", async () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
const filePath = createOutboxFile();
const legacyEvent = event("legacy-private-auto-drain", {
payload: {
title: "Paket fehlgeschlagen",
description: "Paket",
fields: [{ name: "Fehler", value: `Download · ${privateFailureDetails}`, inline: false }]
}
});
fs.writeFileSync(filePath, JSON.stringify({
version: 1,
events: [legacyEvent],
lastSuccessAt: 0,
lastFailureAt: 0
}), "utf8");
const sentEvents: NotificationEvent[] = [];
const requestBodies: string[] = [];
const outbox = new NotificationOutbox({
filePath,
autoDrain: true,
send: async (queuedEvent) => {
sentEvents.push(queuedEvent);
requestBodies.push(String(buildNotifyRequest("https://discord.com/api/webhooks/123/abc", {
title: queuedEvent.payload.title,
message: queuedEvent.payload.description || "",
color: queuedEvent.payload.color,
fields: queuedEvent.payload.fields,
timestamp: queuedEvent.createdAt
}).init.body));
return true;
}
});
await vi.advanceTimersByTimeAsync(0);
await outbox.drain(1000);
expect(sentEvents).toHaveLength(1);
expect(sentEvents[0].payload.fields[0]?.value).toBe("Download · Download");
expect(outbox.getStatus().queued).toBe(0);
expect(persisted(filePath).events).toEqual([]);
const emittedText = `${JSON.stringify(sentEvents)} ${requestBodies.join(" ")} ${fs.readFileSync(filePath, "utf8")}`;
for (const sensitiveValue of privateFailureValues) {
expect(emittedText).not.toContain(sensitiveValue);
}
});
it("persists an enqueue while an earlier delivery is blocked", async () => {
const filePath = createOutboxFile();
let releaseSend = (_sent: boolean) => {};
let markSendStarted = () => {};
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
const sendResult = new Promise<boolean>((resolve) => { releaseSend = resolve; });
const outbox = new NotificationOutbox({
filePath,
send: async () => {
markSendStarted();
return sendResult;
},
now: () => 1000
});
await outbox.enqueue(event("blocked"));
const draining = outbox.drain();
await sendStarted;
const lateEnqueue = outbox.enqueue(event("late-digest", {
type: "package_completed",
priority: "success"
}));
const persistedBeforeRelease = await Promise.race([
lateEnqueue.then(() => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 25))
]);
const stateBeforeRelease = persisted(filePath);
releaseSend(true);
await draining;
await lateEnqueue;
expect(persistedBeforeRelease).toBe(true);
expect(stateBeforeRelease.events.map((queuedEvent) => queuedEvent.id)).toContain("late-digest");
});
it("acknowledges a successful in-flight delivery after a parallel enqueue expires it", async () => {
const filePath = createOutboxFile();
let now = 1000;
let releaseSend = (_sent: boolean) => {};
let markSendStarted = () => {};
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
const sendResult = new Promise<boolean>((resolve) => { releaseSend = resolve; });
const delivered: Array<{ id: string; deliveredAt: number }> = [];
const outbox = new NotificationOutbox({
filePath,
now: () => now,
send: async () => {
markSendStarted();
return sendResult;
},
onDelivered: (queuedEvent, deliveredAt) => {
delivered.push({ id: queuedEvent.id, deliveredAt });
}
});
await outbox.enqueue(event("expires-in-flight", { expiresAt: 1500 }));
const draining = outbox.drain();
await sendStarted;
now = 2000;
await outbox.enqueue(event("late", { createdAt: 2000, nextAttemptAt: 3000 }));
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toContain("expires-in-flight");
releaseSend(true);
await draining;
expect(delivered).toEqual([{ id: "expires-in-flight", deliveredAt: 2000 }]);
expect(outbox.getStatus()).toEqual({ queued: 1, lastSuccessAt: 2000, lastFailureAt: 0 });
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual(["late"]);
});
it("keeps a failed in-flight delivery queued for retry during a capacity-enforcing enqueue", async () => {
const filePath = createOutboxFile();
const queuedEvents = [
event("fails-in-flight", { createdAt: 1 }),
...Array.from({ length: 249 }, (_, index) => event(`queued-${index}`, { createdAt: index + 2 }))
];
fs.writeFileSync(filePath, JSON.stringify({
version: 1,
events: queuedEvents,
lastSuccessAt: 0,
lastFailureAt: 0
}), "utf8");
let releaseSend = (_sent: boolean) => {};
let markSendStarted = () => {};
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
const sendResult = new Promise<boolean>((resolve) => { releaseSend = resolve; });
const outbox = new NotificationOutbox({
filePath,
now: () => 1000,
send: async () => {
markSendStarted();
return sendResult;
}
});
const draining = outbox.drain();
await sendStarted;
await outbox.enqueue(event("late-capacity", { createdAt: 10000 }));
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toContain("fails-in-flight");
releaseSend(false);
await draining;
const state = persisted(filePath);
expect(state.events).toHaveLength(250);
expect(state.events[0]).toMatchObject({ id: "fails-in-flight", attempts: 1, nextAttemptAt: 2000 });
expect(state.lastFailureAt).toBe(1000);
});
it("acknowledges only successful delivery with its actual completion time", async () => {
const filePath = createOutboxFile();
let now = 1000;
const delivered: Array<{ id: string; deliveredAt: number }> = [];
const failedOutbox = new NotificationOutbox({
filePath,
now: () => now,
send: async () => {
now = 2000;
return false;
},
onDelivered: (queuedEvent, deliveredAt) => {
delivered.push({ id: queuedEvent.id, deliveredAt });
}
});
await failedOutbox.enqueue(event("failed"));
await failedOutbox.drain();
expect(delivered).toEqual([]);
const deliveredFilePath = createOutboxFile();
now = 3000;
const deliveredOutbox = new NotificationOutbox({
filePath: deliveredFilePath,
now: () => now,
send: async () => true,
onDelivered: (queuedEvent, deliveredAt) => {
delivered.push({ id: queuedEvent.id, deliveredAt });
}
});
await deliveredOutbox.enqueue(event("delivered", { nextAttemptAt: 3000 }));
await deliveredOutbox.drain();
expect(delivered).toEqual([{ id: "delivered", deliveredAt: 3000 }]);
});
it("does not redeliver or block later events when delivery acknowledgement fails", async () => {
const filePath = createOutboxFile();
const sent: string[] = [];
const outbox = new NotificationOutbox({
filePath,
now: () => 1000,
send: async (queuedEvent) => {
sent.push(queuedEvent.id);
return true;
},
onDelivered: (queuedEvent) => {
if (queuedEvent.id === "first") {
throw new Error("health state unavailable");
}
}
});
await outbox.enqueue(event("first"));
await outbox.enqueue(event("second"));
await expect(outbox.drain()).resolves.toBeUndefined();
expect(sent).toEqual(["first", "second"]);
expect(persisted(filePath).events).toEqual([]);
});
it("returns after the default three-second shutdown budget when sending hangs", async () => {
vi.useFakeTimers();
const filePath = createOutboxFile();
const outbox = new NotificationOutbox({ filePath, send: async () => new Promise<boolean>(() => {}), now: () => 1000 });
await outbox.enqueue(event("hanging"));
let completed = false;
const draining = outbox.drainForShutdown().then(() => { completed = true; });
await vi.advanceTimersByTimeAsync(2999);
expect(completed).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await draining;
expect(completed).toBe(true);
expect(outbox.getStatus().queued).toBe(1);
});
it("persists a retry during shutdown while an earlier delivery remains blocked", async () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
const filePath = createOutboxFile();
const renameFile = fsp.rename.bind(fsp);
let renameAttempts = 0;
let markRecoveryPersisted = () => {};
const recoveryPersisted = new Promise<void>((resolve) => { markRecoveryPersisted = resolve; });
const rename = vi.spyOn(fsp, "rename").mockImplementation(async (oldPath, newPath) => {
const attempt = ++renameAttempts;
if (attempt === 2) {
throw new Error("temporary shutdown rename failure");
}
await renameFile(oldPath, newPath);
if (attempt === 3) {
markRecoveryPersisted();
}
});
const sent: string[] = [];
let markSendStarted = () => {};
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
const blockedSend = new Promise<boolean>(() => {});
const outbox = new NotificationOutbox({
filePath,
autoDrain: true,
send: async (queuedEvent) => {
sent.push(queuedEvent.id);
markSendStarted();
return blockedSend;
}
});
try {
await outbox.enqueue(event("shutdown-a"));
await vi.advanceTimersByTimeAsync(0);
await sendStarted;
await expect(outbox.enqueue(event("shutdown-b"))).rejects.toThrow("temporary shutdown rename failure");
let completed = false;
const shutdown = outbox.drainForShutdown(100).then(() => { completed = true; });
await recoveryPersisted;
expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual(["shutdown-a", "shutdown-b"]);
expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
expect(sent).toEqual(["shutdown-a"]);
expect(completed).toBe(false);
await vi.advanceTimersByTimeAsync(99);
expect(completed).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await shutdown;
expect(completed).toBe(true);
expect(sent).toEqual(["shutdown-a"]);
} finally {
rename.mockRestore();
}
});
});
+890 -153
View File
File diff suppressed because it is too large Load Diff
+70 -30
View File
@@ -52,26 +52,66 @@ describe("truncateContent", () => {
});
});
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("buildNotifyRequest", () => {
it("builds a bounded Discord embed with the product username", () => {
const req = buildNotifyRequest(` ${WEBHOOK} `, {
title: "Paket fehlgeschlagen",
message: "Eine Datei konnte nicht verarbeitet werden.",
color: 0xe74c3c,
fields: [
{ name: "Ergebnis", value: "Fehlgeschlagen", inline: true },
{ name: "Dateien", value: "0 erfolgreich, 1 fehlgeschlagen", inline: true }
],
timestamp: 1000
});
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).toEqual({
username: "Multi-Debrid Downloader",
content: "",
embeds: [{
title: "Paket fehlgeschlagen",
description: "Eine Datei konnte nicht verarbeitet werden.",
color: 0xe74c3c,
fields: [
{ name: "Ergebnis", value: "Fehlgeschlagen", inline: true },
{ name: "Dateien", value: "0 erfolgreich, 1 fehlgeschlagen", inline: true }
],
timestamp: "1970-01-01T00:00:01.000Z"
}]
});
});
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>");
expect(body.embeds[0]).toMatchObject({ title: "T", description: "M" });
});
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("");
});
it("enforces Discord title, description, field, and total embed limits", () => {
const req = buildNotifyRequest(WEBHOOK, {
title: "T".repeat(400),
message: "M".repeat(5000),
fields: Array.from({ length: 30 }, (_, index) => ({
name: `${index}-${"N".repeat(300)}`,
value: "V".repeat(1400)
}))
});
const body = JSON.parse(String(req.init.body));
const embed = body.embeds[0] as { title: string; description: string; fields: Array<{ name: string; value: string }> };
expect(embed.title.length).toBeLessThanOrEqual(256);
expect(embed.description.length).toBeLessThanOrEqual(4096);
expect(embed.fields.length).toBeLessThanOrEqual(25);
expect(embed.fields.every((field) => field.name.length <= 256 && field.value.length <= 1024)).toBe(true);
const total = embed.title.length + embed.description.length + embed.fields.reduce((sum, field) => sum + field.name.length + field.value.length, 0);
expect(total).toBeLessThanOrEqual(6000);
});
});
describe("sendNotification", () => {
@@ -104,20 +144,20 @@ describe("sendNotification", () => {
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 });
});
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)).embeds[0].title);
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"]);
});
];
await expect(Promise.all(sends)).resolves.toEqual([true, true, true]);
expect(order).toEqual(["1", "2", "3"]);
});
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);
+2
View File
@@ -20,6 +20,7 @@ afterEach(async () => {
function settings(): AppSettings {
return {
token: "rd-secret-token",
deepbridApiKey: "fixture-deepbrid-online-key-6jK8",
megaLogin: "backup-user",
megaPassword: "backup-password",
outputDir: "D:\\Downloads",
@@ -45,6 +46,7 @@ describe("online backup key", () => {
const parsed = parseOnlineBackupKey(created.key);
expect(serialized).not.toContain("rd-secret-token");
expect(serialized).not.toContain("fixture-deepbrid-online-key-6jK8");
expect(serialized).not.toContain("backup-password");
expect(serialized).not.toContain(parsed.masterKey.toString("base64url"));
expect(Object.keys(created.record).sort()).toEqual(["blob", "deleteVerifier", "id"]);
+412
View File
@@ -0,0 +1,412 @@
import { describe, expect, it } from "vitest";
import type {
ArchiveOperationMetric,
DownloadItem,
PackageEntry,
PackageTelemetry,
RemuxOperationMetric
} from "../src/shared/types";
import {
durationMsToSeconds,
durationSecondsBetween,
finalizePackageResult
} from "../src/main/package-telemetry";
import { buildRunResult } from "../src/main/notification-events";
import { normalizeLoadedSession } from "../src/main/storage";
function packageEntry(overrides: Partial<PackageEntry> = {}): PackageEntry {
return {
id: "pkg-1",
name: "Paket",
outputDir: "C:\\Downloads\\Paket",
extractDir: "C:\\Downloads\\Paket",
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
downloadStartedAt: 1_000,
downloadCompletedAt: 121_000,
downloadEndedAt: 121_000,
postProcessQueuedAt: 122_000,
postProcessStartedAt: 130_000,
postProcessCompletedAt: 160_000,
terminalAt: 166_000,
createdAt: 1_000,
updatedAt: 166_000,
...overrides
};
}
function downloadItem(id: string, status: DownloadItem["status"] = "completed"): DownloadItem {
return {
id,
packageId: "pkg-1",
url: `https://example.test/${id}`,
provider: "realdebrid",
status,
retries: 0,
speedBps: 0,
downloadedBytes: status === "completed" ? 1_000 : 0,
totalBytes: 1_000,
progressPercent: status === "completed" ? 100 : 0,
fileName: `${id}.rar`,
targetPath: `C:\\Downloads\\Paket\\${id}.rar`,
resumable: true,
attempts: 1,
lastError: status === "failed" ? "download-error" : "",
fullStatus: "",
createdAt: 1_000,
updatedAt: 121_000
};
}
function archiveOperation(overrides: Partial<ArchiveOperationMetric> = {}): ArchiveOperationMetric {
return {
id: "archive-1",
name: "Paket.part01.rar",
itemIds: ["item-1"],
partCount: 1,
startedAt: 130_000,
completedAt: 160_000,
durationMs: 30_000,
status: "completed",
errorCategory: "",
...overrides
};
}
function remuxOperation(overrides: Partial<RemuxOperationMetric> = {}): RemuxOperationMetric {
return {
id: "remux-1",
fileName: "episode.mkv",
startedAt: 150_000,
completedAt: 155_000,
durationMs: 5_000,
status: "completed",
errorCategory: "",
...overrides
};
}
function telemetry(overrides: Partial<PackageTelemetry> = {}): PackageTelemetry {
const items = [downloadItem("item-1")];
return {
package: packageEntry({ itemIds: items.map((item) => item.id) }),
items,
archiveOperations: [],
remuxOperations: [],
outputCount: 0,
cleanupErrorCategory: "",
...overrides
};
}
describe("package lifecycle telemetry", () => {
it("finalizes a successful package from explicit timestamps and operation durations", () => {
const items = [downloadItem("item-1"), downloadItem("item-2")];
const result = finalizePackageResult(telemetry({
package: packageEntry({ itemIds: items.map((item) => item.id) }),
items,
archiveOperations: [archiveOperation({ itemIds: items.map((item) => item.id), partCount: 2 })],
remuxOperations: [remuxOperation()],
outputCount: 2
}));
expect(result).toEqual(expect.objectContaining({
packageId: "pkg-1",
status: "completed",
downloadDurationSeconds: 120,
extractionDurationSeconds: 30,
remuxDurationSeconds: 5,
postProcessDurationSeconds: 30,
totalDurationSeconds: 165,
successfulFiles: 2,
failedFiles: 0,
cancelledFiles: 0,
archiveCount: 1,
partCount: 2,
outputCount: 2,
failurePhase: null,
averageDownloadSpeedBps: 16
}));
});
it("counts a failed 16-part archive as one failed file and produces a partial result", () => {
const items = Array.from({ length: 16 }, (_, index) => downloadItem(`item-${index + 1}`));
const result = finalizePackageResult(telemetry({
package: packageEntry({ itemIds: items.map((item) => item.id) }),
items,
archiveOperations: [archiveOperation({
itemIds: items.map((item) => item.id),
partCount: 16,
status: "failed",
errorCategory: "checksum"
})]
}));
expect(result).toEqual(expect.objectContaining({
status: "partial",
downloadDurationSeconds: 120,
extractionDurationSeconds: 30,
totalDurationSeconds: 165,
successfulFiles: 15,
failedFiles: 1,
partCount: 16,
archiveCount: 1,
failurePhase: "extract",
errorCategory: "Entpacken"
}));
});
it("classifies a package with no successful files and a download failure as failed", () => {
const item = downloadItem("item-1", "failed");
const result = finalizePackageResult(telemetry({
package: packageEntry({ status: "failed", itemIds: [item.id] }),
items: [item]
}));
expect(result).toEqual(expect.objectContaining({
status: "failed",
successfulFiles: 0,
failedFiles: 1,
cancelledFiles: 0,
failurePhase: "download",
errorCategory: "Download"
}));
});
it("includes immediately cleaned successes and bytes in a mixed package result", () => {
const failedItem = downloadItem("item-3", "failed");
const result = finalizePackageResult(telemetry({
package: packageEntry({
status: "failed",
itemIds: [failedItem.id],
cleanedCompletedItemCount: 2,
cleanedDownloadedBytes: 2_000,
cleanedTotalBytes: 2_000
}),
items: [failedItem]
}));
expect(result).toEqual(expect.objectContaining({
status: "partial",
successfulFiles: 2,
failedFiles: 1,
downloadedBytes: 2_000,
totalBytes: 3_000
}));
});
it("classifies a failed download without error text as a download-phase failure", () => {
const item = { ...downloadItem("item-1", "failed"), lastError: "", fullStatus: "" };
const result = finalizePackageResult(telemetry({
package: packageEntry({ status: "failed", itemIds: [item.id] }),
items: [item]
}));
expect(result).toEqual(expect.objectContaining({
status: "failed",
failedFiles: 1,
failurePhase: "download",
errorCategory: "Download"
}));
});
it("projects private download failure details to a fixed category", () => {
const privateDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET";
const item = {
...downloadItem("item-1", "failed"),
lastError: privateDetails,
fullStatus: privateDetails
};
const result = finalizePackageResult(telemetry({
package: packageEntry({ status: "failed", itemIds: [item.id] }),
items: [item]
}));
expect(result.errorCategory).toBe("Download");
expect(result.errorCategory).not.toContain("private.example.test");
expect(result.errorCategory).not.toContain("C:/Private/target");
expect(result.errorCategory).not.toContain("alice@example.test");
expect(result.errorCategory).not.toContain("SUPERSECRET");
});
it.each([
["Host ist offline", "Offline"],
["Request ETIMEDOUT", "Timeout"],
["socket ECONNRESET", "Netzwerk"],
["ENOSPC: no space left on device", "Speicherplatz"],
["EACCES: permission denied", "Berechtigung"],
["nicht näher klassifizierbar", "Download"]
])("maps download failure detail %s to %s", (detail, expectedCategory) => {
const item = { ...downloadItem("item-1", "failed"), lastError: detail };
const result = finalizePackageResult(telemetry({
package: packageEntry({ status: "failed", itemIds: [item.id] }),
items: [item]
}));
expect(result.errorCategory).toBe(expectedCategory);
});
it("classifies a package with only cancelled work as cancelled", () => {
const item = downloadItem("item-1", "cancelled");
const result = finalizePackageResult(telemetry({
package: packageEntry({ status: "cancelled", cancelled: true, itemIds: [item.id] }),
items: [item]
}));
expect(result).toEqual(expect.objectContaining({
status: "cancelled",
successfulFiles: 0,
failedFiles: 0,
cancelledFiles: 1,
failurePhase: null
}));
});
it("reports zero postprocess durations when no postprocess phase ran", () => {
const result = finalizePackageResult(telemetry({
package: packageEntry({
downloadCompletedAt: 61_000,
downloadEndedAt: 61_000,
postProcessQueuedAt: undefined,
postProcessStartedAt: undefined,
postProcessCompletedAt: undefined,
terminalAt: 61_000,
updatedAt: 61_000
})
}));
expect(result).toEqual(expect.objectContaining({
downloadDurationSeconds: 60,
extractionDurationSeconds: 0,
remuxDurationSeconds: 0,
postProcessDurationSeconds: 0,
totalDurationSeconds: 60
}));
});
it("retains legacy download timing after session normalization", () => {
const item = { ...downloadItem("item-1"), downloadedBytes: 240_000, totalBytes: 240_000 };
const legacyPackage = packageEntry({
itemIds: [item.id],
downloadStartedAt: 1_000,
downloadCompletedAt: 121_000,
terminalAt: 121_000,
updatedAt: 121_000
});
delete legacyPackage.downloadEndedAt;
const normalized = normalizeLoadedSession({
version: 2,
packageOrder: [legacyPackage.id],
packages: { [legacyPackage.id]: legacyPackage },
items: { [item.id]: item },
runStartedAt: 1_000,
totalDownloadedBytes: 240_000,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: false,
updatedAt: 121_000
});
const normalizedPackage = normalized.packages[legacyPackage.id];
const result = finalizePackageResult({
package: normalizedPackage,
items: normalizedPackage.itemIds.map((itemId) => normalized.items[itemId])
});
expect(result).toEqual(expect.objectContaining({
downloadEndedAt: 121_000,
downloadDurationSeconds: 120,
averageDownloadSpeedBps: 2_000
}));
});
it("uses cleanup, remux, extract, and download as deterministic failure precedence", () => {
const failedItem = downloadItem("item-1", "failed");
const failedArchive = archiveOperation({ status: "failed", errorCategory: "archive-error" });
const failedRemux = remuxOperation({ status: "failed", errorCategory: "remux-error" });
const failures = [
finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive], remuxOperations: [failedRemux], cleanupErrorCategory: "cleanup-error" })),
finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive], remuxOperations: [failedRemux] })),
finalizePackageResult(telemetry({ items: [failedItem], archiveOperations: [failedArchive] })),
finalizePackageResult(telemetry({ items: [failedItem] }))
];
expect(failures.map(({ failurePhase, errorCategory }) => ({ failurePhase, errorCategory }))).toEqual([
{ failurePhase: "cleanup", errorCategory: "Cleanup" },
{ failurePhase: "remux", errorCategory: "Remux" },
{ failurePhase: "extract", errorCategory: "Entpacken" },
{ failurePhase: "download", errorCategory: "Download" }
]);
});
it("retains additive failure counters when cleanup is the primary failure phase", () => {
const failedItem = { ...downloadItem("item-1", "failed"), lastError: "Host ist offline" };
const packageResult = finalizePackageResult(telemetry({
items: [failedItem],
archiveOperations: [archiveOperation({ status: "failed", errorCategory: "archive-error" })],
remuxOperations: [remuxOperation({ status: "failed", errorCategory: "remux-error" })],
cleanupErrorCategory: "cleanup-error"
}));
const runResult = buildRunResult({
id: "mixed-failures",
stopped: false,
startedAt: 1_000,
completedAt: 166_000,
packages: [packageResult]
});
expect(packageResult).toEqual(expect.objectContaining({
failurePhase: "cleanup",
downloadFailures: 1,
offlineFailures: 1,
extractionFailures: 1,
remuxFailures: 1,
cleanupFailures: 1
}));
expect(runResult).toEqual(expect.objectContaining({
downloadFailures: 1,
offlineFailures: 1,
extractionFailures: 1,
remuxFailures: 1,
cleanupFailures: 1
}));
});
it("uses audio-strip outcomes when no individual remux operation was recorded", () => {
const items = [downloadItem("item-1"), downloadItem("item-2")];
const result = finalizePackageResult(telemetry({
package: packageEntry({
itemIds: items.map((item) => item.id),
audioStripSummary: {
at: 160_000,
candidates: 2,
remuxed: 1,
keptSingle: 0,
skippedNoGerman: 0,
skippedNoTool: 0,
failed: 1,
files: []
}
}),
items
}));
expect(result).toEqual(expect.objectContaining({
status: "partial",
successfulFiles: 1,
failedFiles: 1,
failurePhase: "remux"
}));
});
it("clamps invalid and reversed durations instead of producing negative or non-finite values", () => {
expect(durationMsToSeconds(1_999)).toBe(1);
expect(durationMsToSeconds(-1)).toBe(0);
expect(durationMsToSeconds(Number.NaN)).toBe(0);
expect(durationSecondsBetween(5_000, 4_000)).toBe(0);
expect(durationSecondsBetween(undefined, 5_000)).toBe(0);
});
});
+15
View File
@@ -8,6 +8,7 @@ import {
getRealDebridAccountDailyUsageBytes,
getRealDebridAccountTotalUsageBytes,
isRealDebridAccountDailyLimitReached,
resetProviderDailyUsage,
resetRealDebridAccountDailyUsage
} from "../src/shared/provider-daily-limits";
@@ -67,3 +68,17 @@ describe("Real-Debrid account usage", () => {
expect(next.realDebridAccountDailyUsageBytes).toEqual({ rda_two: 200 });
});
});
describe("Deepbrid provider usage", () => {
it("resets only Deepbrid daily usage while preserving other providers", () => {
const settings = {
...defaultSettings(),
providerDailyUsageDay: getProviderUsageDayKey(),
providerDailyUsageBytes: { deepbrid: 300, alldebrid: 900 }
};
const next = resetProviderDailyUsage(settings, "deepbrid");
expect(next.providerDailyUsageBytes).toEqual({ alldebrid: 900 });
});
});
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { parseDebridLinkTerminalFailure } from "../src/main/download-manager";
import { classifyProviderUnrestrictBackoff, isProviderUnrestrictFailure, parseDebridLinkTerminalFailure } from "../src/main/download-manager";
describe("provider error classification", () => {
it("does not relabel an aggregated Real-Debrid failure as Debrid-Link", () => {
@@ -13,4 +13,15 @@ describe("provider error classification", () => {
kind: "no_active_key"
});
});
it.each([
["Deepbrid-Anfrage fehlgeschlagen (rate_limit, HTTP 429, Code 429)", "busy"],
["Deepbrid-Anfrage fehlgeschlagen (temporary, HTTP 503, Code 503)", "temporary"],
["Deepbrid-Anfrage fehlgeschlagen (auth, HTTP 401, Code 401)", null],
["Deepbrid-Anfrage fehlgeschlagen (link, HTTP 400, Code 17)", null],
["Deepbrid-Anfrage fehlgeschlagen (malformed, HTTP 200, Code 200)", null]
])("classifies Deepbrid backoff precisely for %s", (message, expected) => {
expect(classifyProviderUnrestrictBackoff(message)).toBe(expected);
expect(isProviderUnrestrictFailure(message)).toBe(true);
});
});
+503
View File
@@ -0,0 +1,503 @@
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, vi } from "vitest";
import { DownloadManager } from "../src/main/download-manager";
import { defaultSettings } from "../src/main/constants";
import {
evaluateRemainingThreshold,
type RunRemainingSnapshot
} from "../src/main/notification-events";
import type { NotificationEvent } from "../src/main/notification-outbox";
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 type { AppSettings, PackageEntry } from "../src/shared/types";
const GIB = 1024 ** 3;
const tempDirs: string[] = [];
afterEach(() => {
vi.restoreAllMocks();
shutdownItemLogs();
shutdownPackageLogs();
shutdownRenameLog();
for (const dir of tempDirs.splice(0)) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
function snapshot(overrides: Partial<RunRemainingSnapshot> = {}): RunRemainingSnapshot {
return {
remainingBytes: 51 * GIB,
openItems: 2,
openPackages: 1,
unknownCount: 0,
finalizingItems: 0,
speedBps: 1024 ** 2,
etaSeconds: 51 * 1024,
...overrides
};
}
function setupManager(settings: Partial<AppSettings> = {}, session = emptySession()): {
manager: DownloadManager;
session: ReturnType<typeof emptySession>;
events: NotificationEvent[];
settings: AppSettings;
} {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-remaining-"));
tempDirs.push(root);
const events: NotificationEvent[] = [];
const resolvedSettings = {
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "out"),
extractDir: path.join(root, "extract"),
notifyUrl: "https://discord.com/api/webhooks/123/abc",
notifyOnPackageCompleted: false,
notifyOnPackageFailed: false,
notifyOnRunFinished: false,
notifyOnRemainingBelow: true,
notifyRemainingThresholdGb: 50,
autoExtract: false,
...settings
};
const manager = new DownloadManager(
resolvedSettings,
session,
createStoragePaths(path.join(root, "state")),
{
enqueueNotification: async (event: NotificationEvent) => {
events.push(event);
}
}
);
return { manager, session, events, settings: resolvedSettings };
}
function addPackage(
session: ReturnType<typeof emptySession>,
packageId: string,
totalBytes: number | null,
downloadedBytes = 0,
enabled = true
): PackageEntry {
const now = Date.now();
const itemId = `${packageId}-item`;
const pkg: PackageEntry = {
id: packageId,
name: packageId,
outputDir: `C:/out/${packageId}`,
extractDir: `C:/extract/${packageId}`,
status: "queued",
itemIds: [itemId],
cancelled: false,
enabled,
priority: "normal",
createdAt: now,
updatedAt: now
};
session.packages[packageId] = pkg;
session.packageOrder.push(packageId);
session.items[itemId] = {
id: itemId,
packageId,
url: `https://dummy/${packageId}`,
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes,
totalBytes,
progressPercent: totalBytes && totalBytes > 0 ? Math.floor((downloadedBytes / totalBytes) * 100) : 0,
fileName: `${packageId}.bin`,
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt: now,
updatedAt: now
};
return pkg;
}
function internal(manager: DownloadManager): any {
return manager as any;
}
async function flushNotifications(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
describe("remaining threshold evaluation", () => {
it("emits when known remaining bytes cross from above to exactly the threshold", () => {
const previous = snapshot();
const current = snapshot({ remainingBytes: 50 * GIB, etaSeconds: 50 * 1024 });
expect(evaluateRemainingThreshold(previous, current, 50 * GIB)).toEqual({
emit: true,
remainingBytes: 50 * GIB
});
});
it("blocks a crossing when either snapshot contains an unknown open size", () => {
const above = snapshot();
const below = snapshot({ remainingBytes: 49 * GIB, etaSeconds: 49 * 1024 });
expect(evaluateRemainingThreshold(above, { ...below, unknownCount: 1 }, 50 * GIB)).toEqual({ emit: false });
expect(evaluateRemainingThreshold({ ...above, unknownCount: 1 }, below, 50 * GIB)).toEqual({ emit: false });
});
it("suppresses a crossing when no open run item remains", () => {
expect(evaluateRemainingThreshold(
snapshot(),
snapshot({ remainingBytes: 0, openItems: 0, openPackages: 0, etaSeconds: 0 }),
50 * GIB
)).toEqual({ emit: false });
});
it("emits once per crossing and re-arms only after remaining work rises above the threshold", () => {
const above = snapshot();
const below = snapshot({ remainingBytes: 49 * GIB, etaSeconds: 49 * 1024 });
expect(evaluateRemainingThreshold(above, below, 50 * GIB).emit).toBe(true);
expect(evaluateRemainingThreshold(below, below, 50 * GIB).emit).toBe(false);
expect(evaluateRemainingThreshold(below, above, 50 * GIB).emit).toBe(false);
expect(evaluateRemainingThreshold(above, below, 50 * GIB).emit).toBe(true);
});
it("does not synthesize a crossing for a new or restored run that first appears below the threshold", () => {
const below = snapshot({ remainingBytes: 49 * GIB, etaSeconds: 49 * 1024 });
expect(evaluateRemainingThreshold(null, below, 50 * GIB)).toEqual({ emit: false });
});
});
describe("run-scoped remaining notifications", () => {
it("waits for real HTTP finalization before crossing and keeps genuine remaining work in the event", async () => {
const payload = Buffer.alloc(256 * 1024, 7);
const server = http.createServer((_request, response) => {
response.statusCode = 200;
response.setHeader("Accept-Ranges", "bytes");
response.setHeader("Content-Length", String(payload.length));
response.end(payload);
});
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 { manager, session, events, settings } = setupManager({
notifyRemainingThresholdGb: (128 * 1024) / GIB
});
const downloadingPackage = addPackage(session, "http-final-package", payload.length);
downloadingPackage.outputDir = path.join(settings.outputDir, downloadingPackage.id);
downloadingPackage.extractDir = path.join(settings.extractDir, downloadingPackage.id);
const remainingPackage = addPackage(session, "genuine-remaining-package", 64 * 1024);
remainingPackage.outputDir = path.join(settings.outputDir, remainingPackage.id);
remainingPackage.extractDir = path.join(settings.extractDir, remainingPackage.id);
const downloadingItem = session.items[downloadingPackage.itemIds[0]];
const state = internal(manager);
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
state.debridService.unrestrictLink = vi.fn(async () => ({
fileName: downloadingItem.fileName,
directUrl: `http://127.0.0.1:${address.port}/download`,
fileSize: payload.length,
retriesUsed: 0,
provider: "realdebrid",
providerLabel: "Real-Debrid"
}));
const eventStatuses: string[] = [];
state.enqueueNotificationCallback = async (event: NotificationEvent) => {
events.push(event);
eventStatuses.push(downloadingItem.status);
};
try {
await manager.start();
const active = {
itemId: downloadingItem.id,
packageId: downloadingPackage.id,
abortController: new AbortController(),
abortReason: "none",
resumable: true,
nonResumableCounted: false,
stallRetries: 0,
genericErrorRetries: 0,
unrestrictRetries: 0
};
state.activeTasks.set(downloadingItem.id, active);
await state.processItem(active);
await flushNotifications();
expect(downloadingItem.status).toBe("completed");
expect(events.filter((event) => event.type === "remaining_threshold_crossed")).toHaveLength(1);
expect(eventStatuses).toEqual(["completed"]);
expect(events[0].payload.fields).toContainEqual({ name: "Restmenge", value: "64 KB", inline: true });
expect(events[0].payload.fields).toContainEqual({ name: "Offene Dateien", value: "1", inline: true });
} finally {
manager.stop();
server.close();
await once(server, "close");
}
}, 15_000);
it("creates a stable run context when public download work joins a postprocess-only start", async () => {
const { manager, session, events } = setupManager();
const postprocessPackage = addPackage(session, "postprocess-only-package", GIB, GIB);
const postprocessItem = session.items[postprocessPackage.itemIds[0]];
postprocessItem.status = "completed";
postprocessItem.progressPercent = 100;
postprocessItem.fullStatus = "Fertig";
postprocessPackage.status = "completed";
const state = internal(manager);
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
let releasePostprocess = (): void => {};
const postprocessGate = new Promise<void>((resolve) => {
releasePostprocess = resolve;
});
state.handlePackagePostProcessing = vi.fn(async () => postprocessGate);
const postprocessTask = state.runPackagePostProcessing(postprocessPackage.id);
await Promise.resolve();
await manager.start();
expect(session.running).toBe(true);
expect(state.activeRunContextId).toBeNull();
manager.addPackages([{
name: "late-download-package",
links: ["https://dummy/late-download.bin"],
fileNames: ["late-download.bin"]
}]);
const latePackageId = session.packageOrder.find((packageId) => packageId !== postprocessPackage.id);
if (!latePackageId) {
throw new Error("late package missing");
}
const latePackage = session.packages[latePackageId];
const lateItem = session.items[latePackage.itemIds[0]];
lateItem.totalBytes = 51 * GIB;
await manager.startItems([lateItem.id]);
const runContextId = state.activeRunContextId;
expect(runContextId).toEqual(expect.any(String));
expect(state.runContexts.get(runContextId)?.packageGenerations.has(latePackage.id)).toBe(true);
lateItem.downloadedBytes = 2 * GIB;
manager.setPackagePriority(latePackage.id, "high");
await flushNotifications();
expect(state.activeRunContextId).toBe(runContextId);
expect(events.filter((event) => event.type === "remaining_threshold_crossed")).toHaveLength(1);
manager.stop();
releasePostprocess();
await postprocessTask;
});
it("derives speed and ETA only from open enabled current run items", async () => {
const { manager, session } = setupManager();
const activePackage = addPackage(session, "scoped-speed-package", 60 * GIB, 10 * GIB);
const disabledPackage = addPackage(session, "disabled-speed-package", 100 * GIB);
const removedPackage = addPackage(session, "removed-speed-package", 200 * GIB);
const activeItem = session.items[activePackage.itemIds[0]];
const disabledItem = session.items[disabledPackage.itemIds[0]];
const removedItem = session.items[removedPackage.itemIds[0]];
activeItem.speedBps = 2 * GIB;
disabledItem.speedBps = 100 * GIB;
removedItem.speedBps = 200 * GIB;
const state = internal(manager);
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
await manager.start();
disabledPackage.enabled = false;
delete session.items[removedItem.id];
state.speedBytesLastWindow = 500 * GIB;
expect(state.buildRunRemainingSnapshot()).toEqual({
remainingBytes: 50 * GIB,
openItems: 1,
openPackages: 1,
unknownCount: 0,
finalizingItems: 0,
speedBps: 2 * GIB,
etaSeconds: 25
});
});
it("calculates known remainder, speed and ETA only from open enabled items in the active run", async () => {
const { manager, session } = setupManager();
const active = addPackage(session, "active-package", 60 * GIB, 9 * GIB);
const disabled = addPackage(session, "disabled-package", null, 0, false);
const notStarted = addPackage(session, "not-started-package", 400 * GIB);
const completedItemId = `${active.id}-completed-item`;
active.itemIds.push(completedItemId);
session.items[completedItemId] = {
...session.items[active.itemIds[0]],
id: completedItemId,
status: "completed",
downloadedBytes: 300 * GIB,
totalBytes: 300 * GIB,
progressPercent: 100,
fullStatus: "Fertig"
};
const overrunItemId = `${active.id}-overrun-item`;
active.itemIds.push(overrunItemId);
session.items[overrunItemId] = {
...session.items[active.itemIds[0]],
id: overrunItemId,
downloadedBytes: 2 * GIB,
totalBytes: GIB,
progressPercent: 100
};
const state = internal(manager);
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
await manager.start({ excludePackageIds: new Set([notStarted.id]) });
session.items[active.itemIds[0]].speedBps = GIB;
state.speedBytesLastWindow = GIB;
expect(state.buildRunRemainingSnapshot()).toEqual({
remainingBytes: 51 * GIB,
openItems: 2,
openPackages: 1,
unknownCount: 0,
finalizingItems: 0,
speedBps: GIB,
etaSeconds: 51
});
expect(state.runPackageIds).toEqual(new Set([active.id]));
expect(state.runPackageIds.has(disabled.id)).toBe(false);
});
it("enqueues one complete event per crossing and re-arms after new work raises the remainder", async () => {
const { manager, session, events } = setupManager();
const pkg = addPackage(session, "crossing-package", 51 * GIB);
const item = session.items[pkg.itemIds[0]];
const state = internal(manager);
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
await manager.start();
item.speedBps = GIB;
state.speedBytesLastWindow = GIB;
item.downloadedBytes = GIB;
state.evaluateRemainingNotification();
state.evaluateRemainingNotification();
await flushNotifications();
expect(events.filter((event) => event.type === "remaining_threshold_crossed")).toHaveLength(1);
expect(events[0].payload.fields).toEqual([
{ name: "Restmenge", value: "50 GB", inline: true },
{ name: "Offene Pakete", value: "1", inline: true },
{ name: "Offene Dateien", value: "1", inline: true },
{ name: "Geschwindigkeit", value: "1 GB/s", inline: true },
{ name: "ETA", value: "0:50", inline: true }
]);
item.downloadedBytes = 0;
state.evaluateRemainingNotification();
item.downloadedBytes = 2 * GIB;
state.evaluateRemainingNotification();
await flushNotifications();
expect(events.filter((event) => event.type === "remaining_threshold_crossed")).toHaveLength(2);
expect(new Set(events.map((event) => event.id)).size).toBe(2);
});
it("waits for unknown sizes to become known above the threshold before allowing a crossing", async () => {
const { manager, session, events } = setupManager();
const pkg = addPackage(session, "unknown-package", null);
const item = session.items[pkg.itemIds[0]];
const state = internal(manager);
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
await manager.start();
item.totalBytes = 49 * GIB;
state.evaluateRemainingNotification();
await flushNotifications();
expect(events).toHaveLength(0);
item.totalBytes = 60 * GIB;
state.evaluateRemainingNotification();
item.downloadedBytes = 11 * GIB;
state.evaluateRemainingNotification();
await flushNotifications();
expect(events.filter((event) => event.type === "remaining_threshold_crossed")).toHaveLength(1);
});
it("allows the same package to cross again in a new run", async () => {
const { manager, session, events } = setupManager();
const pkg = addPackage(session, "new-run-package", 51 * GIB);
const item = session.items[pkg.itemIds[0]];
const state = internal(manager);
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
await manager.start();
item.downloadedBytes = 2 * GIB;
state.evaluateRemainingNotification();
manager.stop();
item.downloadedBytes = 0;
await manager.start();
item.downloadedBytes = 2 * GIB;
state.evaluateRemainingNotification();
await flushNotifications();
expect(events.filter((event) => event.type === "remaining_threshold_crossed")).toHaveLength(2);
expect(new Set(events.map((event) => event.id)).size).toBe(2);
});
it("does not duplicate a prior crossing when a restored queue first appears below the threshold", async () => {
const first = setupManager();
const pkg = addPackage(first.session, "restart-package", 51 * GIB);
const item = first.session.items[pkg.itemIds[0]];
const firstState = internal(first.manager);
vi.spyOn(firstState, "ensureScheduler").mockResolvedValue(undefined);
await first.manager.start();
item.downloadedBytes = 2 * GIB;
firstState.evaluateRemainingNotification();
await flushNotifications();
expect(first.events).toHaveLength(1);
const restored = setupManager({}, first.session);
const restoredState = internal(restored.manager);
vi.spyOn(restoredState, "ensureScheduler").mockResolvedValue(undefined);
await restored.manager.start();
restoredState.evaluateRemainingNotification();
await flushNotifications();
expect(restored.events).toHaveLength(0);
});
it("suppresses a threshold event when the same evaluation completes the last open item", async () => {
const { manager, session, events } = setupManager();
const pkg = addPackage(session, "final-package", 51 * GIB);
const item = session.items[pkg.itemIds[0]];
const state = internal(manager);
vi.spyOn(state, "ensureScheduler").mockResolvedValue(undefined);
await manager.start();
item.status = "completed";
item.downloadedBytes = 51 * GIB;
item.progressPercent = 100;
item.fullStatus = "Fertig";
pkg.status = "completed";
state.runOutcomes.set(item.id, "completed");
state.evaluateRemainingNotification();
state.finishRun();
await flushNotifications();
expect(events.filter((event) => event.type === "remaining_threshold_crossed")).toHaveLength(0);
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it, vi } from "vitest";
import type { RendererSettings, UiSnapshot } from "../src/shared/types";
import {
activateDailyScheduleSettings,
buildScheduleCancellationSettingsUpdate,
buildDailyScheduleSettingsUpdate,
persistDailyScheduleSettingsUpdate,
resolveDailyScheduleInitialTime
} from "../src/renderer/App";
describe("daily schedule settings form", () => {
it("maps local time and the chosen start day to the recurring schedule settings", () => {
const now = new Date(2026, 7, 22, 18, 30, 0, 0);
expect(buildDailyScheduleSettingsUpdate("08:15", "today", now)).toEqual({
dailyStartEnabled: true,
dailyStartMinuteOfDay: 8 * 60 + 15,
dailyStartFirstLocalDate: "2026-08-22"
});
expect(buildDailyScheduleSettingsUpdate("23:45", "tomorrow", now)).toEqual({
dailyStartEnabled: true,
dailyStartMinuteOfDay: 23 * 60 + 45,
dailyStartFirstLocalDate: "2026-08-23"
});
});
it("prefills a previously saved disabled rule and uses local time only for the empty default", () => {
const now = new Date(2026, 7, 22, 18, 30, 0, 0);
const disabledSavedRule = {
dailyStartEnabled: false,
dailyStartMinuteOfDay: 8 * 60 + 15,
dailyStartFirstLocalDate: "2026-08-21"
};
expect(resolveDailyScheduleInitialTime(disabledSavedRule, now)).toBe("08:15");
expect(resolveDailyScheduleInitialTime({
dailyStartMinuteOfDay: 0,
dailyStartFirstLocalDate: ""
}, now)).toBe("18:30");
});
it.each(["", "8:15", "24:00", "12:60"])("reports the invalid time %j when activation is invoked directly", async (time) => {
const persist = vi.fn();
const showError = vi.fn();
await expect(activateDailyScheduleSettings(
time,
"today",
persist,
showError,
new Date(2026, 7, 22, 18, 30, 0, 0)
)).resolves.toBe(false);
expect(showError).toHaveBeenCalledWith("Bitte eine gültige Startzeit auswählen.");
expect(persist).not.toHaveBeenCalled();
});
it("applies persisted settings after a successful activation", async () => {
const persisted = { dailyStartEnabled: true } as RendererSettings;
const updateSettings = vi.fn().mockResolvedValue(persisted);
const applySettings = vi.fn();
const getSnapshot = vi.fn();
const applySnapshot = vi.fn();
const showError = vi.fn();
const update = {
dailyStartEnabled: true,
dailyStartMinuteOfDay: 495,
dailyStartFirstLocalDate: "2026-08-23"
};
await expect(persistDailyScheduleSettingsUpdate(update, "activate", {
updateSettings,
getSnapshot,
applySettings,
applySnapshot,
showError
})).resolves.toBe(true);
expect(updateSettings).toHaveBeenCalledWith(update);
expect(applySettings).toHaveBeenCalledWith(persisted);
expect(getSnapshot).not.toHaveBeenCalled();
expect(showError).not.toHaveBeenCalled();
});
it("cancels the active schedule through its owning settings field", () => {
expect(buildScheduleCancellationSettingsUpdate({
dailyStartEnabled: true,
scheduledStartEpochMs: 1_800_000_000_000
})).toEqual({ dailyStartEnabled: false });
expect(buildScheduleCancellationSettingsUpdate({
dailyStartEnabled: false,
scheduledStartEpochMs: 1_800_000_000_000
})).toEqual({ scheduledStartEpochMs: 0 });
});
it.each([
["activate" as const, "Zeitplan konnte nicht aktiviert werden: Error: Speichern fehlgeschlagen"],
["cancel" as const, "Zeitplan konnte nicht abgebrochen werden: Error: Speichern fehlgeschlagen"]
])("shows %s failures before reconciling the authoritative snapshot", async (operation, expectedMessage) => {
const authoritative = { settings: { dailyStartEnabled: false } } as UiSnapshot;
const sequence: string[] = [];
const updateSettings = vi.fn().mockRejectedValue(new Error("Speichern fehlgeschlagen"));
const getSnapshot = vi.fn(async () => {
sequence.push("snapshot");
return authoritative;
});
const applySnapshot = vi.fn((snapshot: UiSnapshot) => {
sequence.push(`apply:${String(snapshot.settings.dailyStartEnabled)}`);
});
const showError = vi.fn((message: string) => {
sequence.push(`error:${message}`);
});
await expect(persistDailyScheduleSettingsUpdate({ dailyStartEnabled: false }, operation, {
updateSettings,
getSnapshot,
applySettings: vi.fn(),
applySnapshot,
showError
})).resolves.toBe(false);
expect(showError).toHaveBeenCalledWith(expectedMessage);
expect(getSnapshot).toHaveBeenCalledTimes(1);
expect(applySnapshot).toHaveBeenCalledWith(authoritative);
expect(sequence).toEqual([`error:${expectedMessage}`, "snapshot", "apply:false"]);
});
});
+77 -1
View File
@@ -1,9 +1,35 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { defaultSettings } from "../src/main/constants";
import { createRendererSettings } from "../src/main/renderer-state";
import { validateRendererSettingsUpdate } from "../src/main/renderer-settings";
describe("renderer settings validation", () => {
it("accepts every editable notification control while keeping the webhook write-only", () => {
const current = { ...defaultSettings(), notifyUrl: "https://notify.example.test/private-hook" };
const update = {
notifyPackageSuccessMode: "individual",
notifyOnRemainingBelow: true,
notifyRemainingThresholdGb: 25,
notifyOnDownloadStall: true,
notifyStallAfterSeconds: 120,
notifyStallCooldownMinutes: 15,
notifyOnDownloadRecovery: false
};
const projected = createRendererSettings(current);
expect(projected).not.toHaveProperty("notifyUrl");
expect(projected.notifyUrlConfigured).toBe(true);
expect(validateRendererSettingsUpdate(update, current)).toEqual(update);
});
it("rejects unsupported package success modes at the IPC boundary", () => {
const current = defaultSettings();
expect(validateRendererSettingsUpdate({ notifyPackageSuccessMode: "digest" }, current)).toEqual({ notifyPackageSuccessMode: "digest" });
expect(validateRendererSettingsUpdate({ notifyPackageSuccessMode: "individual" }, current)).toEqual({ notifyPackageSuccessMode: "individual" });
expect(() => validateRendererSettingsUpdate({ notifyPackageSuccessMode: "batched" }, current)).toThrow("Settings-Payload ist ungültig");
});
it("accepts a complete settings save when an account status has no optional email", () => {
const current = defaultSettings();
current.debridAccountStatuses = {
@@ -47,4 +73,54 @@ describe("renderer settings validation", () => {
expect(projected.archivePasswordListConfigured).toBe(true);
expect(JSON.stringify(projected)).not.toContain(password);
});
it("projects daily start state and accepts only editable calendar controls", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 7, 22, 9, 0));
try {
const current = {
...defaultSettings(),
dailyStartEnabled: true,
dailyStartMinuteOfDay: 18 * 60 + 45,
dailyStartFirstLocalDate: "2026-08-23",
dailyStartLastHandledLocalDate: "2026-08-22",
dailyStartPendingLocalDate: "",
dailyStartLastOutcome: "started" as const
};
const projected = createRendererSettings(current);
expect(projected).toMatchObject({
dailyStartEnabled: true,
dailyStartMinuteOfDay: 18 * 60 + 45,
dailyStartFirstLocalDate: "2026-08-23",
dailyStartLastHandledLocalDate: "2026-08-22",
dailyStartPendingLocalDate: "",
dailyStartLastOutcome: "started"
});
expect(projected.nextDailyStartEpochMs).toBe(new Date(2026, 7, 23, 18, 45, 0, 0).getTime());
expect(validateRendererSettingsUpdate({
dailyStartEnabled: true,
dailyStartMinuteOfDay: 7 * 60 + 30,
dailyStartFirstLocalDate: "2026-08-24",
dailyStartLastHandledLocalDate: "2026-08-23",
dailyStartPendingLocalDate: "2026-08-24",
dailyStartLastOutcome: "missed",
nextDailyStartEpochMs: 123
}, current)).toEqual({
dailyStartEnabled: true,
dailyStartMinuteOfDay: 7 * 60 + 30,
dailyStartFirstLocalDate: "2026-08-24"
});
} finally {
vi.useRealTimers();
}
});
it("rejects invalid editable daily calendar values at the renderer boundary", () => {
const current = defaultSettings();
expect(() => validateRendererSettingsUpdate({ dailyStartMinuteOfDay: 1_440 }, current)).toThrow("Settings-Payload ist ungültig");
expect(() => validateRendererSettingsUpdate({ dailyStartMinuteOfDay: 12.5 }, current)).toThrow("Settings-Payload ist ungültig");
expect(() => validateRendererSettingsUpdate({ dailyStartFirstLocalDate: "2026-02-29" }, current)).toThrow("Settings-Payload ist ungültig");
});
});
+12
View File
@@ -40,6 +40,18 @@ const ACCOUNT_FIXTURES: Array<{
];
describe("renderer state serialization", () => {
it("projects notification controls with their safe defaults", () => {
expect(createRendererState(defaultSettings()).settings).toEqual(expect.objectContaining({
notifyPackageSuccessMode: "digest",
notifyOnRemainingBelow: false,
notifyRemainingThresholdGb: 50,
notifyOnDownloadStall: false,
notifyStallAfterSeconds: 90,
notifyStallCooldownMinutes: 10,
notifyOnDownloadRecovery: true
}));
});
it("projects distinct Real-Debrid API and Web rows with per-account state", () => {
const firstToken = "fixture-renderer-rd-first-1aB2";
const secondToken = "fixture-renderer-rd-second-3cD4";
Binary file not shown.
+150 -1
View File
@@ -4,7 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import { createRendererSettings, createRendererState } from "../src/main/renderer-state";
import { buildAccountAddFields, createAccountDialogState } from "../src/renderer/App";
import { buildAccountAddFields, buildAccountCreateProviderOrderUpdate, createAccountDialogState, createSettingsDraft } from "../src/renderer/App";
import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit";
import {
buildScopedAccountEnabledState,
@@ -23,6 +23,7 @@ import {
filterAccountAddOptions,
getSettingsSaveLabel,
getSettingsSelectNavigationIndex,
normalizeNotificationNumberField,
projectAccountRows,
pruneAccountSelection,
reconcileAccountAddDraft,
@@ -465,6 +466,43 @@ describe("settings model", () => {
)).toEqual(["debridlink", "realdebrid", "alldebrid", "bestdebrid"]);
});
it("appends a newly configured Deepbrid provider once for immediate persistence", () => {
const settings = createRendererSettings({
...defaultSettings(),
token: "synthetic-real-debrid-token",
debridLinkApiKeys: "synthetic-debrid-link-key",
deepbridApiKey: "synthetic-deepbrid-key",
providerOrder: ["debridlink", "realdebrid"]
});
expect(buildAccountCreateProviderOrderUpdate(settings)).toEqual({
providerOrder: ["debridlink", "realdebrid", "deepbrid"],
providerPrimary: "debridlink",
providerSecondary: "realdebrid",
providerTertiary: "deepbrid"
});
});
it("builds the Deepbrid API picker and status row", () => {
const dialog = createAccountDialogState("create", "deepbrid-api", createRendererSettings(defaultSettings()));
expect(buildAccountAddFields(dialog).find((field) => field.id === "token")?.label).toBe("Token / API-Key");
const [row] = projectAccountRows([{
identityId: "svc-deepbrid",
service: "deepbrid",
hoster: "Deepbrid",
mode: "API",
enabled: true,
status: { state: "premium", message: "Premium aktiv", premiumUntilMs: NOW + GIB, username: "deep-user", email: "deep@example.test" },
dailyLimitBytes: 10 * GIB,
dailyUsageBytes: 3 * GIB,
totalUsageBytes: 20 * GIB,
username: "",
credentialKind: "api-key",
canCheck: true
}], [], NOW);
expect(row).toMatchObject({ hoster: "Deepbrid", mode: "API", icon: "./provider-icons/deepbrid.png", username: "deep-user", email: "deep@example.test", traffic: "7 GiB von 10 GiB übrig · Gesamt 20 GiB", credential: "API-Key", canCheck: true });
});
it("keeps exact rounded limits and migrates edited identity metadata", () => {
const login = "member@example.test";
const oldId = getMegaDebridAccountId(login);
@@ -579,6 +617,108 @@ describe("settings views", () => {
});
});
it("projects every notification control in the required order with exact bounds", () => {
const form = buildSettingsFormViewModel({
settings: {
...createRendererSettings(defaultSettings()),
archivePasswordList: "",
notifyUrl: "https://discord.com/api/webhooks/example",
notifyOnPackageCompleted: true,
notifyOnRemainingBelow: true,
notifyOnDownloadStall: true
},
section: "allgemein",
speedLimitInput: "0",
scheduleSpeedInputs: {}
});
const fields = form.groups.find((group) => group.id === "general-notifications")?.fields ?? [];
expect(fields.map((field) => field.id)).toEqual([
"notifyUrl",
"notifyMention",
"notifyOnPackageCompleted",
"notifyOnPackageFailed",
"notifyPackageSuccessMode",
"notifyOnRunFinished",
"notifyOnRemainingBelow",
"notifyRemainingThresholdGb",
"notifyOnDownloadStall",
"notifyStallAfterSeconds",
"notifyStallCooldownMinutes",
"notifyOnDownloadRecovery"
]);
expect(fields.find((field) => field.id === "notifyPackageSuccessMode")).toEqual({
id: "notifyPackageSuccessMode",
kind: "select",
label: "Erfolgsmeldungen senden",
value: "digest",
disabled: false,
options: [
{ value: "digest", label: "Gesammelt (alle 2 Minuten)" },
{ value: "individual", label: "Jedes Paket einzeln" }
]
});
expect(fields.find((field) => field.id === "notifyRemainingThresholdGb")).toEqual({
id: "notifyRemainingThresholdGb",
kind: "number",
label: "Restmengenschwelle (GB)",
value: "50",
min: 1,
max: 100000,
disabled: false
});
expect(fields.find((field) => field.id === "notifyStallAfterSeconds")).toEqual({
id: "notifyStallAfterSeconds",
kind: "number",
label: "Stillstand bestätigen nach (Sek.)",
value: "90",
min: 60,
max: 3600,
disabled: false
});
expect(fields.find((field) => field.id === "notifyStallCooldownMinutes")).toEqual({
id: "notifyStallCooldownMinutes",
kind: "number",
label: "Frühestens erneut melden nach (Min.)",
value: "10",
min: 5,
max: 1440,
disabled: false
});
});
it("disables notification controls that depend on an inactive switch", () => {
const form = buildSettingsFormViewModel({
settings: { ...createRendererSettings(defaultSettings()), archivePasswordList: "", notifyUrl: "" },
section: "allgemein",
speedLimitInput: "0",
scheduleSpeedInputs: {}
});
const fields = form.groups.find((group) => group.id === "general-notifications")?.fields ?? [];
const disabled = Object.fromEntries(fields.map((field) => [field.id, Boolean(field.disabled)]));
expect(disabled).toMatchObject({
notifyPackageSuccessMode: true,
notifyRemainingThresholdGb: true,
notifyStallAfterSeconds: true,
notifyStallCooldownMinutes: true,
notifyOnDownloadRecovery: true
});
});
it("clamps notification number fields and restores their exact defaults", () => {
expect(normalizeNotificationNumberField("notifyRemainingThresholdGb", "0")).toBe(1);
expect(normalizeNotificationNumberField("notifyRemainingThresholdGb", "100001")).toBe(100000);
expect(normalizeNotificationNumberField("notifyRemainingThresholdGb", "invalid")).toBe(50);
expect(normalizeNotificationNumberField("notifyStallAfterSeconds", "59")).toBe(60);
expect(normalizeNotificationNumberField("notifyStallAfterSeconds", "3601")).toBe(3600);
expect(normalizeNotificationNumberField("notifyStallAfterSeconds", "invalid")).toBe(90);
expect(normalizeNotificationNumberField("notifyStallCooldownMinutes", "4")).toBe(5);
expect(normalizeNotificationNumberField("notifyStallCooldownMinutes", "1441")).toBe(1440);
expect(normalizeNotificationNumberField("notifyStallCooldownMinutes", "invalid")).toBe(10);
expect(normalizeNotificationNumberField("maxParallel", "8")).toBeUndefined();
});
it("supports keyboard navigation in animated settings selects", () => {
expect(getSettingsSelectNavigationIndex(1, 3, "ArrowDown")).toBe(2);
expect(getSettingsSelectNavigationIndex(2, 3, "ArrowDown")).toBe(0);
@@ -1165,6 +1305,15 @@ describe("account workspace", () => {
});
describe("settings App integration", () => {
it("preserves the write-only webhook during live snapshots and clears it for backup reseeding", () => {
const safe = createRendererSettings({ ...defaultSettings(), notifyUrl: "https://private.example.test/hook" });
const current = { ...safe, archivePasswordList: "loaded-password", notifyUrl: "https://private.example.test/hook" };
expect(createSettingsDraft(safe, current).notifyUrl).toBe("https://private.example.test/hook");
expect(createSettingsDraft(safe).notifyUrl).toBe("");
expect(appSource).toContain("applyPersistedSettings(fresh.settings, false)");
});
it("loads and preserves the stored archive password list in the extraction section", () => {
const revealBlock = sourceBlock(appSource, "const showToast", "const clearImportQueueFocusListener");
const applyBlock = sourceBlock(appSource, "const applyPersistedSettings", "const syncLiveProviderUsageSettings");
+15
View File
@@ -32,6 +32,21 @@ function localTime(day: number, hour = 12): number {
}
describe("statistics ledger", () => {
it("normalizes and records Deepbrid provider statistics", () => {
const now = localTime(10);
let ledger = recordStatisticsBytes(createStatisticsLedger(now), "deepbrid", 2_048, now);
ledger = recordStatisticsOutcome(ledger, "deepbrid", "completed", now);
addStatisticsAccountBytesInPlace(ledger, "deepbrid", 1_024, "svc-deepbrid", "Deepbrid", now);
const normalized = normalizeStatisticsLedger(ledger, now);
expect(normalized.days[0]?.providers.deepbrid).toEqual({ bytes: 2_048, completed: 1, failed: 0 });
expect(normalized.minutes[0]?.accounts["svc-deepbrid"]).toEqual({
provider: "deepbrid",
label: "Deepbrid",
bytes: 1_024
});
});
it("migrates version one ledgers without inventing minute history", () => {
const now = localTime(10);
const legacy = {
+493 -6
View File
@@ -6,10 +6,10 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
import { AppSettings } from "../src/shared/types";
import { AppSettings } from "../src/shared/types";
import { defaultSettings } from "../src/main/constants";
import { configureCredentialProtector } from "../src/main/credential-protection";
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
const tempDirs: string[] = [];
type SettingsSaveMode = "sync" | "async";
@@ -22,6 +22,14 @@ async function saveSettingsInMode(mode: SettingsSaveMode, paths: ReturnType<type
}
}
function loadSettingsFrom(raw: Record<string, unknown>): AppSettings {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(paths.configFile, JSON.stringify(raw), "utf8");
return loadSettings(paths);
}
beforeEach(() => {
configureCredentialProtector({
isEncryptionAvailable: () => true,
@@ -38,6 +46,225 @@ afterEach(() => {
});
describe("settings storage", () => {
it("defaults and round-trips Deepbrid credentials and provider settings", () => {
const key = "fixture-deepbrid-storage-key-2aB4";
const defaults = defaultSettings();
expect(defaults.deepbridApiKey).toBe("");
const normalized = normalizeSettings({
...defaults,
deepbridApiKey: ` ${key} `,
providerOrder: ["deepbrid", "realdebrid", "deepbrid"],
providerPrimary: "deepbrid",
providerSecondary: "realdebrid",
providerTertiary: "none",
disabledProviders: ["deepbrid"],
hosterRouting: { rapidgator: "deepbrid" },
providerDailyLimitBytes: { deepbrid: 1_024 },
providerDailyUsageBytes: { deepbrid: 2_048 },
providerTotalUsageBytes: { deepbrid: 4_096 },
debridAccountStatuses: {
"svc-deepbrid": {
accountId: "svc-deepbrid",
provider: "deepbrid",
label: "Deepbrid",
maskedLogin: "••••",
valid: true,
isPremium: true,
premiumUntilMs: null,
message: "OK",
checkedAt: 1
}
}
} as AppSettings);
expect(normalized).toMatchObject({
deepbridApiKey: key,
providerOrder: ["deepbrid", "realdebrid"],
providerPrimary: "deepbrid",
providerSecondary: "realdebrid",
providerTertiary: "none",
disabledProviders: ["deepbrid"],
hosterRouting: { rapidgator: "deepbrid" },
providerDailyLimitBytes: { deepbrid: 1_024 },
providerDailyUsageBytes: { deepbrid: 2_048 },
providerTotalUsageBytes: { deepbrid: 4_096 }
});
expect(normalized.debridAccountStatuses["svc-deepbrid"]?.provider).toBe("deepbrid");
expect(normalizeSettings({
...defaults,
providerPrimary: "realdebrid",
providerSecondary: "deepbrid",
providerTertiary: "bestdebrid"
}).providerSecondary).toBe("deepbrid");
expect(normalizeSettings({
...defaults,
providerPrimary: "realdebrid",
providerSecondary: "bestdebrid",
providerTertiary: "deepbrid"
}).providerTertiary).toBe("deepbrid");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveSettings(paths, normalized);
expect(loadSettings(paths).deepbridApiKey).toBe(key);
});
it("preserves Deepbrid in normalized session items, packages and history", () => {
const outputDir = path.resolve("C:\\Downloads\\Deepbrid");
const session = normalizeLoadedSession({
...emptySession(),
packageOrder: ["pkg-deepbrid"],
packages: {
"pkg-deepbrid": {
id: "pkg-deepbrid",
name: "Deepbrid package",
outputDir,
extractDir: outputDir,
status: "completed",
itemIds: ["item-deepbrid"],
cleanedProviders: ["deepbrid"],
createdAt: 1,
updatedAt: 2
}
},
items: {
"item-deepbrid": {
id: "item-deepbrid",
packageId: "pkg-deepbrid",
url: "https://example.test/deepbrid.bin",
provider: "deepbrid",
status: "completed",
fileName: "deepbrid.bin",
targetPath: path.join(outputDir, "deepbrid.bin"),
createdAt: 1,
updatedAt: 2
}
}
});
const history = normalizeHistoryEntry({
id: "hist-deepbrid",
name: "Deepbrid history",
provider: "deepbrid",
status: "completed"
}, 0);
expect(session.items["item-deepbrid"]?.provider).toBe("deepbrid");
expect(session.packages["pkg-deepbrid"]?.cleanedProviders).toEqual(["deepbrid"]);
expect(history?.provider).toBe("deepbrid");
});
it.each([undefined, "megadebrid", "realdebrid", "invalid-provider"])("normalizes svc-deepbrid status with provider %s deterministically to Deepbrid", (provider) => {
const key = "fixture-deepbrid-status-key-4pQ5";
const normalized = normalizeSettings({
...defaultSettings(),
deepbridApiKey: key,
debridAccountStatuses: {
"svc-deepbrid": {
accountId: "svc-deepbrid",
...(provider === undefined ? {} : { provider }),
label: "Deepbrid",
maskedLogin: "••••",
valid: true,
isPremium: true,
premiumUntilMs: null,
message: "OK",
checkedAt: 1
}
}
} as AppSettings);
expect(normalized.debridAccountStatuses["svc-deepbrid"]?.provider).toBe("deepbrid");
});
it("defaults and normalizes persistent daily start calendar fields", () => {
const defaults = defaultSettings();
expect(defaults.dailyStartEnabled).toBe(false);
expect(defaults.dailyStartMinuteOfDay).toBe(0);
expect(defaults.dailyStartFirstLocalDate).toBe("");
expect(defaults.dailyStartLastHandledLocalDate).toBe("");
expect(defaults.dailyStartPendingLocalDate).toBe("");
expect(defaults.dailyStartLastOutcome).toBe("");
expect(normalizeSettings({
...defaults,
dailyStartEnabled: true,
dailyStartMinuteOfDay: 1_500,
dailyStartFirstLocalDate: "2026-02-29",
dailyStartLastHandledLocalDate: "2026-08-21",
dailyStartPendingLocalDate: "not-a-day",
dailyStartLastOutcome: "unsupported"
} as unknown as AppSettings)).toMatchObject({
dailyStartEnabled: true,
dailyStartMinuteOfDay: 1_439,
dailyStartFirstLocalDate: "",
dailyStartLastHandledLocalDate: "2026-08-21",
dailyStartPendingLocalDate: "",
dailyStartLastOutcome: ""
});
});
it("reports whether a loaded session was active before transient normalization", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const session = emptySession();
session.running = true;
saveSession(paths, session);
const loaded = loadSessionWithStatus(paths);
expect(loaded.wasRunning).toBe(true);
expect(loaded.session.running).toBe(false);
});
it("migrates legacy package success notifications without changing their delivery frequency", () => {
expect(loadSettingsFrom({ notifyOnPackageCompleted: true }).notifyPackageSuccessMode).toBe("individual");
expect(loadSettingsFrom({}).notifyPackageSuccessMode).toBe("digest");
expect(loadSettingsFrom({ notifyOnPackageCompleted: true, notifyPackageSuccessMode: "digest" }).notifyPackageSuccessMode).toBe("digest");
});
it("preserves an explicit individual success mode when legacy package notifications are disabled", () => {
expect(loadSettingsFrom({ notifyOnPackageCompleted: false, notifyPackageSuccessMode: "individual" }).notifyPackageSuccessMode).toBe("individual");
});
it("falls back from an invalid persisted success mode according to the legacy package toggle", () => {
expect(loadSettingsFrom({ notifyOnPackageCompleted: true, notifyPackageSuccessMode: "invalid" }).notifyPackageSuccessMode).toBe("individual");
expect(loadSettingsFrom({ notifyOnPackageCompleted: false, notifyPackageSuccessMode: "invalid" }).notifyPackageSuccessMode).toBe("digest");
});
it("loads notification defaults for new settings", () => {
const defaults = loadSettingsFrom({});
expect(defaults).toEqual(expect.objectContaining({
notifyPackageSuccessMode: "digest",
notifyOnRemainingBelow: false,
notifyRemainingThresholdGb: 50,
notifyOnDownloadStall: false,
notifyStallAfterSeconds: 90,
notifyStallCooldownMinutes: 10,
notifyOnDownloadRecovery: true
}));
});
it.each([
["notifyRemainingThresholdGb", 0, 1],
["notifyRemainingThresholdGb", 100_001, 100_000],
["notifyRemainingThresholdGb", "invalid", 50],
["notifyStallAfterSeconds", 59, 60],
["notifyStallAfterSeconds", 3_601, 3_600],
["notifyStallAfterSeconds", "invalid", 90],
["notifyStallCooldownMinutes", 4, 5],
["notifyStallCooldownMinutes", 1_441, 1_440],
["notifyStallCooldownMinutes", "invalid", 10]
] as const)("normalizes %s from %s to %s", (key, input, expected) => {
const normalized = normalizeSettings({ ...defaultSettings(), [key]: input } as AppSettings);
expect(normalized[key]).toBe(expected);
});
it("enables package disclosure motion by default and preserves an explicit opt-out", () => {
const legacy = { ...defaultSettings() } as Partial<AppSettings>;
delete legacy.animatePackageDisclosure;
@@ -928,7 +1155,7 @@ describe("settings storage", () => {
expect(normalizedDisabled.allDebridUseWebLogin).toBe(false);
});
it("defaults history retention to permanent and normalizes invalid values", () => {
it("defaults history retention to permanent and normalizes invalid values", () => {
expect(defaultSettings().historyRetentionMode).toBe("permanent");
const normalized = normalizeSettings({
@@ -936,9 +1163,269 @@ describe("settings storage", () => {
historyRetentionMode: "broken" as unknown as AppSettings["historyRetentionMode"]
});
expect(normalized.historyRetentionMode).toBe("permanent");
});
expect(normalized.historyRetentionMode).toBe("permanent");
});
it("loads legacy history without inventing structured durations", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(paths.historyFile, JSON.stringify([{
id: "legacy",
name: "Altbestand",
totalBytes: 1_000,
downloadedBytes: 1_000,
fileCount: 1,
provider: "realdebrid",
completedAt: 10_000,
durationSeconds: 9,
status: "completed",
outputDir: "C:\\Downloads\\Altbestand"
}]), "utf8");
const [loaded] = loadHistory(paths);
expect(loaded).toEqual(expect.objectContaining({
durationSeconds: 9,
status: "completed"
}));
expect(loaded.downloadDurationSeconds).toBeUndefined();
expect(loaded.totalDurationSeconds).toBeUndefined();
});
it.each(["completed", "partial", "failed", "cancelled", "deleted"] as const)("preserves the %s history status", (status) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(paths.historyFile, JSON.stringify([{
id: `history-${status}`,
name: "Paket",
completedAt: 10_000,
durationSeconds: 1,
status,
outputDir: "C:\\Downloads\\Paket"
}]), "utf8");
expect(loadHistory(paths)[0]?.status).toBe(status);
});
it("clamps expanded history metrics and preserves normalized operations", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(paths.historyFile, JSON.stringify([{
id: "structured",
name: "Paket",
completedAt: 50_000,
durationSeconds: 1,
status: "failed",
outputDir: "C:\\Downloads\\Paket",
startedAt: -10,
downloadEndedAt: 20_000,
postProcessStartedAt: 25_000,
downloadDurationSeconds: -120,
extractionDurationSeconds: 30,
remuxDurationSeconds: 5,
postProcessDurationSeconds: 35,
totalDurationSeconds: 50,
successfulFiles: -1,
failedFiles: 1,
cancelledFiles: 0,
archiveCount: 1,
partCount: 16,
outputCount: 15,
failurePhase: "extract",
archiveOperations: [{
id: "archive-1",
name: "Paket.part01.rar",
itemIds: ["item-1", "", "item-2"],
partCount: -16,
startedAt: 25_000,
completedAt: 50_000,
durationMs: -30_000,
status: "failed",
errorCategory: "checksum"
}],
remuxOperations: [{
id: "remux-1",
fileName: "episode.mkv",
startedAt: 30_000,
completedAt: 35_000,
durationMs: 5_000,
status: "cancelled",
errorCategory: "cancelled"
}]
}]), "utf8");
expect(loadHistory(paths)[0]).toEqual(expect.objectContaining({
status: "failed",
startedAt: 0,
downloadEndedAt: 20_000,
postProcessStartedAt: 25_000,
downloadDurationSeconds: 0,
extractionDurationSeconds: 30,
remuxDurationSeconds: 5,
postProcessDurationSeconds: 35,
totalDurationSeconds: 50,
successfulFiles: 0,
failedFiles: 1,
cancelledFiles: 0,
archiveCount: 1,
partCount: 16,
outputCount: 15,
failurePhase: "extract",
archiveOperations: [{
id: "archive-1",
name: "Paket.part01.rar",
itemIds: ["item-1", "item-2"],
partCount: 0,
startedAt: 25_000,
completedAt: 50_000,
durationMs: 0,
status: "failed",
errorCategory: "Entpacken"
}],
remuxOperations: [{
id: "remux-1",
fileName: "episode.mkv",
startedAt: 30_000,
completedAt: 35_000,
durationMs: 5_000,
status: "cancelled",
errorCategory: "Remux"
}]
}));
});
it("preserves package lifecycle timestamps and operation metrics when loading a session", () => {
const normalized = normalizeLoadedSession({
version: 2,
packageOrder: ["pkg-1"],
packages: {
"pkg-1": {
id: "pkg-1",
name: "Paket",
outputDir: "C:\\Downloads\\Paket",
extractDir: "C:\\Downloads\\Paket",
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
resultGeneration: 7,
downloadStartedAt: 1_000,
downloadCompletedAt: 10_000,
downloadEndedAt: 12_000,
postProcessQueuedAt: 13_000,
postProcessStartedAt: 14_000,
postProcessCompletedAt: 20_000,
terminalAt: 21_000,
archiveOperations: [{
id: "archive-1",
name: "Paket.rar",
itemIds: [],
partCount: 1,
startedAt: 14_000,
completedAt: 18_000,
durationMs: 4_000,
status: "completed",
errorCategory: ""
}],
remuxOperations: [],
outputCount: 1,
cleanupErrorCategory: "",
createdAt: 1_000,
updatedAt: 21_000
}
},
items: {},
runStartedAt: 1_000,
totalDownloadedBytes: 0,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: false,
updatedAt: 21_000
});
expect(normalized.packages["pkg-1"]).toEqual(expect.objectContaining({
resultGeneration: 7,
downloadEndedAt: 12_000,
postProcessQueuedAt: 13_000,
postProcessStartedAt: 14_000,
postProcessCompletedAt: 20_000,
terminalAt: 21_000,
archiveOperations: [expect.objectContaining({ id: "archive-1", durationMs: 4_000 })],
remuxOperations: [],
outputCount: 1,
cleanupErrorCategory: ""
}));
});
it("migrates persisted package failure details to safe categories", () => {
const normalized = normalizeLoadedSession({
version: 2,
packageOrder: ["pkg-private"],
packages: {
"pkg-private": {
id: "pkg-private",
name: "Private telemetry",
outputDir: "C:\\Downloads\\Private",
extractDir: "C:\\Downloads\\Private",
status: "failed",
itemIds: [],
cancelled: false,
enabled: true,
archiveOperations: [{
id: "archive-private",
name: "private.rar",
itemIds: [],
partCount: 1,
startedAt: 1_000,
completedAt: 2_000,
durationMs: 1_000,
status: "failed",
errorCategory: "CRC_ERROR in C:\\Users\\Alice\\private.rar"
}],
remuxOperations: [{
id: "remux-private",
fileName: "private.mkv",
startedAt: 2_000,
completedAt: 3_000,
durationMs: 1_000,
status: "failed",
errorCategory: "ffmpeg failed for https://private.example.test/private.mkv"
}, {
id: "remux-disk-full",
fileName: "disk-full.mkv",
startedAt: 3_000,
completedAt: 4_000,
durationMs: 1_000,
status: "failed",
errorCategory: "disk_full at C:\\Users\\Alice\\disk-full.mkv"
}],
cleanupErrorCategory: "unlink failed for C:\\Users\\Alice\\private.rar",
createdAt: 1_000,
updatedAt: 4_000
}
},
items: {},
runStartedAt: 1_000,
totalDownloadedBytes: 0,
summaryText: "",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: false,
updatedAt: 4_000
});
const pkg = normalized.packages["pkg-private"];
expect(pkg.archiveOperations?.[0]?.errorCategory).toBe("Entpacken");
expect(pkg.remuxOperations?.map((operation) => operation.errorCategory)).toEqual(["Remux", "Speicherplatz"]);
expect(pkg.cleanupErrorCategory).toBe("Cleanup");
});
it("skips adding persisted history entries when history retention is never", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
+107 -11
View File
@@ -2,9 +2,12 @@ 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";
import { afterEach, describe, expect, it } from "vitest";
import { buildSupportBundle } from "../src/main/support-bundle";
import { createStoragePaths, emptySession, loadSession } from "../src/main/storage";
import type { DownloadManager } from "../src/main/download-manager";
import type { NotificationSupportPayload } from "../src/main/support-data";
import type { SessionState } from "../src/shared/types";
const tempDirs: string[] = [];
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
@@ -15,10 +18,10 @@ afterEach(() => {
}
});
function fakeManager(): DownloadManager {
const snapshot = {
stats: {},
session: { packages: {}, items: {}, packageOrder: [] },
function fakeManager(session: SessionState = emptySession()): DownloadManager {
const snapshot = {
stats: {},
session,
speedText: "",
etaText: "",
canStart: false,
@@ -60,7 +63,7 @@ describe("buildSupportBundle (async, non-blocking)", () => {
expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test");
});
it("does not block the event loop while building (a concurrent timer still fires)", async () => {
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);
@@ -69,6 +72,99 @@ describe("buildSupportBundle (async, non-blocking)", () => {
await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
clearTimeout(timer);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(timerFired).toBe(true);
});
});
expect(timerFired).toBe(true);
});
it("writes only the safe notification aggregate and excludes its runtime files", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-notifications-"));
tempDirs.push(root);
const paths = createStoragePaths(root);
fs.writeFileSync(paths.notificationOutboxFile, "PRIVATE_OUTBOX_RUNTIME_PAYLOAD", "utf8");
fs.writeFileSync(paths.notificationHealthFile, "PRIVATE_HEALTH_RUNTIME_PAYLOAD", "utf8");
const notificationStatus: NotificationSupportPayload & Record<string, unknown> = {
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "scheduler",
incidentAgeMs: 45_000,
events: [{ payload: "PRIVATE_EVENT_PAYLOAD" }],
url: "https://private.example.test/webhook",
mention: "@private"
};
const buffer = await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none", notificationStatus });
const zip = new AdmZip(buffer);
const entry = zip.getEntry("overview/notifications.json");
const payload = JSON.parse(entry?.getData().toString("utf8") || "null");
const serializedEntries = zip.getEntries().map((item) => item.getData().toString("utf8")).join("\n");
expect(payload).toEqual({
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "scheduler",
incidentAgeMs: 45_000
});
expect(zip.getEntries().map((item) => item.entryName)).not.toContain(path.basename(paths.notificationOutboxFile));
expect(zip.getEntries().map((item) => item.entryName)).not.toContain(path.basename(paths.notificationHealthFile));
expect(serializedEntries).not.toMatch(/PRIVATE_|https:\/\/private|@private/);
});
it("excludes legacy raw package failure details after loading persisted sessions", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-package-telemetry-"));
tempDirs.push(root);
const paths = createStoragePaths(root);
const legacySession = emptySession();
legacySession.packageOrder = ["pkg-private"];
legacySession.packages["pkg-private"] = {
id: "pkg-private",
name: "Support diagnostics",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
status: "failed",
itemIds: [],
cancelled: false,
enabled: true,
archiveOperations: [{
id: "archive-private",
name: "private.rar",
itemIds: [],
partCount: 1,
startedAt: 1_000,
completedAt: 2_000,
durationMs: 1_000,
status: "failed",
errorCategory: "ARCHIVE_PRIVATE C:\\Users\\Alice\\private.rar https://private.example.test/archive"
}],
remuxOperations: [{
id: "remux-private",
fileName: "private.mkv",
startedAt: 2_000,
completedAt: 3_000,
durationMs: 1_000,
status: "failed",
errorCategory: "REMUX_PRIVATE C:\\Users\\Alice\\private.mkv https://private.example.test/remux"
}],
cleanupErrorCategory: "CLEANUP_PRIVATE C:\\Users\\Alice\\private.rar https://private.example.test/cleanup",
outputCount: 7,
createdAt: 1_000,
updatedAt: 3_000
};
fs.writeFileSync(paths.sessionFile, JSON.stringify(legacySession), "utf8");
const buffer = await buildSupportBundle(fakeManager(loadSession(paths)), root, { hostDiagnosticsMode: "none" });
const zip = new AdmZip(buffer);
const payload = JSON.parse(zip.getEntry("overview/packages.json")?.getData().toString("utf8") || "null");
const serializedEntries = zip.getEntries().map((entry) => entry.getData().toString("utf8")).join("\n");
expect(payload).toEqual({
count: 1,
packages: [expect.objectContaining({
name: "Support diagnostics",
outputCount: 7,
archiveOperations: [expect.objectContaining({ errorCategory: "Entpacken" })],
remuxOperations: [expect.objectContaining({ errorCategory: "Remux" })],
cleanupErrorCategory: "Cleanup"
})]
});
expect(serializedEntries).not.toMatch(/ARCHIVE_PRIVATE|REMUX_PRIVATE|CLEANUP_PRIVATE|private\.example\.test/);
});
});
+132 -2
View File
@@ -1,10 +1,87 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import AdmZip from "adm-zip";
import { describe, expect, it, vi } from "vitest";
import { AppController } from "../src/main/app-controller";
import { defaultSettings } from "../src/main/constants";
import { buildAccountSummary, buildStatsPayload } from "../src/main/support-data";
import { buildAccountSummary, buildNotificationSupportPayload, buildStatsPayload } from "../src/main/support-data";
import { buildSupportBundle } from "../src/main/support-bundle";
import { createStoragePaths } from "../src/main/storage";
import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
import { createVisualFixture } from "./visual/fixtures";
describe("Real-Debrid support summary", () => {
it("projects the current private AppController notification state into the safe DTO", () => {
const controller = Object.create(AppController.prototype) as AppController;
const internals = controller as unknown as {
notificationOutbox: { getStatus: () => Record<string, unknown> };
downloadHealthMonitor: { getState: () => Record<string, unknown> };
};
internals.notificationOutbox = {
getStatus: () => ({
queued: 4,
lastSuccessAt: 1_700_000_000_000,
lastFailureAt: 1_700_000_010_000,
events: [{ payload: "PRIVATE_CONTROLLER_EVENT" }]
})
};
internals.downloadHealthMonitor = {
getState: () => ({
incidentType: "scheduler",
incidentStartedAt: 1_700_000_020_000,
runFingerprint: "PRIVATE_CONTROLLER_FINGERPRINT"
})
};
vi.spyOn(Date, "now").mockReturnValue(1_700_000_050_000);
const payload = controller.getNotificationSupportPayload();
expect(payload).toEqual({
queued: 4,
lastSuccessAt: 1_700_000_000_000,
incidentType: "scheduler",
incidentAgeMs: 30_000
});
expect(JSON.stringify(payload)).not.toMatch(/PRIVATE_|event|payload|fingerprint|lastFailure/i);
vi.restoreAllMocks();
});
it("projects only safe notification delivery and incident aggregates", () => {
const payload = buildNotificationSupportPayload(
{
queued: 7,
lastSuccessAt: 1_700_000_000_000,
lastFailureAt: 1_700_000_010_000,
events: [{ payload: { url: "https://private.example.test/hook", mention: "@private" } }]
} as Parameters<typeof buildNotificationSupportPayload>[0],
{
status: "alerted",
incidentType: "no_data",
incidentStartedAt: 1_700_000_020_000,
runFingerprint: "private-run",
url: "https://private.example.test/hook",
mention: "@private"
} as Parameters<typeof buildNotificationSupportPayload>[1],
1_700_000_050_000
);
expect(payload).toEqual({
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "no_data",
incidentAgeMs: 30_000
});
expect(JSON.stringify(payload)).not.toMatch(/payload|https?:|mention|private|lastFailure/i);
});
it("reports no active incident when the health state has no incident", () => {
expect(buildNotificationSupportPayload(
{ queued: 0, lastSuccessAt: 0 },
{ incidentType: null, incidentStartedAt: 0 },
1_700_000_050_000
)).toEqual({ queued: 0, lastSuccessAt: null, incidentType: null, incidentAgeMs: null });
});
it("reports pool counts without exposing account IDs or credentials", () => {
const summary = buildAccountSummary({
...defaultSettings(),
@@ -31,6 +108,35 @@ describe("Real-Debrid support summary", () => {
expect(serialized).not.toContain("secret-");
});
it("reports only safe Deepbrid configuration, status and usage aggregates", () => {
const settings = defaultSettings();
settings.deepbridApiKey = "synthetic-private-deepbrid-key";
settings.providerDailyLimitBytes.deepbrid = 10_000;
settings.providerDailyUsageBytes.deepbrid = 4_000;
settings.providerTotalUsageBytes.deepbrid = 20_000;
settings.debridAccountStatuses["svc-deepbrid"] = {
accountId: "svc-deepbrid",
provider: "deepbrid",
label: "Deepbrid",
maskedLogin: "synthetic-masked-login",
valid: true,
isPremium: true,
premiumUntilMs: 1_800_000_000_000,
checkedAt: 1_700_000_000_000,
message: "PRIVATE_STATUS_MESSAGE"
};
const deepbrid = buildAccountSummary(settings).deepbrid;
const serialized = JSON.stringify(deepbrid);
expect(deepbrid).toEqual({
configured: true,
apiKeyConfigured: true,
status: { checked: true, valid: true, premium: true, premiumUntilMs: 1_800_000_000_000, checkedAt: 1_700_000_000_000 },
usage: { dailyLimitBytes: 10_000, dailyUsageBytes: 4_000, totalUsageBytes: 20_000 }
});
expect(serialized).not.toMatch(/synthetic-private|PRIVATE_STATUS/);
});
it("removes rolling account IDs and labels from support statistics", () => {
const snapshot = structuredClone(createVisualFixture("empty").snapshot);
snapshot.stats.rolling24Hours = {
@@ -53,4 +159,28 @@ describe("Real-Debrid support summary", () => {
expect(serialized).toContain("realdebrid");
expect(serialized).toContain("4096");
});
it("keeps the persisted notification health incident outside support bundles", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-support-health-"));
try {
const paths = createStoragePaths(root);
fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(paths.notificationHealthFile, "PRIVATE_HEALTH_INCIDENT_PAYLOAD", "utf8");
const snapshot = structuredClone(createVisualFixture("empty").snapshot);
const manager = {
getSnapshot: () => snapshot,
getPackageLogPath: () => null,
getItemLogPath: () => null
};
const buffer = await buildSupportBundle(manager as any, root, { hostDiagnosticsMode: "none" });
const zip = new AdmZip(buffer);
const entries = zip.getEntries().map((entry) => entry.entryName);
expect(entries).not.toContain(path.basename(paths.notificationHealthFile));
expect(buffer.toString("utf8")).not.toContain("PRIVATE_HEALTH_INCIDENT_PAYLOAD");
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
+27
View File
@@ -93,6 +93,26 @@ describe("visual fixtures", () => {
expect(createVisualFixture("dense")).toEqual(dense);
});
it("keeps the complete notification center state deterministic without exposing its webhook", () => {
const settings = createVisualFixture("dense").snapshot.settings;
expect(settings).toEqual(expect.objectContaining({
notifyUrlConfigured: true,
notifyMention: "@visual",
notifyOnPackageCompleted: true,
notifyOnPackageFailed: true,
notifyPackageSuccessMode: "digest",
notifyOnRunFinished: true,
notifyOnRemainingBelow: true,
notifyRemainingThresholdGb: 75,
notifyOnDownloadStall: true,
notifyStallAfterSeconds: 120,
notifyStallCooldownMinutes: 15,
notifyOnDownloadRecovery: true
}));
expect(settings).not.toHaveProperty("notifyUrl");
});
it("freezes runtime and recurring chart timers across visual frames", async () => {
const dense = createVisualFixture("dense");
const originalDateNow = Date.now;
@@ -172,6 +192,13 @@ describe("visual fixtures", () => {
expect(hostLimits[0]?.keyId).toBe(debridLinkKeys[0].accountId);
});
it("includes the configured synthetic Deepbrid account", () => {
const dense = createVisualFixture("dense");
expect(dense.snapshot.accounts).toEqual(expect.arrayContaining([
expect.objectContaining({ accountId: "svc-deepbrid", kind: "deepbrid-api", provider: "deepbrid" })
]));
});
it("stores every mutable bridge state inside the visual fixture", async () => {
const dense = createVisualFixture("dense");
const api = createVisualElectronApi(dense);
+22 -8
View File
@@ -74,9 +74,10 @@ function createSettings(): AppSettings {
megaDebridPreferApi: true,
bestToken: "visual-best-debrid-token",
bestDebridUseWebLogin: false,
allDebridToken: "visual-all-debrid-token",
allDebridUseWebLogin: false,
ddownloadLogin: "visual-ddownload",
allDebridToken: "visual-all-debrid-token",
allDebridUseWebLogin: false,
deepbridApiKey: "visual-deepbrid-key",
ddownloadLogin: "visual-ddownload",
ddownloadPassword: "visual-password",
oneFichierApiKey: "visual-onefichier-key",
debridLinkApiKeys,
@@ -136,9 +137,16 @@ function createSettings(): AppSettings {
backupIncludeRemoteDiagnostics: false,
notifyUrl: "https://example.test/visual-webhook",
notifyMention: "@visual",
notifyOnPackageCompleted: true,
notifyOnPackageFailed: true,
notifyOnRunFinished: true,
notifyOnPackageCompleted: true,
notifyOnPackageFailed: true,
notifyOnRunFinished: true,
notifyPackageSuccessMode: "digest",
notifyOnRemainingBelow: true,
notifyRemainingThresholdGb: 75,
notifyOnDownloadStall: true,
notifyStallAfterSeconds: 120,
notifyStallCooldownMinutes: 15,
notifyOnDownloadRecovery: true,
totalDownloadedAllTime: 987654321000,
totalCompletedFilesAllTime: 842,
totalRuntimeAllTimeMs: 172800000,
@@ -232,8 +240,14 @@ function createSettings(): AppSettings {
checkedAt: 1786312800000
}
},
providerDailyUsageDay: "2026-08-10",
scheduledStartEpochMs: 0
providerDailyUsageDay: "2026-08-10",
dailyStartEnabled: false,
dailyStartMinuteOfDay: 0,
dailyStartFirstLocalDate: "",
dailyStartLastHandledLocalDate: "",
dailyStartPendingLocalDate: "",
dailyStartLastOutcome: "",
scheduledStartEpochMs: 0
};
}