Files
Multi-Debrid-Downloader/tests/statistics-ledger.test.ts
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

296 lines
12 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 { defaultSettings } from "../src/main/constants";
import { DownloadManager } from "../src/main/download-manager";
import { createStoragePaths, emptySession } from "../src/main/storage";
import {
RollingAccountStatisticsAccumulator,
addStatisticsAccountBytesInPlace,
aggregateStatisticsRange,
createStatisticsLedger,
loadStatisticsLedger,
normalizeStatisticsLedger,
projectStatisticsLedger,
recordStatisticsActiveInterval,
recordStatisticsBytes,
recordStatisticsOutcome,
saveStatisticsLedger
} from "../src/main/statistics-ledger";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
function localTime(day: number, hour = 12): number {
return new Date(2026, 7, day, hour, 0, 0, 0).getTime();
}
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 = {
version: 1,
startedAt: now - 1_000,
days: [{
day: "2026-08-10",
downloadedBytes: 1_024,
measuredBytes: 1_024,
completedFiles: 1,
failedFiles: 0,
activeDownloadMs: 100,
providers: { realdebrid: { bytes: 1_024, completed: 1, failed: 0 } }
}]
};
const migrated = normalizeStatisticsLedger(legacy, now);
expect(migrated.version).toBe(2);
expect(migrated.days).toEqual(legacy.days);
expect(migrated.minutes).toEqual([]);
});
it("normalizes, merges, and bounds sparse account minute history", () => {
const now = localTime(10);
const currentMinute = Math.floor(now / 60_000) * 60_000;
const keptMinute = currentMinute - (47 * 60 * 60 * 1_000);
const raw = {
version: 2,
startedAt: now - 1_000,
days: [],
minutes: [
{
minute: keptMinute,
downloadedBytes: 1,
accounts: {
rdw_one: { provider: "realdebrid", label: "secret@example.test", bytes: 20 }
}
},
{
minute: keptMinute,
downloadedBytes: 1,
accounts: {
rdw_one: { provider: "realdebrid", label: "New label", bytes: 30 },
"https://unsafe": { provider: "realdebrid", label: "Unsafe", bytes: 99 }
}
},
{
minute: currentMinute - (49 * 60 * 60 * 1_000),
accounts: { rdw_old: { provider: "realdebrid", label: "Old", bytes: 500 } }
},
{
minute: currentMinute + 120_000,
accounts: { rdw_future: { provider: "realdebrid", label: "Future", bytes: 500 } }
}
]
};
const normalized = normalizeStatisticsLedger(raw, now);
expect(normalized.minutes).toHaveLength(1);
expect(normalized.minutes[0].minute).toBe(keptMinute);
expect(normalized.minutes[0].downloadedBytes).toBe(50);
expect(normalized.minutes[0].accounts.rdw_one.bytes).toBe(50);
expect(normalized.minutes[0].accounts.rdw_one.label).not.toContain("secret@example.test");
expect(normalized.minutes[0].accounts).not.toHaveProperty("https://unsafe");
});
it("records separate accounts and projects no minute history into renderer snapshots", () => {
const now = localTime(10);
const ledger = createStatisticsLedger(now);
addStatisticsAccountBytesInPlace(ledger, "realdebrid", 50, "rdw_one", "Primary", now);
addStatisticsAccountBytesInPlace(ledger, "realdebrid", 75, "rdw_two", "Secondary", now);
addStatisticsAccountBytesInPlace(ledger, "debridlink", 25, undefined, undefined, now);
expect(ledger.minutes).toHaveLength(1);
expect(ledger.minutes[0].downloadedBytes).toBe(150);
expect(ledger.minutes[0].accounts).toMatchObject({
rdw_one: { provider: "realdebrid", label: "Primary", bytes: 50 },
rdw_two: { provider: "realdebrid", label: "Secondary", bytes: 75 },
"provider:debridlink": { provider: "debridlink", label: "Debrid-Link", bytes: 25 }
});
expect(projectStatisticsLedger(ledger, now).minutes).toEqual([]);
expect(ledger.minutes).toHaveLength(1);
});
it("maintains rolling account totals incrementally and expires the boundary minute", () => {
const now = localTime(10);
const oldMinute = now - (24 * 60 * 60 * 1_000) - 60_000;
const boundaryMinute = Math.floor((now - (24 * 60 * 60 * 1_000)) / 60_000) * 60_000;
const ledger = createStatisticsLedger(now);
addStatisticsAccountBytesInPlace(ledger, "realdebrid", 500, "rdw_old", "Old", oldMinute);
addStatisticsAccountBytesInPlace(ledger, "realdebrid", 100, "rdw_boundary", "Boundary", boundaryMinute);
const accumulator = new RollingAccountStatisticsAccumulator(ledger, now);
accumulator.record("realdebrid", 50, "rdw_one", "Primary", now);
accumulator.record("realdebrid", 75, "rdw_two", "Secondary", now);
const current = accumulator.snapshot(now);
expect(current.downloadedBytes).toBe(225);
expect(current.accounts.map((account) => [account.id, account.bytes])).toEqual([
["rdw_boundary", 100],
["rdw_two", 75],
["rdw_one", 50]
]);
expect(current.accounts).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "rdw_old" })]));
const expired = accumulator.snapshot(now + 60_000);
expect(expired.downloadedBytes).toBe(125);
expect(expired.accounts).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: "rdw_boundary" })]));
});
it("aggregates every available day inside a rolling seven-day window without requiring seven complete days", () => {
let ledger = createStatisticsLedger(localTime(10));
ledger = recordStatisticsBytes(ledger, "realdebrid", 100, localTime(7));
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", localTime(7));
ledger = recordStatisticsBytes(ledger, "debridlink", 200, localTime(8));
ledger = recordStatisticsOutcome(ledger, "debridlink", "failed", localTime(8));
ledger = recordStatisticsBytes(ledger, "realdebrid", 300, localTime(10));
ledger = recordStatisticsOutcome(ledger, "realdebrid", "completed", localTime(10));
const aggregate = aggregateStatisticsRange(ledger, 7, localTime(10));
expect(aggregate).toMatchObject({
downloadedBytes: 600,
completedFiles: 2,
failedFiles: 1,
coveredDays: 3
});
expect(aggregate.providers).toEqual({
debridlink: { bytes: 200, completed: 0, failed: 1 },
realdebrid: { bytes: 400, completed: 2, failed: 0 }
});
});
it("excludes data outside the requested calendar window and computes average speed from measured active time", () => {
let ledger = createStatisticsLedger(localTime(10));
ledger = recordStatisticsBytes(ledger, "realdebrid", 8_000, localTime(10));
ledger = recordStatisticsActiveInterval(ledger, localTime(10, 10), localTime(10, 10) + 2_000);
ledger = recordStatisticsBytes(ledger, "debridlink", 99_000, localTime(3));
ledger = recordStatisticsActiveInterval(ledger, localTime(3, 10), localTime(3, 10) + 1_000);
const today = aggregateStatisticsRange(ledger, 1, localTime(10));
const week = aggregateStatisticsRange(ledger, 7, localTime(10));
const month = aggregateStatisticsRange(ledger, 30, localTime(10));
expect(today).toMatchObject({ downloadedBytes: 8_000, activeDownloadMs: 2_000, averageSpeedBps: 4_000 });
expect(week.downloadedBytes).toBe(8_000);
expect(month).toMatchObject({ downloadedBytes: 107_000, activeDownloadMs: 3_000 });
});
it("splits active intervals across local calendar days", () => {
const start = new Date(2026, 7, 9, 23, 59, 59, 500).getTime();
const end = new Date(2026, 7, 10, 0, 0, 0, 500).getTime();
const ledger = recordStatisticsActiveInterval(createStatisticsLedger(start), start, end);
expect(aggregateStatisticsRange(ledger, 1, localTime(10)).activeDownloadMs).toBe(500);
expect(aggregateStatisticsRange(ledger, 1, localTime(9)).activeDownloadMs).toBe(500);
});
it("persists normalized statistics and recovers safely from malformed files", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-statistics-ledger-"));
roots.push(root);
const filePath = path.join(root, "rd_statistics.json");
const ledger = recordStatisticsOutcome(
recordStatisticsBytes(createStatisticsLedger(localTime(10)), "realdebrid", 4_096, localTime(10)),
"realdebrid",
"completed",
localTime(10)
);
saveStatisticsLedger(filePath, ledger);
expect(loadStatisticsLedger(filePath, localTime(10))).toEqual(ledger);
fs.writeFileSync(filePath, "{broken", "utf8");
expect(loadStatisticsLedger(filePath, localTime(11))).toEqual(createStatisticsLedger(localTime(11)));
});
it("retries transient Windows rename failures while preserving the statistics file", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-statistics-rename-"));
roots.push(root);
const filePath = path.join(root, "rd_statistics.json");
const ledger = recordStatisticsBytes(createStatisticsLedger(localTime(10)), "realdebrid", 8_192, localTime(10));
const rename = fs.renameSync.bind(fs);
let attempts = 0;
const spy = vi.spyOn(fs, "renameSync").mockImplementation((source, target) => {
attempts += 1;
if (attempts < 3) {
throw Object.assign(new Error("busy"), { code: "EPERM" });
}
return rename(source, target);
});
saveStatisticsLedger(filePath, ledger);
expect(attempts).toBe(3);
expect(loadStatisticsLedger(filePath, localTime(10))).toEqual(ledger);
spy.mockRestore();
});
it("records provider bytes and terminal outcomes through the download manager and restores them after restart", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-statistics-manager-"));
roots.push(root);
const paths = createStoragePaths(root);
const session = emptySession();
session.items.item = {
id: "item",
packageId: "package",
url: "https://example.test/file",
provider: "realdebrid",
providerLabel: "Real-Debrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 4_096,
totalBytes: 4_096,
progressPercent: 100,
fileName: "file.bin",
targetPath: path.join(root, "file.bin"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig",
createdAt: Date.now(),
updatedAt: Date.now()
};
const manager = new DownloadManager(defaultSettings(), session, paths);
(manager as any).runItemIds.add("item");
(manager as any).recordProviderDownloadedBytes("realdebrid", 4_096);
(manager as any).recordRunOutcome("item", "completed");
manager.persistNowSync();
const current = aggregateStatisticsRange(manager.getStats().statistics, 1);
expect(current).toMatchObject({ downloadedBytes: 4_096, completedFiles: 1, failedFiles: 0 });
expect(current.providers.realdebrid).toEqual({ bytes: 4_096, completed: 1, failed: 0 });
expect(fs.existsSync(paths.statisticsFile)).toBe(true);
const restored = new DownloadManager(defaultSettings(), emptySession(), paths);
expect(aggregateStatisticsRange(restored.getStats().statistics, 1)).toMatchObject({
downloadedBytes: 4_096,
completedFiles: 1
});
});
});