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 { logger } from "../src/main/logger"; import { getManagedOnlineProxyListPath } from "../src/main/online-proxy-list"; import { configureNetworkProxy } from "../src/main/network-proxy"; import { createStatisticsLedger, loadStatisticsLedger, saveStatisticsLedger } from "../src/main/statistics-ledger"; import { createStoragePaths, emptySession, loadHistory, loadSession, loadSettings, saveHistory, saveSession, saveSettings } from "../src/main/storage"; import type { CollectorPersistenceState } from "../src/shared/collector"; 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, historyResetError: null as Error | null, historyClearError: null as Error | null, settingsSaveError: null as Error | null, sessionSaveError: null as Error | null, rollbackCreateError: null as Error | null, barrierAcquisitions: 0, barrierReleases: [] as boolean[], barrierReleaseHook: null as ((replayBlocked: boolean) => void) | null })); const statisticsStorage = vi.hoisted(() => ({ saveError: null as Error | null })); const debugStorage = vi.hoisted(() => ({ configError: null as Error | null, restartCalls: 0, restartErrors: [] as Error[] })); 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("../src/main/debug-server"); return { ...actual, startDebugServer: vi.fn(), stopDebugServer: vi.fn(), restartDebugServer: vi.fn(async () => { debugStorage.restartCalls += 1; const error = debugStorage.restartErrors.shift(); if (error) throw error; return actual.getDebugServerRuntimeStatus(); }), writeDebugServerConfig: (...args: Parameters) => { if (debugStorage.configError) throw debugStorage.configError; return actual.writeDebugServerConfig(...args); } }; }); vi.mock("../src/main/statistics-ledger", async () => { const actual = await vi.importActual("../src/main/statistics-ledger"); return { ...actual, saveStatisticsLedger: (...args: Parameters) => { if (statisticsStorage.saveError) throw statisticsStorage.saveError; return actual.saveStatisticsLedger(...args); } }; }); vi.mock("../src/main/storage", async () => { const actual = await vi.importActual("../src/main/storage"); return { ...actual, loadSettings: (...args: Parameters) => bootStorage.settings ? bootStorage.settings as AppSettings : actual.loadSettings(...args), loadSessionWithStatus: (...args: Parameters) => bootStorage.loadResult ? bootStorage.loadResult as ReturnType : actual.loadSessionWithStatus(...args), resetHistoryForRetention: (...args: Parameters) => { if (bootStorage.historyResetError) throw bootStorage.historyResetError; return actual.resetHistoryForRetention(...args); }, clearHistory: (...args: Parameters) => { if (bootStorage.historyClearError) throw bootStorage.historyClearError; return actual.clearHistory(...args); }, acquirePersistenceBarrier: async () => { bootStorage.barrierAcquisitions += 1; return { release: async ({ replayBlocked }: { replayBlocked: boolean }) => { bootStorage.barrierReleases.push(replayBlocked); bootStorage.barrierReleaseHook?.(replayBlocked); } }; }, createFileRollback: (...args: Parameters) => { if (bootStorage.rollbackCreateError) throw bootStorage.rollbackCreateError; return actual.createFileRollback(...args); }, saveSettings: (...args: Parameters) => { if (bootStorage.settingsSaveError) throw bootStorage.settingsSaveError; return actual.saveSettings(...args); }, saveSession: (...args: Parameters) => { if (bootStorage.sessionSaveError) throw bootStorage.sessionSaveError; return actual.saveSession(...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(), persistNowSync: 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; bootStorage.historyResetError = null; bootStorage.historyClearError = null; bootStorage.settingsSaveError = null; bootStorage.sessionSaveError = null; bootStorage.rollbackCreateError = null; bootStorage.barrierAcquisitions = 0; bootStorage.barrierReleases = []; bootStorage.barrierReleaseHook = null; statisticsStorage.saveError = null; debugStorage.configError = null; debugStorage.restartCalls = 0; debugStorage.restartErrors = []; configureNetworkProxy(defaultSettings()); vi.restoreAllMocks(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); describe("AppController collector persistence", () => { it("updates and synchronously flushes the latest collector state before returning", () => { const state: CollectorPersistenceState = { packages: [], collapsedPackageIds: [] }; const operations: string[] = []; const controller = Object.create(AppController.prototype) as any; controller.collectorStore = { update: (value: CollectorPersistenceState) => { expect(value).toEqual(state); operations.push("update"); }, flushSync: () => { operations.push("flush"); }, getState: () => { operations.push("get"); return structuredClone(state); } }; expect(controller.saveCollectorStateSync(state)).toEqual(state); expect(operations).toEqual(["update", "flush", "get"]); }); }); describe("AppController full backup history restore", () => { it("clears existing history when a full backup explicitly contains an empty history", async () => { const settings = { ...defaultSettings(), backupIncludeDownloads: true, historyRetentionMode: "permanent" as const }; const controller = createController(settings) as any; controller.manager = { setSettings: vi.fn(), stop: vi.fn(), abortAllPostProcessing: vi.fn(), clearPersistTimer: vi.fn(), skipShutdownPersist: false, blockAllPersistence: false }; controller.restoreRemoteDiagnosticsFromBackup = vi.fn(); saveHistory(controller.storagePaths, [{ id: "existing", name: "Alt" } as never]); const payload = Buffer.from(JSON.stringify({ version: 2, kind: "full", settings, session: emptySession(), history: [] }), "utf8"); expect((await controller.importBackup(payload)).restored).toBe(true); expect(bootStorage.barrierAcquisitions).toBe(1); expect(bootStorage.barrierReleases).toEqual([false]); expect(loadHistory(controller.storagePaths)).toEqual([]); expect(JSON.parse(fs.readFileSync(`${controller.storagePaths.historyFile}.bak`, "utf8"))).toEqual([]); }); it("rejects a nonempty backup history when every entry is invalid", async () => { const settings = { ...defaultSettings(), backupIncludeDownloads: true, historyRetentionMode: "permanent" as const }; const controller = createController(settings) as any; controller.manager = { setSettings: vi.fn() }; saveHistory(controller.storagePaths, [{ id: "existing", name: "Alt" } as never]); const payload = Buffer.from(JSON.stringify({ version: 2, kind: "full", settings, session: emptySession(), history: [null] }), "utf8"); await expect(controller.importBackup(payload)).resolves.toMatchObject({ restored: false }); expect(loadHistory(controller.storagePaths).map((entry) => entry.id)).toEqual(["existing"]); }); it("keeps settings, session ownership, and existing history untouched when authoritative history replacement fails", async () => { const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current", backupIncludeDownloads: true, historyRetentionMode: "permanent" as const }; const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" }; const controller = createController(currentSettings) as any; controller.manager = { setSettings: vi.fn(), stop: vi.fn(), abortAllPostProcessing: vi.fn(), clearPersistTimer: vi.fn(), skipShutdownPersist: false, blockAllPersistence: false }; controller.restoreRemoteDiagnosticsFromBackup = vi.fn(); saveHistory(controller.storagePaths, [{ id: "existing", name: "Alt" } as never]); const payload = Buffer.from(JSON.stringify({ version: 2, kind: "full", settings: importedSettings, session: emptySession(), history: [{ id: "imported", name: "Neu" }] }), "utf8"); const renameFile = fs.renameSync.bind(fs); let failed = false; const rename = vi.spyOn(fs, "renameSync").mockImplementation((source, destination) => { if (!failed && String(destination) === controller.storagePaths.historyFile) { failed = true; throw Object.assign(new Error("history locked"), { code: "EPERM" }); } return renameFile(source, destination); }); try { await expect(controller.importBackup(payload)).rejects.toThrow("history locked"); } finally { rename.mockRestore(); } expect(controller.getSettings().outputDir).toBe("C:\\Current"); expect(controller.manager.setSettings).not.toHaveBeenCalled(); expect(controller.manager.stop).not.toHaveBeenCalled(); expect(loadHistory(controller.storagePaths).map((entry) => entry.id)).toEqual(["existing"]); expect(bootStorage.barrierReleases).toEqual([true]); }); it("restores existing history when a later full-import write fails", async () => { const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current", backupIncludeDownloads: true, historyRetentionMode: "permanent" as const }; const controller = createController(currentSettings) as any; controller.manager = { setSettings: vi.fn(), stop: vi.fn(), abortAllPostProcessing: vi.fn(), clearPersistTimer: vi.fn(), skipShutdownPersist: false, blockAllPersistence: false }; controller.restoreRemoteDiagnosticsFromBackup = vi.fn(); saveHistory(controller.storagePaths, [{ id: "existing", name: "Alt" } as never]); bootStorage.settingsSaveError = new Error("settings locked"); const payload = Buffer.from(JSON.stringify({ version: 2, kind: "full", settings: { ...currentSettings, outputDir: "C:\\Imported" }, session: emptySession(), history: [{ id: "imported", name: "Neu" }] }), "utf8"); await expect(controller.importBackup(payload)).rejects.toThrow("settings locked"); expect(controller.getSettings().outputDir).toBe("C:\\Current"); expect(controller.manager.setSettings).not.toHaveBeenCalled(); expect(loadHistory(controller.storagePaths).map((entry) => entry.id)).toEqual(["existing"]); }); it.each(["session", "statistics", "remote"] as const)("rolls back every persisted and runtime surface when %s persistence fails", async (failure) => { const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current", backupIncludeDownloads: true, historyRetentionMode: "permanent" as const }; const controller = createController(currentSettings) as any; controller.manager = { setSettings: vi.fn(), stop: vi.fn(), abortAllPostProcessing: vi.fn(), clearPersistTimer: vi.fn(), skipShutdownPersist: false, blockAllPersistence: false }; const currentSession = queuedSession(); saveSettings(controller.storagePaths, currentSettings); saveSession(controller.storagePaths, currentSession); saveHistory(controller.storagePaths, [{ id: "existing", name: "Alt" } as never]); saveStatisticsLedger(controller.storagePaths.statisticsFile, createStatisticsLedger(1_000)); for (const [name, value] of [["debug_host.txt", "127.0.0.1\n"], ["debug_port.txt", "9868\n"], ["debug_allowlist.txt", "192.0.2.1\n"]]) { fs.writeFileSync(path.join(controller.storagePaths.baseDir, name), value, "utf8"); } if (failure === "session") bootStorage.sessionSaveError = new Error("session locked"); if (failure === "statistics") statisticsStorage.saveError = new Error("statistics locked"); if (failure === "remote") debugStorage.configError = new Error("remote locked"); const importedSession = emptySession(); importedSession.packageOrder = ["imported-package"]; const payload = Buffer.from(JSON.stringify({ version: 2, kind: "full", settings: { ...currentSettings, outputDir: "C:\\Imported" }, session: importedSession, history: [{ id: "imported", name: "Neu" }], statistics: createStatisticsLedger(2_000), remoteDiagnostics: { allowlist: ["198.51.100.8"] } }), "utf8"); await expect(controller.importBackup(payload)).rejects.toThrow(`${failure} locked`); expect(controller.getSettings().outputDir).toBe("C:\\Current"); expect(controller.manager.setSettings).not.toHaveBeenCalled(); expect(controller.manager.stop).not.toHaveBeenCalled(); expect(controller.manager.abortAllPostProcessing).not.toHaveBeenCalled(); expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current"); expect(loadSession(controller.storagePaths).packageOrder).toEqual(["pkg"]); expect(loadHistory(controller.storagePaths).map((entry) => entry.id)).toEqual(["existing"]); expect(loadStatisticsLedger(controller.storagePaths.statisticsFile).startedAt).toBe(1_000); expect(fs.readFileSync(path.join(controller.storagePaths.baseDir, "debug_allowlist.txt"), "utf8")).toBe("192.0.2.1\n"); }); it("restores runtime settings and persistence flags when full-import finalization fails", async () => { const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current", backupIncludeDownloads: true }; const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" }; const controller = createController(currentSettings) as any; controller.manager = { setSettings: vi.fn(), stop: vi.fn(() => { throw new Error("stop failed"); }), abortAllPostProcessing: vi.fn(), clearPersistTimer: vi.fn(), skipShutdownPersist: false, blockAllPersistence: false }; saveSettings(controller.storagePaths, currentSettings); saveSession(controller.storagePaths, queuedSession()); const payload = Buffer.from(JSON.stringify({ version: 2, kind: "full", settings: importedSettings, session: emptySession(), history: [] }), "utf8"); await expect(controller.importBackup(payload)).rejects.toThrow("stop failed"); expect(controller.getSettings().outputDir).toBe("C:\\Current"); expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current"); expect(controller.manager.setSettings).toHaveBeenNthCalledWith(1, expect.objectContaining({ outputDir: "C:\\Imported" })); expect(controller.manager.setSettings).toHaveBeenNthCalledWith(2, currentSettings, { settingsOnlyImport: true }); expect(controller.manager.skipShutdownPersist).toBe(false); expect(controller.manager.blockAllPersistence).toBe(false); expect(bootStorage.barrierReleases).toEqual([true]); expect(controller.audit).not.toHaveBeenCalled(); }); }); describe("AppController Proxy-only account validation", () => { it("rejects account checks before network access when no proxy list is configured", async () => { const controller = createController({ ...defaultSettings(), proxyDownloadEnabled: true, proxyListPath: "" }) as any; controller.applyNetworkProxyConfiguration(); await expect(controller.checkAccountCredentials({ kind: "realdebrid-api", secret: "test-token" })).rejects.toThrow("proxy_only_account:proxy_list_missing"); }); }); describe("AppController settings-only backup transactions", () => { it("restores an online proxy list to a managed local path", async () => { const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" }; const importedSettings = { ...currentSettings, outputDir: "C:\\Imported", proxyDownloadEnabled: true, proxyListPath: "C:\\OtherServer\\proxy.txt", proxyApiProxyIndex: 1 }; const controller = createController(currentSettings) as any; controller.applyNetworkProxyConfiguration = vi.fn(); const content = "proxy-user:proxy-secret@192.0.2.10:8080\n"; const result = await controller.applySettingsOnlyBackup(importedSettings, undefined, false, content); const managedPath = getManagedOnlineProxyListPath(controller.storagePaths.baseDir); expect(result).toEqual({ proxyListRestored: true, proxyOnlyDisabled: false }); expect(fs.readFileSync(managedPath, "utf8")).toBe(content); expect(controller.getSettings().proxyDownloadEnabled).toBe(true); expect(controller.getSettings().proxyListPath).toBe(managedPath); expect(loadSettings(controller.storagePaths).proxyListPath).toBe(managedPath); }); it("disables Proxy-only when an older online backup has no embedded proxy list", async () => { const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" }; const importedSettings = { ...currentSettings, outputDir: "C:\\Imported", proxyDownloadEnabled: true, proxyListPath: "C:\\OtherServer\\proxy.txt" }; const controller = createController(currentSettings) as any; controller.applyNetworkProxyConfiguration = vi.fn(); const result = await controller.applySettingsOnlyBackup(importedSettings, undefined, false, null); expect(result).toEqual({ proxyListRestored: false, proxyOnlyDisabled: true }); expect(controller.getSettings().proxyDownloadEnabled).toBe(false); expect(controller.getSettings().proxyListPath).toBe(""); }); it("restores the previous managed proxy file when online settings persistence fails", async () => { const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" }; const importedSettings = { ...currentSettings, outputDir: "C:\\Imported", proxyDownloadEnabled: true }; const controller = createController(currentSettings) as any; controller.applyNetworkProxyConfiguration = vi.fn(); const managedPath = getManagedOnlineProxyListPath(controller.storagePaths.baseDir); fs.writeFileSync(managedPath, "192.0.2.1:8080\n", "utf8"); bootStorage.settingsSaveError = new Error("online settings locked"); await expect(controller.applySettingsOnlyBackup( importedSettings, undefined, false, "198.51.100.1:3128\n" )).rejects.toThrow("online settings locked"); expect(fs.readFileSync(managedPath, "utf8")).toBe("192.0.2.1:8080\n"); expect(controller.getSettings().outputDir).toBe("C:\\Current"); }); it("preserves local-backup proxy paths when no online proxy-list mode is supplied", async () => { const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" }; const importedSettings = { ...currentSettings, outputDir: "C:\\Imported", proxyDownloadEnabled: true, proxyListPath: "C:\\LocalBackup\\proxy.txt" }; const controller = createController(currentSettings) as any; controller.applyNetworkProxyConfiguration = vi.fn(); await controller.applySettingsOnlyBackup(importedSettings); expect(controller.getSettings().proxyDownloadEnabled).toBe(true); expect(controller.getSettings().proxyListPath).toBe("C:\\LocalBackup\\proxy.txt"); }); it("applies runtime settings before releasing the successful import barrier", async () => { const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" }; const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" }; const controller = createController(currentSettings) as any; const events: string[] = []; controller.manager.setSettings = vi.fn(() => { events.push("runtime"); }); bootStorage.barrierReleaseHook = (replayBlocked) => { events.push(`release:${replayBlocked}`); }; await controller.applySettingsOnlyBackup(importedSettings); expect(controller.getSettings().outputDir).toBe("C:\\Imported"); expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Imported"); expect(events).toEqual(["runtime", "release:false"]); expect(bootStorage.barrierAcquisitions).toBe(1); }); it("persists the current live session before discarding blocked saves after a settings-only import", async () => { const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" }; const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" }; const controller = createController(currentSettings) as any; const staleSession = { ...emptySession(), summaryText: "before remote restart" }; const liveSession = { ...emptySession(), summaryText: "completed during remote restart" }; const events: string[] = []; saveSession(controller.storagePaths, staleSession); controller.manager = { setSettings: vi.fn(() => { events.push("runtime"); }), persistNowSync: vi.fn(() => { events.push("session"); saveSession(controller.storagePaths, liveSession); }) }; bootStorage.barrierReleaseHook = (replayBlocked) => { events.push(`release:${replayBlocked}`); }; await controller.applySettingsOnlyBackup(importedSettings); expect(loadSession(controller.storagePaths).summaryText).toBe("completed during remote restart"); expect(events).toEqual(["runtime", "session", "release:false"]); }); it("rolls back a settings-only import when the final live-session flush fails", async () => { const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" }; const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" }; const controller = createController(currentSettings) as any; controller.manager = { setSettings: vi.fn(), persistNowSync: vi.fn(() => { throw new Error("live session flush failed"); }) }; saveSettings(controller.storagePaths, currentSettings); await expect(controller.applySettingsOnlyBackup(importedSettings)).rejects.toThrow("live session flush failed"); expect(controller.getSettings().outputDir).toBe("C:\\Current"); expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current"); expect(controller.manager.setSettings).toHaveBeenNthCalledWith(1, expect.objectContaining({ outputDir: "C:\\Imported" }), { settingsOnlyImport: true }); expect(controller.manager.setSettings).toHaveBeenNthCalledWith(2, currentSettings, { settingsOnlyImport: true }); expect(bootStorage.barrierReleases).toEqual([true]); expect(controller.audit).not.toHaveBeenCalled(); }); it("releases the persistence barrier when the rollback snapshot cannot be created", async () => { const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" }; const controller = createController(currentSettings) as any; bootStorage.rollbackCreateError = new Error("rollback snapshot unreadable"); await expect(controller.applySettingsOnlyBackup({ ...currentSettings, outputDir: "C:\\Imported" })) .rejects.toThrow("rollback snapshot unreadable"); expect(controller.getSettings().outputDir).toBe("C:\\Current"); expect(bootStorage.barrierAcquisitions).toBe(1); expect(bootStorage.barrierReleases).toEqual([true]); }); it("does not reconfigure logs or runtime settings before settings persistence succeeds", async () => { const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current", logStorageLocation: "appdata" as const }; const controller = createController(currentSettings) as any; controller.logDirectory = "C:\\CurrentLogs"; controller.reconfigureLogStorage = vi.fn(() => { controller.logDirectory = "C:\\ChangedLogs"; return true; }); saveSettings(controller.storagePaths, currentSettings); bootStorage.settingsSaveError = new Error("settings-only locked"); await expect(controller.applySettingsOnlyBackup({ ...currentSettings, outputDir: "C:\\Imported", logStorageLocation: "desktop" })).rejects.toThrow("settings-only locked"); expect(controller.getSettings().outputDir).toBe("C:\\Current"); expect(controller.logDirectory).toBe("C:\\CurrentLogs"); expect(controller.reconfigureLogStorage).not.toHaveBeenCalled(); expect(controller.manager.setSettings).not.toHaveBeenCalled(); expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current"); }); it("rolls back settings when remote diagnostics persistence fails", async () => { const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" }; const controller = createController(currentSettings) as any; saveSettings(controller.storagePaths, currentSettings); fs.writeFileSync(path.join(controller.storagePaths.baseDir, "debug_allowlist.txt"), "192.0.2.1\n", "utf8"); debugStorage.configError = new Error("remote settings-only locked"); await expect(controller.applySettingsOnlyBackup( { ...currentSettings, outputDir: "C:\\Imported" }, { allowlist: ["198.51.100.8"] }, true )).rejects.toThrow("remote settings-only locked"); expect(controller.getSettings().outputDir).toBe("C:\\Current"); expect(controller.manager.setSettings).not.toHaveBeenCalled(); expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current"); expect(fs.readFileSync(path.join(controller.storagePaths.baseDir, "debug_allowlist.txt"), "utf8")).toBe("192.0.2.1\n"); }); it("rejects and rolls back a settings-only import when the restored debug server cannot start", async () => { const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" }; const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" }; const controller = createController(currentSettings) as any; controller.persistRemoteDiagnosticsFromBackup = vi.fn(() => ({ host: "0.0.0.0", port: 9876, allowlist: ["198.51.100.8"] })); saveSettings(controller.storagePaths, currentSettings); debugStorage.restartErrors = [new Error("imported debug server failed")]; await expect(controller.applySettingsOnlyBackup( importedSettings, { allowlist: ["198.51.100.8"] }, true )).rejects.toThrow("imported debug server failed"); expect(controller.getSettings().outputDir).toBe("C:\\Current"); expect(loadSettings(controller.storagePaths).outputDir).toBe("C:\\Current"); expect(controller.manager.setSettings).not.toHaveBeenCalled(); expect(debugStorage.restartCalls).toBe(2); expect(controller.audit).not.toHaveBeenCalled(); expect(bootStorage.barrierAcquisitions).toBe(1); expect(bootStorage.barrierReleases).toEqual([true]); }); }); describe("AppController history failure isolation", () => { it("does not abort package finalization when primary and backup history are unreadable", () => { const controller = createController({ ...defaultSettings(), historyRetentionMode: "permanent" }) as any; fs.writeFileSync(controller.storagePaths.historyFile, "{broken", "utf8"); fs.writeFileSync(`${controller.storagePaths.historyFile}.bak`, "{also-broken", "utf8"); expect(() => controller.recordHistoryEntry({ id: "history-failure", name: "Paket" } as never)).not.toThrow(); }); }); describe("AppController history retention lifecycle", () => { function prepareBoot(retentionMode: "permanent" | "session"): void { const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-controller-history-lifecycle-")); tempDirs.push(root); appState.userDataDir = root; bootStorage.settings = { ...defaultSettings(), historyRetentionMode: retentionMode } satisfies AppSettings; bootStorage.loadResult = { session: emptySession(), status: "ok", wasRunning: false }; } it("logs and tolerates retention cleanup failures during startup", () => { prepareBoot("session"); bootStorage.historyResetError = Object.assign(new Error("startup history busy"), { code: "EBUSY" }); const warning = vi.spyOn(logger, "warn").mockImplementation(() => {}); let controller: AppController | null = null; expect(() => { controller = new AppController(); }).not.toThrow(); expect(warning).toHaveBeenCalledWith(expect.stringContaining("startup history busy")); if (controller) liveControllers.push(controller); }); it("logs and tolerates retention cleanup failures during shutdown", async () => { prepareBoot("permanent"); const controller = new AppController(); (controller as any).settings.historyRetentionMode = "session"; bootStorage.historyClearError = Object.assign(new Error("shutdown history busy"), { code: "EBUSY" }); const warning = vi.spyOn(logger, "warn").mockImplementation(() => {}); await expect(controller.shutdown()).resolves.toBeUndefined(); expect(warning).toHaveBeenCalledWith(expect.stringContaining("shutdown history busy")); }); }); describe("AppController history retention updates", () => { it("keeps the previous controller state when retention cleanup fails", () => { const controller = createController({ ...defaultSettings(), historyRetentionMode: "permanent" }); bootStorage.historyResetError = Object.assign(new Error("retention history busy"), { code: "EBUSY" }); expect(() => controller.updateSettings({ historyRetentionMode: "session" })).toThrow("retention history busy"); expect(controller.getSettings().historyRetentionMode).toBe("permanent"); expect((controller as any).manager.setSettings).not.toHaveBeenCalled(); }); it("restores history when settings persistence fails after retention cleanup", () => { const controller = createController({ ...defaultSettings(), historyRetentionMode: "permanent" }); saveHistory((controller as any).storagePaths, [{ id: "kept", name: "Behalten" } as never]); bootStorage.settingsSaveError = new Error("settings history locked"); expect(() => controller.updateSettings({ historyRetentionMode: "session" })).toThrow("settings history locked"); expect(controller.getSettings().historyRetentionMode).toBe("permanent"); expect(loadHistory((controller as any).storagePaths).map((entry) => entry.id)).toEqual(["kept"]); }); }); describe("AppController work directory settings updates", () => { it("creates all enabled missing work directories immediately after saving the option", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-controller-work-dirs-")); tempDirs.push(root); const outputDir = path.join(root, "downloads"); const extractDir = path.join(root, "extracted"); const mkvLibraryDir = path.join(root, "videos"); const controller = createController({ ...defaultSettings(), outputDir, extractDir, mkvLibraryDir, autoExtract: true, collectMkvToLibrary: true, createWorkDirectoriesOnStartup: false }); controller.updateSettings({ createWorkDirectoriesOnStartup: true }); expect(fs.statSync(outputDir).isDirectory()).toBe(true); expect(fs.statSync(extractDir).isDirectory()).toBe(true); expect(fs.statSync(mkvLibraryDir).isDirectory()).toBe(true); }); it("recreates a missing directory when enabled work-directory settings are saved unchanged", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-controller-work-dirs-")); tempDirs.push(root); const outputDir = path.join(root, "downloads"); const controller = createController({ ...defaultSettings(), outputDir, autoExtract: false, collectMkvToLibrary: false, createWorkDirectoriesOnStartup: true }); controller.updateSettings({ createWorkDirectoriesOnStartup: true }); expect(fs.statSync(outputDir).isDirectory()).toBe(true); expect((controller as any).manager.setSettings).not.toHaveBeenCalled(); }); it("does not create directories when the settings cannot be persisted", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-controller-work-dirs-")); tempDirs.push(root); const outputDir = path.join(root, "downloads"); const controller = createController({ ...defaultSettings(), outputDir, createWorkDirectoriesOnStartup: false }); bootStorage.settingsSaveError = new Error("settings work directories locked"); expect(() => controller.updateSettings({ createWorkDirectoriesOnStartup: true })) .toThrow("settings work directories locked"); expect(fs.existsSync(outputDir)).toBe(false); expect(controller.getSettings().createWorkDirectoriesOnStartup).toBe(false); }); }); describe("AppController provider quota reset statistics", () => { it("persists quota settings before rebasing statistics and applying runtime settings", () => { const settings = { ...defaultSettings(), providerDailyUsageBytes: { realdebrid: 1_024 } }; const controller = createController(settings) as any; controller.manager = { getSettings: vi.fn(() => settings), rebaseStatisticsProviderDailyUsage: vi.fn(), setSettings: vi.fn() }; const result = controller.resetProviderDailyUsage("realdebrid"); expect(result.providerDailyUsageBytes.realdebrid ?? 0).toBe(0); expect(controller.manager.rebaseStatisticsProviderDailyUsage).toHaveBeenCalledWith("realdebrid", 0); expect(controller.manager.rebaseStatisticsProviderDailyUsage.mock.invocationCallOrder[0]) .toBeLessThan(controller.manager.setSettings.mock.invocationCallOrder[0]); }); it("rolls back quota settings when statistics rebasing cannot be persisted", () => { const settings = { ...defaultSettings(), providerDailyUsageBytes: { realdebrid: 1_024 } }; const controller = createController(settings) as any; controller.manager = { getSettings: vi.fn(() => settings), rebaseStatisticsProviderDailyUsage: vi.fn(() => { throw new Error("rebase ledger locked"); }), setSettings: vi.fn() }; saveSettings(controller.storagePaths, settings); expect(() => controller.resetProviderDailyUsage("realdebrid")).toThrow("rebase ledger locked"); expect(controller.getSettings().providerDailyUsageBytes.realdebrid).toBe(1_024); expect(controller.manager.setSettings).not.toHaveBeenCalled(); expect(loadSettings(controller.storagePaths).providerDailyUsageBytes.realdebrid).toBe(1_024); }); }); 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); }); });