Files
Multi-Debrid-Downloader/tests/session-restart-loss.test.ts
T
Sucukdeluxe 1b7caba2eb 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.
2026-08-24 07:37:55 +02:00

404 lines
14 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DownloadItem, PackageEntry, SessionState } from "../src/shared/types";
import {
cancelPendingAsyncSaves,
createStoragePaths,
emptySession,
loadSession,
loadSessionWithStatus,
loadSettings,
saveSession,
saveSessionAsync,
saveSettings,
saveSettingsAsync
} from "../src/main/storage";
import { defaultSettings } from "../src/main/constants";
import { DownloadManager } from "../src/main/download-manager";
import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log";
import { NotificationEvent, NotificationOutbox } from "../src/main/notification-outbox";
const tempDirs: string[] = [];
afterEach(async () => {
shutdownItemLogs();
shutdownPackageLogs();
for (const dir of tempDirs.splice(0)) {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
fs.rmSync(dir, { recursive: true, force: true });
break;
} catch {
await new Promise((resolve) => setTimeout(resolve, 80));
}
}
}
});
function makePackage(id: string, itemId: string): PackageEntry {
return {
id,
name: `Package ${id}`,
outputDir: "C:/tmp/out",
extractDir: "C:/tmp/extract",
status: "queued",
itemIds: [itemId],
cancelled: false,
enabled: true,
downloadStartedAt: 0,
downloadCompletedAt: 0,
createdAt: 1,
updatedAt: 1
};
}
function makeItem(id: string, packageId: string): DownloadItem {
return {
id,
packageId,
url: `https://example.com/${id}`,
provider: null,
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: `${id}.rar`,
targetPath: "",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "Wartet",
createdAt: 1,
updatedAt: 1
};
}
function sessionWith(ids: string[]): SessionState {
const s = emptySession();
for (const id of ids) {
const itemId = `${id}-item`;
s.packageOrder.push(id);
s.packages[id] = makePackage(id, itemId);
s.items[itemId] = makeItem(itemId, id);
}
return s;
}
const settle = (ms = 250): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
function primaryPackageKeys(sessionFile: string): string[] {
if (!fs.existsSync(sessionFile)) {
return [];
}
try {
const parsed = JSON.parse(fs.readFileSync(sessionFile, "utf8")) as { packages?: Record<string, unknown> };
return Object.keys(parsed.packages || {});
} catch {
return ["<unparseable>"];
}
}
describe("session restart loss", () => {
it("recovers and sends a persisted notification after restart", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-notification-restart-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const queuedEvent: NotificationEvent = {
id: "package:pkg-1:g1",
type: "package_failed",
priority: "error",
createdAt: 1000,
expiresAt: 86401000,
attempts: 0,
nextAttemptAt: 1000,
payload: { title: "Paket fehlgeschlagen", fields: [] }
};
const firstProcess = new NotificationOutbox({
filePath: paths.notificationOutboxFile,
send: async () => true,
now: () => 1000
});
await firstProcess.enqueue(queuedEvent);
const delivered: NotificationEvent[] = [];
const restartedProcess = new NotificationOutbox({
filePath: paths.notificationOutboxFile,
send: async (event) => {
delivered.push(event);
return true;
},
now: () => 1000
});
await restartedProcess.drain();
expect(delivered).toEqual([queuedEvent]);
expect(restartedProcess.getStatus()).toEqual({ queued: 0, lastSuccessAt: 1000, lastFailureAt: 0 });
});
it("does not let a queued stale async save clobber a newer synchronous save", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
cancelPendingAsyncSaves();
await settle(50);
saveSession(paths, sessionWith(["A", "B"]));
const inflight = saveSessionAsync(paths, sessionWith(["A", "B"]));
const queued = saveSessionAsync(paths, sessionWith(["A", "B"]));
saveSession(paths, sessionWith(["A", "B", "C"]));
await inflight;
await queued;
await settle();
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B", "C"]);
});
it("recovers packages from the backup when the primary session file is absent", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(sessionWith(["A", "B"])), "utf8");
expect(fs.existsSync(paths.sessionFile)).toBe(false);
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B"]);
});
it("still treats a truly fresh install (no primary, no backup, no temp) as empty", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages)).toEqual([]);
expect(Object.keys(loaded.items)).toEqual([]);
});
it("recovers from the backup when the primary exists but is empty", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(paths.sessionFile, JSON.stringify(emptySession()), "utf8");
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(sessionWith(["A", "B"])), "utf8");
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B"]);
});
it("does not let an in-flight/queued async settings save clobber a newer synchronous saveSettings", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-settings-race-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
cancelPendingAsyncSaves();
await settle(50);
const withName = (name: string) => ({ ...defaultSettings(), packageName: name });
saveSettings(paths, withName("OLD"));
const inflight = saveSettingsAsync(paths, withName("OLD"));
const queued = saveSettingsAsync(paths, withName("OLD"));
saveSettings(paths, withName("NEW"));
await inflight;
await queued;
await settle();
expect(loadSettings(paths).packageName).toBe("NEW");
});
});
describe("session load status classification", () => {
it("a readable populated primary reports status ok", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-status-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveSession(paths, sessionWith(["A", "B"]));
const result = loadSessionWithStatus(paths);
expect(result.status).toBe("ok");
expect(Object.keys(result.session.packages).sort()).toEqual(["A", "B"]);
});
it("a truly fresh install reports status empty-fresh (no protection needed)", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-status-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
expect(loadSessionWithStatus(paths).status).toBe("empty-fresh");
});
it("a corrupt primary with a good backup reports recovered-backup, not empty", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-status-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(paths.sessionFile, "{ this is not valid json", "utf8");
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(sessionWith(["A", "B"])), "utf8");
const result = loadSessionWithStatus(paths);
expect(result.status).toBe("recovered-backup");
expect(Object.keys(result.session.packages).sort()).toEqual(["A", "B"]);
});
it("THE BUG SIGNATURE: corrupt primary AND corrupt backup (no temp) reports empty-unreadable", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-status-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
fs.writeFileSync(paths.sessionFile, "", "utf8");
fs.writeFileSync(`${paths.sessionFile}.bak`, "garbage not json", "utf8");
const result = loadSessionWithStatus(paths);
expect(result.status).toBe("empty-unreadable");
expect(Object.keys(result.session.packages)).toEqual([]);
});
it("an empty-but-valid primary is status ok (a legitimate empty queue), NOT empty-unreadable", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-status-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveSession(paths, emptySession());
const result = loadSessionWithStatus(paths);
expect(result.status).toBe("ok");
});
});
describe("durable session writes", () => {
it("fsyncs the temp file before renaming on a synchronous save", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-fsync-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const fsyncSpy = vi.spyOn(fs, "fsyncSync");
try {
saveSession(paths, sessionWith(["A"]));
expect(fsyncSpy).toHaveBeenCalled();
} finally {
fsyncSpy.mockRestore();
}
expect(Object.keys(loadSession(paths).packages)).toEqual(["A"]);
});
it("an async save round-trips correctly (close-before-rename works on this platform)", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-fsync-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
cancelPendingAsyncSaves();
await settle(50);
await saveSessionAsync(paths, sessionWith(["A", "B", "C"]));
await settle();
expect(Object.keys(loadSession(paths).packages).sort()).toEqual(["A", "B", "C"]);
});
it("retries a transiently locked session file and recovers (EBUSY)", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-retry-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
saveSession(paths, sessionWith(["A"]));
const realRead = fs.readFileSync;
let lockedReads = 0;
const readSpy = vi.spyOn(fs, "readFileSync").mockImplementation(((p: fs.PathOrFileDescriptor, options?: unknown) => {
if (String(p) === paths.sessionFile && lockedReads < 2) {
lockedReads += 1;
const err = new Error("locked") as NodeJS.ErrnoException;
err.code = "EBUSY";
throw err;
}
return (realRead as (a: unknown, b: unknown) => unknown)(p, options);
}) as typeof fs.readFileSync);
try {
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages)).toEqual(["A"]);
expect(lockedReads).toBe(2);
} finally {
readSpy.mockRestore();
}
});
});
describe("empty-clobber guard", () => {
const managerSettings = (root: string) => ({
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: false,
autoReconnect: false,
autoResumeOnStart: false,
retryLimit: 0
});
it("after an unreadable load, a sync save does NOT overwrite the on-disk data with empty; a later real change persists, and a genuine clear still works", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-guard-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
saveSession(paths, sessionWith(["GOOD"]));
expect(Object.keys(loadSession(paths).packages)).toEqual(["GOOD"]);
const manager = new DownloadManager(managerSettings(root), emptySession(), paths, { protectEmptyClobber: true });
try {
manager.persistNowSync();
expect(Object.keys(loadSession(paths).packages)).toEqual(["GOOD"]);
const real = sessionWith(["NEW"]);
const m = manager as unknown as { session: SessionState; protectAgainstEmptyClobber: boolean };
m.session.packageOrder = real.packageOrder;
m.session.packages = real.packages;
m.session.items = real.items;
manager.persistNowSync();
expect(primaryPackageKeys(paths.sessionFile)).toEqual(["NEW"]);
expect(m.protectAgainstEmptyClobber).toBe(false);
m.session.packageOrder = [];
m.session.packages = {};
m.session.items = {};
manager.persistNowSync();
expect(primaryPackageKeys(paths.sessionFile)).toEqual([]);
} finally {
manager.blockAllPersistence = true;
manager.clearPersistTimer();
}
});
it("a normal start (status ok) does NOT protect, so removing the last package persists empty immediately", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-guard-"));
tempDirs.push(root);
const paths = createStoragePaths(path.join(root, "state"));
saveSession(paths, sessionWith(["GOOD"]));
const manager = new DownloadManager(managerSettings(root), sessionWith(["GOOD"]), paths, { protectEmptyClobber: false });
try {
const m = manager as unknown as { session: SessionState };
m.session.packageOrder = [];
m.session.packages = {};
m.session.items = {};
manager.persistNowSync();
expect(primaryPackageKeys(paths.sessionFile)).toEqual([]);
} finally {
manager.blockAllPersistence = true;
manager.clearPersistTimer();
}
});
});