Files
Multi-Debrid-Downloader/tests/statistics-ledger.test.ts

281 lines
11 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("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
});
});
});