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.
404 lines
14 KiB
TypeScript
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, " |