import fs from "node:fs"; import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { NotificationEvent, NotificationOutbox } from "../src/main/notification-outbox"; import { buildPackageNotificationEvent } from "../src/main/notification-events"; import { buildNotifyRequest, sendNotification } from "../src/main/notify"; import type { PackageResult } from "../src/shared/types"; const tempDirs: string[] = []; afterEach(() => { vi.useRealTimers(); for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } }); function createOutboxFile(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-notification-outbox-")); tempDirs.push(dir); return path.join(dir, "notification-outbox.json"); } function event(id: string, overrides: Partial = {}): NotificationEvent { return { id, type: "package_failed", priority: "error", createdAt: 1000, expiresAt: 86401000, attempts: 0, nextAttemptAt: 1000, payload: { title: "Paket fehlgeschlagen", description: "Eine Datei ist fehlgeschlagen.", fields: [] }, ...overrides }; } function persisted(filePath: string): { events: NotificationEvent[]; lastSuccessAt: number; lastFailureAt: number } { return JSON.parse(fs.readFileSync(filePath, "utf8")) as { events: NotificationEvent[]; lastSuccessAt: number; lastFailureAt: number }; } describe("NotificationOutbox", () => { it("sends due events serially in stable enqueue order and removes each success", async () => { const filePath = createOutboxFile(); const sent: string[] = []; const outbox = new NotificationOutbox({ filePath, now: () => 1000, send: async (queuedEvent) => { sent.push(queuedEvent.id); return true; } }); await outbox.enqueue(event("first")); await outbox.enqueue(event("second", { createdAt: 900 })); await outbox.enqueue(event("third")); await outbox.drain(1000); expect(sent).toEqual(["first", "second", "third"]); expect(outbox.getStatus()).toEqual({ queued: 0, lastSuccessAt: 1000, lastFailureAt: 0 }); expect(persisted(filePath).events).toEqual([]); }); it("backs off a failed event without allowing later events to overtake it", async () => { const filePath = createOutboxFile(); let now = 1000; const outcomes = [false, true, true]; const sent: string[] = []; const outbox = new NotificationOutbox({ filePath, now: () => now, send: async (queuedEvent) => { sent.push(queuedEvent.id); return outcomes.shift() ?? true; } }); await outbox.enqueue(event("first")); await outbox.enqueue(event("second")); await outbox.drain(); expect(sent).toEqual(["first"]); expect(persisted(filePath).events[0]).toMatchObject({ id: "first", attempts: 1, nextAttemptAt: 2000 }); expect(outbox.getStatus()).toEqual({ queued: 2, lastSuccessAt: 0, lastFailureAt: 1000 }); now = 1999; await outbox.drain(); expect(sent).toEqual(["first"]); now = 2000; await outbox.drain(); expect(sent).toEqual(["first", "first", "second"]); }); it("uses the actual failure time for retry backoff", async () => { const filePath = createOutboxFile(); let now = 1000; const outbox = new NotificationOutbox({ filePath, now: () => now, send: async () => { now = 4500; return false; } }); await outbox.enqueue(event("late-failure")); await outbox.drain(); expect(persisted(filePath).events[0]).toMatchObject({ attempts: 1, nextAttemptAt: 5500 }); expect(outbox.getStatus().lastFailureAt).toBe(4500); }); it("rechecks expiration after each send before delivering the next event", async () => { const filePath = createOutboxFile(); let now = 1000; const sent: string[] = []; const outbox = new NotificationOutbox({ filePath, now: () => now, send: async (queuedEvent) => { sent.push(queuedEvent.id); now = 2000; return true; } }); await outbox.enqueue(event("first", { expiresAt: 5000 })); await outbox.enqueue(event("expires-during-send", { expiresAt: 1500 })); await outbox.drain(); expect(sent).toEqual(["first"]); expect(outbox.getStatus()).toEqual({ queued: 0, lastSuccessAt: 2000, lastFailureAt: 0 }); }); it("caps exponential retry backoff at ten minutes after many attempts", async () => { const filePath = createOutboxFile(); let now = 1000; const outbox = new NotificationOutbox({ filePath, now: () => now, send: async () => { now = 2000; return false; } }); await outbox.enqueue(event("many-attempts", { attempts: 20 })); await outbox.drain(); expect(persisted(filePath).events[0]).toMatchObject({ attempts: 21, nextAttemptAt: 602000 }); }); it("restores a future retry timer and reads changed URL and mention only when retrying", async () => { vi.useFakeTimers(); vi.setSystemTime(1000); const filePath = createOutboxFile(); const firstUrl = "https://discord.example.test/api/webhooks/first"; const secondUrl = "https://discord.example.test/api/webhooks/second"; let settings = { url: firstUrl, mention: "111111" }; const fetchFn = vi.fn() .mockResolvedValueOnce(new Response("", { status: 404 })) .mockResolvedValueOnce(new Response(null, { status: 204 })); const sender = (queuedEvent: NotificationEvent): Promise => sendNotification(settings.url, { title: queuedEvent.payload.title, message: queuedEvent.payload.description || "", mention: settings.mention, fields: queuedEvent.payload.fields, timestamp: queuedEvent.createdAt }, fetchFn, async () => {}); const firstProcess = new NotificationOutbox({ filePath, send: sender }); await firstProcess.enqueue(event("restart-retry")); await firstProcess.drain(); expect(persisted(filePath).events[0]).toMatchObject({ attempts: 1, nextAttemptAt: 2000 }); settings = { url: secondUrl, mention: "222222" }; const restartedProcess = new NotificationOutbox({ filePath, send: sender, autoDrain: true }); await vi.advanceTimersByTimeAsync(999); expect(fetchFn).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(1); expect(fetchFn).toHaveBeenCalledTimes(2); expect(fetchFn.mock.calls[0][0]).toBe(firstUrl); expect(fetchFn.mock.calls[1][0]).toBe(secondUrl); expect(JSON.parse(String(fetchFn.mock.calls[0][1]?.body)).content).toBe("<@111111>"); expect(JSON.parse(String(fetchFn.mock.calls[1][1]?.body)).content).toBe("<@222222>"); await restartedProcess.drain(); expect(persisted(filePath).events).toEqual([]); }); it("automatically drains new events and retries them at the persisted deadline", async () => { vi.useFakeTimers(); vi.setSystemTime(1000); const filePath = createOutboxFile(); const outcomes = [false, true]; const send = vi.fn().mockImplementation(async () => outcomes.shift() ?? true); const outbox = new NotificationOutbox({ filePath, send, autoDrain: true }); await outbox.enqueue(event("automatic")); await vi.advanceTimersByTimeAsync(0); await outbox.drain(1000); expect(send).toHaveBeenCalledTimes(1); expect(outbox.getStatus().queued).toBe(1); await vi.advanceTimersByTimeAsync(999); expect(send).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(1); expect(send).toHaveBeenCalledTimes(2); expect(outbox.getStatus().queued).toBe(0); }); it("persists through a temporary file and atomic rename", async () => { const filePath = createOutboxFile(); const rename = vi.spyOn(fsp, "rename"); try { const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 }); await outbox.enqueue(event("atomic")); expect(rename).toHaveBeenCalledWith(`${filePath}.tmp`, filePath); expect(fs.existsSync(`${filePath}.tmp`)).toBe(false); expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual(["atomic"]); } finally { rename.mockRestore(); } }); it("drops expired events before persisting or sending", async () => { const filePath = createOutboxFile(); const send = vi.fn().mockResolvedValue(true); const outbox = new NotificationOutbox({ filePath, send, now: () => 2000 }); await outbox.enqueue(event("expired", { expiresAt: 1999 })); await outbox.drain(2000); expect(send).not.toHaveBeenCalled(); expect(outbox.getStatus().queued).toBe(0); expect(persisted(filePath).events).toEqual([]); }); it("caps the queue at 250 and evicts the oldest success before errors", async () => { const filePath = createOutboxFile(); const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 }); for (let index = 0; index < 249; index += 1) { await outbox.enqueue(event(`error-${index}`, { createdAt: 1000 + index })); } await outbox.enqueue(event("success-old", { type: "package_completed", priority: "success", createdAt: 500 })); await outbox.enqueue(event("success-new", { type: "package_completed", priority: "success", createdAt: 2000 })); const ids = persisted(filePath).events.map((queuedEvent) => queuedEvent.id); expect(ids).toHaveLength(250); expect(ids).not.toContain("success-old"); expect(ids).toContain("success-new"); expect(ids.filter((id) => id.startsWith("error-"))).toHaveLength(249); }); it("never persists webhook or mention fields supplied outside the event contract", async () => { const filePath = createOutboxFile(); const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 }); const unsafe = { ...event("safe"), url: "https://discord.example.test/private-webhook", mention: "@private", payload: { ...event("safe").payload, url: "https://discord.example.test/nested-private-webhook", mention: "@nested-private" } } as unknown as NotificationEvent; await outbox.enqueue(unsafe); const raw = fs.readFileSync(filePath, "utf8"); expect(raw).not.toContain("private-webhook"); expect(raw).not.toContain("@private"); expect(persisted(filePath).events[0]).toEqual(event("safe")); }); it("keeps private package failure details out of events, requests, and persisted state", async () => { const filePath = createOutboxFile(); const privateDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET"; const result: PackageResult = { packageId: "pkg-private", name: "Paket", status: "failed", startedAt: 1000, downloadEndedAt: 2000, postProcessStartedAt: 0, completedAt: 2000, downloadDurationSeconds: 1, extractionDurationSeconds: 0, remuxDurationSeconds: 0, postProcessDurationSeconds: 0, totalDurationSeconds: 1, totalBytes: 1000, downloadedBytes: 0, averageDownloadSpeedBps: 0, successfulFiles: 0, failedFiles: 1, cancelledFiles: 0, archiveCount: 0, partCount: 0, outputCount: 0, failurePhase: "download", errorCategory: privateDetails, archiveOperations: [], remuxOperations: [] }; const notificationEvent = buildPackageNotificationEvent({ generation: 1, result }, 1000); const request = buildNotifyRequest("https://discord.com/api/webhooks/123/abc", { title: notificationEvent.payload.title, message: notificationEvent.payload.description || "", color: notificationEvent.payload.color, fields: notificationEvent.payload.fields, timestamp: notificationEvent.createdAt }); const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 }); await outbox.enqueue(notificationEvent); const eventText = JSON.stringify(notificationEvent); const requestText = String(request.init.body); const persistedText = fs.readFileSync(filePath, "utf8"); const sensitiveValues = [ "https://private.example.test/hook", "C:/Private/target", "alice@example.test", "token=SUPERSECRET" ]; expect(notificationEvent.payload.fields.find((field) => field.name === "Fehler")?.value).toBe("Download · Download"); for (const sensitiveValue of sensitiveValues) { expect(eventText).not.toContain(sensitiveValue); expect(requestText).not.toContain(sensitiveValue); expect(persistedText).not.toContain(sensitiveValue); } }); it("projects private failure details from an existing outbox before persisting again", async () => { const filePath = createOutboxFile(); const privateDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET"; const legacyEvent = event("legacy-private", { payload: { title: "Paket fehlgeschlagen", description: "Paket", fields: [{ name: "Fehler", value: `Download · ${privateDetails}`, inline: false }] } }); fs.writeFileSync(filePath, JSON.stringify({ version: 1, events: [legacyEvent], lastSuccessAt: 0, lastFailureAt: 0 }), "utf8"); const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 }); await outbox.enqueue(event("safe")); const persistedText = fs.readFileSync(filePath, "utf8"); expect(persisted(filePath).events[0].payload.fields[0]?.value).toBe("Download · Download"); expect(persistedText).not.toContain("https://private.example.test/hook"); expect(persistedText).not.toContain("C:/Private/target"); expect(persistedText).not.toContain("alice@example.test"); expect(persistedText).not.toContain("token=SUPERSECRET"); }); it("returns after the default three-second shutdown budget when sending hangs", async () => { vi.useFakeTimers(); const filePath = createOutboxFile(); const outbox = new NotificationOutbox({ filePath, send: async () => new Promise(() => {}), now: () => 1000 }); await outbox.enqueue(event("hanging")); let completed = false; const draining = outbox.drainForShutdown().then(() => { completed = true; }); await vi.advanceTimersByTimeAsync(2999); expect(completed).toBe(false); await vi.advanceTimersByTimeAsync(1); await draining; expect(completed).toBe(true); expect(outbox.getStatus().queued).toBe(1); }); });