Files
Multi-Debrid-Downloader/tests/collector-store.test.ts
Sucukdeluxe bdcf9e3754 Persist collector state and stabilize download controls
Persist collector packages and disclosure state through validated atomic AppData storage with backup recovery and shutdown synchronization. Align download sidebar metrics with the hidden-extracted presentation scope and keep Start available through temporary Real-Debrid cooldowns while preserving hard account blocks.
2026-08-26 17:24:07 +02:00

188 lines
6.2 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 { CollectorStore } from "../src/main/collector-store";
import type { CollectorPersistenceState } from "../src/shared/collector";
const roots: string[] = [];
function createFilePath(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-collector-store-"));
roots.push(root);
return path.join(root, "collector.json");
}
function state(id: string, collapsed = true): CollectorPersistenceState {
return {
packages: [{
id: `package-${id}`,
name: `Package ${id}`,
nameSource: "explicit",
addedAt: 1_700_000_000_000,
links: [{
id: `link-${id}`,
url: `https://example.com/${id}`,
fileName: `${id}.rar`,
fileSizeBytes: 1024,
hoster: "example.com",
availability: "online",
status: "ready",
addedAt: 1_700_000_000_000
}]
}],
collapsedPackageIds: collapsed ? [`package-${id}`] : []
};
}
afterEach(() => {
vi.useRealTimers();
for (const root of roots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
describe("CollectorStore", () => {
it("starts empty when no persistence file exists", () => {
const store = new CollectorStore(createFilePath());
expect(store.getState()).toEqual({ packages: [], collapsedPackageIds: [] });
});
it("persists a versioned state and loads an independent clone", () => {
const filePath = createFilePath();
const store = new CollectorStore(filePath);
const expected = state("roundtrip");
store.update(expected);
store.flushSync();
const payload = JSON.parse(fs.readFileSync(filePath, "utf8"));
expect(payload).toMatchObject({
version: 1,
packages: expected.packages,
collapsedPackageIds: expected.collapsedPackageIds
});
expect(payload.updatedAt).toEqual(expect.any(Number));
const loaded = new CollectorStore(filePath);
const first = loaded.getState();
first.packages[0].name = "Changed outside";
expect(loaded.getState()).toEqual(expected);
});
it("recovers a valid backup when the primary file is corrupted", () => {
const filePath = createFilePath();
const expected = state("backup");
const store = new CollectorStore(filePath);
store.update(expected);
store.flushSync();
fs.writeFileSync(filePath, "{broken", "utf8");
const recovered = new CollectorStore(filePath);
expect(recovered.getState()).toEqual(expected);
expect(JSON.parse(fs.readFileSync(filePath, "utf8"))).toMatchObject({
version: 1,
packages: expected.packages
});
});
it("prefers a newer valid backup after an interrupted primary replacement", () => {
vi.useFakeTimers();
vi.setSystemTime(1_700_000_000_000);
const filePath = createFilePath();
const store = new CollectorStore(filePath);
store.update(state("old"));
store.flushSync();
const oldPrimary = fs.readFileSync(filePath, "utf8");
store.update(state("latest"));
store.flushSync();
fs.writeFileSync(filePath, oldPrimary, "utf8");
expect(new CollectorStore(filePath).getState()).toEqual(state("latest"));
});
it("keeps the latest update when a scheduled write and flush overlap", async () => {
vi.useFakeTimers();
const filePath = createFilePath();
const store = new CollectorStore(filePath);
store.update(state("old"));
await vi.advanceTimersByTimeAsync(300);
const latest = state("latest", false);
store.update(latest);
store.flushSync();
await vi.runAllTimersAsync();
await Promise.resolve();
expect(store.getState()).toEqual(latest);
expect(new CollectorStore(filePath).getState()).toEqual(latest);
});
it("coalesces pending updates into one delayed persistence", async () => {
vi.useFakeTimers();
const filePath = createFilePath();
const store = new CollectorStore(filePath);
store.update(state("first"));
store.update(state("second"));
expect(fs.existsSync(filePath)).toBe(false);
await vi.advanceTimersByTimeAsync(299);
expect(fs.existsSync(filePath)).toBe(false);
await vi.advanceTimersByTimeAsync(1);
vi.useRealTimers();
for (let attempt = 0; attempt < 50 && !fs.existsSync(filePath); attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
expect(new CollectorStore(filePath).getState()).toEqual(state("second"));
});
it("backs off after a persistent write failure instead of retrying every 300 ms", async () => {
vi.useFakeTimers();
let attempts = 0;
const store = new CollectorStore(createFilePath(), async () => {
attempts += 1;
throw Object.assign(new Error("locked"), { code: "EACCES" });
});
store.update(state("blocked"));
await vi.advanceTimersByTimeAsync(300);
expect(attempts).toBe(1);
await vi.advanceTimersByTimeAsync(999);
expect(attempts).toBe(1);
await vi.advanceTimersByTimeAsync(1);
expect(attempts).toBe(2);
});
it("ignores invalid and oversized persistence files", () => {
const invalidPath = createFilePath();
fs.writeFileSync(invalidPath, JSON.stringify({ version: 1, packages: "wrong", collapsedPackageIds: [], updatedAt: 1 }), "utf8");
const oversizedPath = createFilePath();
fs.writeFileSync(oversizedPath, "", "utf8");
fs.truncateSync(oversizedPath, 64 * 1024 * 1024 + 1);
expect(new CollectorStore(invalidPath).getState()).toEqual({ packages: [], collapsedPackageIds: [] });
expect(new CollectorStore(oversizedPath).getState()).toEqual({ packages: [], collapsedPackageIds: [] });
});
it("rejects a state that would be larger than the load limit", () => {
const store = new CollectorStore(createFilePath());
const longUrl = `https://example.com/${"a".repeat(32_740)}`;
const longName = "n".repeat(1_024);
const packages = Array.from({ length: 2_000 }, (_, index) => ({
...state(String(index)).packages[0],
id: `package-${index}`,
name: longName,
links: [{
...state(String(index)).packages[0].links[0],
id: `link-${index}`,
url: longUrl
}]
}));
expect(() => store.update({ packages, collapsedPackageIds: [] })).toThrow("Linksammler-Speicherzustand ist zu groß");
});
});