feat(notifications): finalize package and run results

This commit is contained in:
Sucukdeluxe
2026-08-22 05:08:48 +02:00
parent c7e48891bb
commit 03da04b629
9 changed files with 1614 additions and 416 deletions
+92 -9
View File
@@ -611,6 +611,14 @@ describe("disk write recovery", () => {
deficitBytes: 640
}));
expect((manager as any).diskReservations.getReservedBytesByVolume().get("remux-volume") ?? 0).toBe(0);
expect(pkg.remuxOperations).toHaveLength(1);
expect(pkg.remuxOperations?.[0]).toMatchObject({
fileName: "Show.S01E01.German.DL.720p.mkv",
status: "failed",
errorCategory: "disk_full"
});
expect(pkg.remuxOperations?.[0].completedAt).toBeGreaterThanOrEqual(pkg.remuxOperations?.[0].startedAt || 0);
expect(pkg.remuxOperations?.[0].durationMs).toBeGreaterThanOrEqual(0);
});
});
@@ -11035,10 +11043,19 @@ describe("download manager", () => {
manager.getSnapshot().session.packages[packageId]?.status === "completed",
25000
);
const snapshot = manager.getSnapshot();
expect(snapshot.session.packages[packageId]?.status).toBe("completed");
expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
}, 30000);
const snapshot = manager.getSnapshot();
expect(snapshot.session.packages[packageId]?.status).toBe("completed");
expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
expect(snapshot.session.packages[packageId]?.archiveOperations).toHaveLength(1);
expect(snapshot.session.packages[packageId]?.archiveOperations?.[0]).toMatchObject({
name: "episode.zip",
itemIds: [itemId],
partCount: 1,
status: "completed",
errorCategory: ""
});
expect(snapshot.session.packages[packageId]?.archiveOperations?.[0].durationMs).toBeGreaterThanOrEqual(0);
}, 30000);
it("does not fail startup post-processing when source package dir is missing but extract output exists", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
@@ -14516,7 +14533,7 @@ describe("mega-debrid api/web resolution overlap gate", () => {
});
});
describe("package priority ordering", () => {
describe("package priority ordering", () => {
function buildPriorityManager(priorities: Array<[string, "high" | "normal" | "low"]>): {
manager: DownloadManager;
session: ReturnType<typeof emptySession>;
@@ -14586,7 +14603,7 @@ describe("package priority ordering", () => {
expect(session.packageOrder).toEqual(["high-a", "normal-a", "target", "low-a"]);
});
it("keeps an unchanged priority in place", () => {
it("keeps an unchanged priority in place", () => {
const { manager, session } = buildPriorityManager([
["high-a", "high"],
["high-b", "high"],
@@ -14595,6 +14612,72 @@ describe("package priority ordering", () => {
manager.setPackagePriority("high-a", "high");
expect(session.packageOrder).toEqual(["high-a", "high-b", "normal-a"]);
});
});
expect(session.packageOrder).toEqual(["high-a", "high-b", "normal-a"]);
});
});
describe("package lifecycle telemetry boundaries", () => {
it("records queued, slot start and terminal timestamps around real post-processing", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-lifecycle-boundaries-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "lifecycle-package";
const itemId = "lifecycle-item";
const createdAt = Date.now() - 5_000;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Lifecycle",
outputDir: path.join(root, "downloads", "lifecycle"),
extractDir: path.join(root, "extract", "lifecycle"),
status: "completed",
itemIds: [itemId],
cancelled: false,
enabled: true,
downloadStartedAt: createdAt,
downloadCompletedAt: createdAt + 1_000,
downloadEndedAt: createdAt + 1_000,
createdAt,
updatedAt: createdAt + 1_000
};
session.items[itemId] = {
id: itemId,
packageId,
url: "https://dummy/lifecycle",
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 65_536,
totalBytes: 65_536,
progressPercent: 100,
fileName: "lifecycle.txt",
targetPath: path.join(root, "downloads", "lifecycle", "lifecycle.txt"),
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Fertig",
createdAt,
updatedAt: createdAt + 1_000
};
fs.mkdirSync(path.dirname(session.items[itemId].targetPath), { recursive: true });
fs.writeFileSync(session.items[itemId].targetPath, Buffer.alloc(65_536, 1));
const manager = new DownloadManager(
{ ...defaultSettings(), autoExtract: false },
session,
createStoragePaths(path.join(root, "state"))
);
const state = manager as any;
state.runPackageIds.add(packageId);
state.handlePackagePostProcessing = vi.fn(async () => undefined);
await state.runPackagePostProcessing(packageId);
const pkg = session.packages[packageId];
expect(session.items[itemId].status).toBe("completed");
expect(pkg.postProcessQueuedAt).toBeGreaterThan(0);
expect(pkg.postProcessStartedAt).toBeGreaterThanOrEqual(pkg.postProcessQueuedAt || 0);
expect(pkg.postProcessCompletedAt).toBeGreaterThanOrEqual(pkg.postProcessStartedAt || 0);
expect(pkg.terminalAt).toBe(pkg.postProcessCompletedAt);
});
});
+105 -3
View File
@@ -185,7 +185,7 @@ describe("history model", () => {
expect(filterHistoryRows(searchable, "all", "d", now).map((row) => row.id)).toEqual(["new", "old"]);
});
it("derives hosters only from valid URL hostnames and clamps the calculated start time", () => {
it("derives hosters only from valid URL hostnames and clamps the calculated start time", () => {
expect(deriveHistoryHoster(["https://rapidgator.net/a", "https://rapidgator.net/b", "https://ddownload.com/c", "not a url"])).toBe("rapidgator.net, ddownload.com");
expect(deriveHistoryHoster([])).toBe("—");
expect(deriveHistoryHoster(undefined)).toBe("—");
@@ -194,8 +194,22 @@ describe("history model", () => {
const row = filterHistoryRows([entry({ id: "provider", name: "Provider", provider: "realdebrid", urls: [] })], "all", "", now)[0];
expect(row.hoster).toBe("—");
expect(row.providerLabel).toBe("Real-Debrid");
});
expect(row.providerLabel).toBe("Real-Debrid");
});
it("uses the authoritative lifecycle start instead of reconstructing it from completion", () => {
const lifecycleStartedAt = todayStart - 45_000;
const lifecycle = entry({
id: "lifecycle-start",
name: "Lifecycle",
startedAt: lifecycleStartedAt,
completedAt: todayStart + 60_000,
durationSeconds: 5
});
expect(deriveHistoryStartAt(lifecycle)).toBe(lifecycleStartedAt);
expect(filterHistoryRows([lifecycle], "all", "", now)[0].startAt).toBe(lifecycleStartedAt);
});
it("prunes removed ids and preserves the original set instance when every id survives", () => {
const stable = new Set(["today", "week"]);
@@ -597,6 +611,94 @@ describe("HistoryView", () => {
expect(html).toContain("https://rapidgator.net/file/test");
});
it("renders authoritative lifecycle timings, counts, failure phase and operation details", () => {
const structured = entry({
id: "structured",
name: "Strukturiert",
status: "partial",
startedAt: todayStart,
downloadEndedAt: todayStart + 60_000,
postProcessStartedAt: todayStart + 65_000,
completedAt: todayStart + 90_000,
downloadDurationSeconds: 60,
extractionDurationSeconds: 12,
remuxDurationSeconds: 8,
postProcessDurationSeconds: 25,
totalDurationSeconds: 90,
successfulFiles: 3,
failedFiles: 1,
cancelledFiles: 0,
archiveCount: 1,
partCount: 16,
outputCount: 10,
failurePhase: "remux",
archiveOperations: [{
id: "archive-1",
name: "show.part01.rar",
itemIds: ["item-1"],
partCount: 16,
startedAt: todayStart + 65_000,
completedAt: todayStart + 77_000,
durationMs: 12_000,
status: "completed",
errorCategory: ""
}],
remuxOperations: [{
id: "remux-1",
fileName: "episode.mkv",
startedAt: todayStart + 77_000,
completedAt: todayStart + 85_000,
durationMs: 8_000,
status: "failed",
errorCategory: "ffmpeg"
}]
});
const html = renderToStaticMarkup(
<HistoryView
actions={createActions()}
model={buildHistoryViewModel([structured], "all", "", [], [structured.id], false, "", now)}
/>
);
for (const label of [
"Download gestartet",
"Download beendet",
"Nachbearbeitung gestartet",
"Abgeschlossen",
"Downloaddauer",
"Entpackdauer",
"Remuxdauer",
"Nachbearbeitungsdauer",
"Gesamtdauer",
"Erfolgreich / Fehlgeschlagen / Abgebrochen",
"Archive / Parts / Ausgaben",
"Fehlerphase",
"Archivvorgänge",
"Remuxvorgänge"
]) {
expect(html).toContain(label);
}
expect(html).toContain("show.part01.rar");
expect(html).toContain("16 Parts");
expect(html).toContain("episode.mkv");
expect(html).toContain("ffmpeg");
expect(html).not.toContain("Downloaddauer (Altbestand)");
});
it("labels durationSeconds honestly for legacy entries", () => {
const legacy = entry({ id: "legacy", name: "Altbestand" });
const html = renderToStaticMarkup(
<HistoryView
actions={createActions()}
model={buildHistoryViewModel([legacy], "all", "", [], [legacy.id], false, "", now)}
/>
);
expect(html).toContain("Downloaddauer (Altbestand)");
expect(html).not.toContain("Download beendet");
expect(html).not.toContain("Nachbearbeitung gestartet");
});
it("uses the global animation setting for the history disclosure surface", () => {
const animated = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], ["today"], false, "", now, true);
const immediate = buildHistoryViewModel(entries.slice(0, 1), "all", "", [], ["today"], false, "", now, false);
+360 -153
View File
@@ -1,153 +1,360 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../src/main/notify", async (importActual) => {
const actual = await importActual<typeof import("../src/main/notify")>();
return { ...actual, sendNotification: vi.fn().mockResolvedValue(true) };
});
import { DownloadManager } from "../src/main/download-manager";
import { defaultSettings } from "../src/main/constants";
import { createStoragePaths, emptySession } from "../src/main/storage";
import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log";
import { shutdownRenameLog } from "../src/main/rename-log";
import { sendNotification } from "../src/main/notify";
const mockedSend = sendNotification as unknown as ReturnType<typeof vi.fn>;
const tempDirs: string[] = [];
afterEach(() => {
mockedSend.mockClear();
shutdownItemLogs();
shutdownPackageLogs();
shutdownRenameLog();
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
function setup(): { manager: DownloadManager; session: ReturnType<typeof emptySession> } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-"));
tempDirs.push(root);
const session = emptySession();
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "out"),
extractDir: path.join(root, "extract"),
notifyUrl: "https://discord.com/api/webhooks/123/abc",
notifyOnPackageCompleted: true,
notifyOnPackageFailed: true
},
session,
createStoragePaths(path.join(root, "state"))
);
return { manager, session };
}
function addPackage(session: ReturnType<typeof emptySession>, itemStatuses: string[]): any {
const pkgId = "pkg-1";
const pkg: any = {
id: pkgId,
name: "Test.Show.S01",
outputDir: "C:/out",
extractDir: "C:/extract",
status: "queued",
itemIds: itemStatuses.map((_s, i) => `it-${i}`),
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 1,
updatedAt: 1
};
session.packages[pkgId] = pkg;
session.packageOrder.push(pkgId);
itemStatuses.forEach((status, i) => {
session.items[`it-${i}`] = {
id: `it-${i}`,
packageId: pkgId,
url: `https://dummy/${i}`,
provider: null,
status,
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: null,
progressPercent: 0,
fileName: `f${i}.rar`,
targetPath: "",
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "",
createdAt: 1,
updatedAt: 1
} as any;
});
return pkg;
}
describe("refreshPackageStatus failed-transition notify", () => {
it("notifies a MIXED package (some success, last finisher failed) — the lost-webhook case", () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["completed", "failed"]);
session.running = true;
(manager as any).refreshPackageStatus(pkg);
expect(pkg.status).toBe("failed");
expect(mockedSend).toHaveBeenCalledTimes(1);
expect(mockedSend.mock.calls[0][1].title).toBe("❌ Paket fehlgeschlagen");
expect(mockedSend.mock.calls[0][1].message).toContain("1 von 2");
});
it("notifies an all-failed package and dedups repeat refreshes", () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["failed", "failed"]);
session.running = true;
(manager as any).refreshPackageStatus(pkg);
(manager as any).refreshPackageStatus(pkg);
expect(pkg.status).toBe("failed");
expect(mockedSend).toHaveBeenCalledTimes(1);
});
it("stays silent outside a run (startup recovery must not spam)", () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["failed"]);
session.running = false;
(manager as any).refreshPackageStatus(pkg);
expect(pkg.status).toBe("failed");
expect(mockedSend).not.toHaveBeenCalled();
});
it("does not notify while items are still pending", () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["failed", "queued"]);
session.running = true;
(manager as any).refreshPackageStatus(pkg);
expect(pkg.status).toBe("queued");
expect(mockedSend).not.toHaveBeenCalled();
});
it("releases the dedup marker when the send ultimately fails (retro-notify possible)", async () => {
const { manager, session } = setup();
const pkg = addPackage(session, ["failed", "failed"]);
session.running = true;
mockedSend.mockResolvedValueOnce(false);
(manager as any).refreshPackageStatus(pkg);
await new Promise((r) => setTimeout(r, 0));
expect((manager as any).notifiedPackages.has(pkg.id)).toBe(false);
});
});
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DownloadManager } from "../src/main/download-manager";
import { defaultSettings } from "../src/main/constants";
import type { NotificationEvent } from "../src/main/notification-outbox";
import { createStoragePaths, emptySession } from "../src/main/storage";
import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log";
import { shutdownRenameLog } from "../src/main/rename-log";
import type { AppSettings, HistoryEntry, PackageEntry } from "../src/shared/types";
const tempDirs: string[] = [];
afterEach(() => {
vi.restoreAllMocks();
shutdownItemLogs();
shutdownPackageLogs();
shutdownRenameLog();
for (const dir of tempDirs.splice(0)) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
function setup(settings: Partial<AppSettings> = {}): {
manager: DownloadManager;
session: ReturnType<typeof emptySession>;
events: NotificationEvent[];
history: HistoryEntry[];
} {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-nh-"));
tempDirs.push(root);
const session = emptySession();
const events: NotificationEvent[] = [];
const history: HistoryEntry[] = [];
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir: path.join(root, "out"),
extractDir: path.join(root, "extract"),
notifyUrl: "https://discord.com/api/webhooks/123/abc",
notifyOnPackageCompleted: true,
notifyOnPackageFailed: true,
notifyOnRunFinished: true,
notifyPackageSuccessMode: "individual",
autoExtract: false,
...settings
},
session,
createStoragePaths(path.join(root, "state")),
{
enqueueNotification: async (event: NotificationEvent) => {
events.push(event);
},
onHistoryEntry: (entry) => history.push(entry)
}
);
return { manager, session, events, history };
}
function addPackage(
session: ReturnType<typeof emptySession>,
statuses: Array<"completed" | "failed" | "cancelled" | "queued"> = ["completed"],
packageId = "pkg-1"
): PackageEntry {
const startedAt = Date.now() - 30_000;
const pkg: PackageEntry = {
id: packageId,
name: `Test ${packageId}`,
outputDir: `C:/out/${packageId}`,
extractDir: `C:/extract/${packageId}`,
status: "queued",
itemIds: statuses.map((_status, index) => `${packageId}-item-${index}`),
cancelled: false,
enabled: true,
priority: "normal",
downloadStartedAt: startedAt,
downloadCompletedAt: startedAt + 10_000,
downloadEndedAt: startedAt + 10_000,
createdAt: startedAt,
updatedAt: startedAt + 10_000
};
session.packages[packageId] = pkg;
session.packageOrder.push(packageId);
statuses.forEach((status, index) => {
const itemId = `${packageId}-item-${index}`;
session.items[itemId] = {
id: itemId,
packageId,
url: `https://dummy/${packageId}/${index}`,
provider: "realdebrid",
status,
retries: 0,
speedBps: 0,
downloadedBytes: status === "completed" ? 1_000 : 0,
totalBytes: 1_000,
progressPercent: status === "completed" ? 100 : 0,
fileName: `${packageId}-${index}.rar`,
targetPath: `C:/out/${packageId}/${packageId}-${index}.rar`,
resumable: true,
attempts: 1,
lastError: status === "failed" ? "offline" : "",
fullStatus: status === "completed" ? "Fertig" : status === "failed" ? "Offline" : "Wartet",
createdAt: startedAt,
updatedAt: startedAt + 10_000
};
});
return pkg;
}
function internal(manager: DownloadManager): any {
return manager as any;
}
async function flushNotifications(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
describe("authoritative package completion", () => {
it("waits for main, deferred, hybrid and file operations before emitting one package result", async () => {
const { manager, session, events, history } = setup();
const pkg = addPackage(session);
const state = internal(manager);
state.runPackageIds.add(pkg.id);
state.packagePostProcessTasks.set(pkg.id, Promise.resolve());
state.packageDeferredPostProcessTasks.set(pkg.id, new Set([Promise.resolve()]));
state.packageHybridPostProcessTasks.set(pkg.id, new Set([Promise.resolve()]));
state.packageFileOpChain.set(pkg.id, Promise.resolve());
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events).toHaveLength(0);
expect(history).toHaveLength(0);
state.packagePostProcessTasks.delete(pkg.id);
state.packageDeferredPostProcessTasks.delete(pkg.id);
state.packageHybridPostProcessTasks.delete(pkg.id);
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events).toHaveLength(0);
state.packageFileOpChain.delete(pkg.id);
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["package_completed"]);
expect(history).toHaveLength(1);
expect(pkg.postProcessCompletedAt).toBeGreaterThan(0);
expect(pkg.terminalAt).toBe(pkg.postProcessCompletedAt);
});
it("turns a deferred remux failure into one immediate failed package event", async () => {
const { manager, session, events, history } = setup({ notifyPackageSuccessMode: "digest" });
const pkg = addPackage(session);
const state = internal(manager);
state.runPackageIds.add(pkg.id);
pkg.remuxOperations = [{
id: "remux-1",
fileName: "episode.mkv",
startedAt: 10_000,
completedAt: 14_000,
durationMs: 4_000,
status: "failed",
errorCategory: "ffmpeg"
}];
state.packageDeferredPostProcessTasks.set(pkg.id, new Set([Promise.resolve()]));
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events).toHaveLength(0);
state.packageDeferredPostProcessTasks.delete(pkg.id);
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["package_failed"]);
expect(events[0].priority).toBe("error");
expect(history[0]).toMatchObject({ status: "failed", failurePhase: "remux", failedFiles: 1 });
});
it("emits a partial package result when the terminal downloads are mixed", async () => {
const { manager, session, events, history } = setup();
const pkg = addPackage(session, ["completed", "failed"]);
session.running = true;
internal(manager).runPackageIds.add(pkg.id);
internal(manager).refreshPackageStatus(pkg);
await flushNotifications();
expect(pkg.status).toBe("failed");
expect(events.map((event) => event.type)).toEqual(["package_partial"]);
expect(history[0]).toMatchObject({ status: "partial", successfulFiles: 1, failedFiles: 1 });
});
it("creates a new result generation when extraction is retried", async () => {
const { manager, session, events, history } = setup();
const pkg = addPackage(session);
const state = internal(manager);
state.runPackageIds.add(pkg.id);
pkg.archiveOperations = [{
id: "archive-1",
name: "episode.rar",
itemIds: [...pkg.itemIds],
partCount: 1,
startedAt: 10_000,
completedAt: 12_000,
durationMs: 2_000,
status: "failed",
errorCategory: "crc_error"
}];
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
const firstId = events[0]?.id;
const postProcess = vi.spyOn(state, "runPackagePostProcessing").mockResolvedValue(undefined);
session.items[pkg.itemIds[0]].fullStatus = "Entpacken - Error";
manager.retryExtraction(pkg.id);
expect(postProcess).toHaveBeenCalledWith(pkg.id);
pkg.archiveOperations = [{
id: "archive-2",
name: "episode.rar",
itemIds: [...pkg.itemIds],
partCount: 1,
startedAt: 20_000,
completedAt: 23_000,
durationMs: 3_000,
status: "completed",
errorCategory: ""
}];
session.items[pkg.itemIds[0]].fullStatus = "Entpackt - Done (3.0s)";
pkg.status = "completed";
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events).toHaveLength(2);
expect(events[1].id).not.toBe(firstId);
expect(events[1].type).toBe("package_completed");
expect(history).toHaveLength(2);
});
it("moves a pending success digest into the outbox before shutdown", async () => {
const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" });
const pkg = addPackage(session);
const state = internal(manager);
state.runPackageIds.add(pkg.id);
state.tryFinalizePackageResult(pkg.id);
await flushNotifications();
expect(events).toHaveLength(0);
await state.flushNotificationsForShutdown?.();
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["package_completed"]);
expect(events[0].payload.title).toContain("Paket-Digest");
});
});
describe("authoritative run completion", () => {
it("emits run_stopped without run_completed for a manual stop", async () => {
const { manager, session, events } = setup();
const pkg = addPackage(session, ["completed", "queued"]);
const state = internal(manager);
session.running = true;
session.runStartedAt = Date.now() - 10_000;
state.runItemIds = new Set(pkg.itemIds);
state.runPackageIds = new Set([pkg.id]);
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
manager.stop();
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["run_stopped"]);
expect(events.some((event) => event.type === "run_completed")).toBe(false);
});
it("waits for failed extraction package results before emitting the final run summary", async () => {
const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" });
const pkg = addPackage(session);
const state = internal(manager);
session.running = true;
session.runStartedAt = Date.now() - 20_000;
state.runItemIds = new Set(pkg.itemIds);
state.runPackageIds = new Set([pkg.id]);
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
state.packageDeferredPostProcessTasks.set(pkg.id, new Set([Promise.resolve()]));
pkg.archiveOperations = [{
id: "archive-failed",
name: "episode.part01.rar",
itemIds: [...pkg.itemIds],
partCount: 16,
startedAt: 10_000,
completedAt: 18_000,
durationMs: 8_000,
status: "failed",
errorCategory: "wrong_password"
}];
state.finishRun();
await flushNotifications();
expect(events).toHaveLength(0);
state.packageDeferredPostProcessTasks.delete(pkg.id);
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["package_failed", "run_completed"]);
const runEvent = events[1];
expect(runEvent.payload.fields.some((field) => field.name === "Entpackfehler" && field.value === "1")).toBe(true);
expect(runEvent.payload.fields.some((field) => field.name === "Dateien" && field.value === "0 erfolgreich · 1 fehlgeschlagen · 0 abgebrochen")).toBe(true);
});
it("flushes successful package digests before run_completed", async () => {
const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" });
const pkg = addPackage(session);
const state = internal(manager);
session.running = true;
session.runStartedAt = Date.now() - 20_000;
state.runItemIds = new Set(pkg.itemIds);
state.runPackageIds = new Set([pkg.id]);
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
state.finishRun();
state.tryFinalizePackageResult?.(pkg.id);
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["package_completed", "run_completed"]);
expect(events[0].payload.title).toContain("Paket-Digest");
});
it("keeps a finalized success in the digest after package_done removes its session package", async () => {
const { manager, session, events } = setup({
notifyPackageSuccessMode: "digest",
completedCleanupPolicy: "package_done"
});
const pkg = addPackage(session);
const state = internal(manager);
session.running = true;
session.runStartedAt = Date.now() - 20_000;
state.runItemIds = new Set(pkg.itemIds);
state.runPackageIds = new Set([pkg.id]);
state.packageResultGenerations = new Map([[pkg.id, 1]]);
state.runPackageGenerations = new Map([[pkg.id, 1]]);
state.runOutcomes = new Map([[pkg.itemIds[0], "completed"]]);
state.tryFinalizePackageResult(pkg.id);
state.applyPackageDoneCleanup(pkg.id);
expect(session.packages[pkg.id]).toBeUndefined();
state.finishRun();
await flushNotifications();
expect(events.map((event) => event.type)).toEqual(["package_completed", "run_completed"]);
expect(events[0].payload.title).toContain("Paket-Digest");
});
});