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 { finalizePackageResult } from "../src/main/package-telemetry"; import type { DownloadItem, PackageEntry, PackageResult, PackageTelemetry, RemuxOperationMetric } 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 }; } const privateFailureDetails = "https://private.example.test/hook C:/Private/target alice@example.test token=SUPERSECRET"; const privateFailureValues = [ "https://private.example.test/hook", "C:/Private/target", "alice@example.test", "token=SUPERSECRET" ]; function privateFailureTelemetry(source: "fullStatus" | "remux" | "cleanup"): PackageTelemetry { const item: DownloadItem = { id: "item-private", packageId: "pkg-private", url: "https://download.example.test/file", provider: "realdebrid", status: source === "fullStatus" ? "failed" : "completed", retries: 0, speedBps: 0, downloadedBytes: source === "fullStatus" ? 0 : 1000, totalBytes: 1000, progressPercent: source === "fullStatus" ? 0 : 100, fileName: "file.bin", targetPath: "C:\\Downloads\\Paket\\file.bin", resumable: true, attempts: 1, lastError: "", fullStatus: source === "fullStatus" ? privateFailureDetails : "Fertig", createdAt: 1000, updatedAt: 2000 }; const packageEntry: PackageEntry = { id: "pkg-private", name: "Paket", outputDir: "C:\\Downloads\\Paket", extractDir: "C:\\Downloads\\Paket", status: "failed", itemIds: [item.id], cancelled: false, enabled: true, downloadStartedAt: 1000, downloadCompletedAt: 2000, downloadEndedAt: 2000, postProcessQueuedAt: 2000, postProcessStartedAt: 2000, postProcessCompletedAt: 3000, terminalAt: 3000, createdAt: 1000, updatedAt: 3000 }; const remuxOperations: RemuxOperationMetric[] = source === "remux" ? [{ id: "remux-private", fileName: "file.mkv", startedAt: 2000, completedAt: 3000, durationMs: 1000, status: "failed", errorCategory: privateFailureDetails }] : []; return { package: packageEntry, items: [item], archiveOperations: [], remuxOperations, outputCount: 0, cleanupErrorCategory: source === "cleanup" ? privateFailureDetails : "" }; } 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("retries a transient Windows rename failure before persisting", async () => { const filePath = createOutboxFile(); const actualRename = fsp.rename; const rename = vi.spyOn(fsp, "rename") .mockRejectedValueOnce(Object.assign(new Error("locked"), { code: "EPERM" })) .mockImplementation((oldPath, newPath) => actualRename(oldPath, newPath)); try { const outbox = new NotificationOutbox({ filePath, now: () => 1000, send: async () => true }); await outbox.enqueue(event("rename-retry")); expect(rename).toHaveBeenCalledTimes(2); expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual(["rename-retry"]); } finally { rename.mockRestore(); } }); it("persists a cleaned empty legacy file atomically during load", () => { const filePath = createOutboxFile(); const rename = vi.spyOn(fs, "renameSync"); fs.writeFileSync(filePath, JSON.stringify({ version: 1, events: [ event("expired-private", { expiresAt: 999, payload: { title: "Paket fehlgeschlagen", fields: [{ name: "Fehler", value: `Download · ${privateFailureDetails}`, inline: false }] } }), { id: "", privateSentinel: privateFailureDetails } ], lastSuccessAt: 0, lastFailureAt: 0, privateSentinel: privateFailureDetails }), "utf8"); const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 }); const raw = fs.readFileSync(filePath, "utf8"); expect(outbox.getStatus().queued).toBe(0); expect(JSON.parse(raw).events).toEqual([]); expect(raw).not.toContain("private.example.test"); expect(raw).not.toContain("SUPERSECRET"); expect(rename).toHaveBeenCalledWith(`${filePath}.tmp`, filePath); expect(fs.existsSync(`${filePath}.tmp`)).toBe(false); rename.mockRestore(); }); it("retries a transient synchronous Windows rename failure during load", () => { const filePath = createOutboxFile(); fs.writeFileSync(filePath, JSON.stringify({ version: 1, events: [], lastSuccessAt: 0, lastFailureAt: 0 }), "utf8"); const actualRenameSync = fs.renameSync; const rename = vi.spyOn(fs, "renameSync") .mockImplementationOnce(() => { throw Object.assign(new Error("locked"), { code: "EBUSY" }); }) .mockImplementation((oldPath, newPath) => actualRenameSync(oldPath, newPath)); try { const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 }); expect(outbox.getStatus().queued).toBe(0); expect(rename).toHaveBeenCalledTimes(2); expect(fs.existsSync(`${filePath}.tmp`)).toBe(false); } 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, downloadFailures: 1, offlineFailures: 0, extractionFailures: 0, remuxFailures: 0, cleanupFailures: 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.each([ ["fullStatus fallback", "fullStatus", "Download · Download"], ["remux operation", "remux", "Remux · Remux"], ["cleanup failure", "cleanup", "Aufräumen · Cleanup"] ] as const)("redacts private %s details through telemetry, Discord request, and persisted outbox", async (_label, source, expectedFailure) => { const filePath = createOutboxFile(); const result = finalizePackageResult(privateFailureTelemetry(source)); const notificationEvent = buildPackageNotificationEvent({ generation: 1, result }, 3000); 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: () => 3000 }); await outbox.enqueue(notificationEvent); const eventText = JSON.stringify(notificationEvent); const requestText = String(request.init.body); const persistedText = fs.readFileSync(filePath, "utf8"); expect(notificationEvent.payload.fields.find((field) => field.name === "Fehler")?.value).toBe(expectedFailure); for (const sensitiveValue of privateFailureValues) { 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("redacts a legacy persisted failure before automatic drain and Discord request creation", async () => { vi.useFakeTimers(); vi.setSystemTime(1000); const filePath = createOutboxFile(); const legacyEvent = event("legacy-private-auto-drain", { payload: { title: "Paket fehlgeschlagen", description: "Paket", fields: [{ name: "Fehler", value: `Download · ${privateFailureDetails}`, inline: false }] } }); fs.writeFileSync(filePath, JSON.stringify({ version: 1, events: [legacyEvent], lastSuccessAt: 0, lastFailureAt: 0 }), "utf8"); const sentEvents: NotificationEvent[] = []; const requestBodies: string[] = []; const outbox = new NotificationOutbox({ filePath, autoDrain: true, send: async (queuedEvent) => { sentEvents.push(queuedEvent); requestBodies.push(String(buildNotifyRequest("https://discord.com/api/webhooks/123/abc", { title: queuedEvent.payload.title, message: queuedEvent.payload.description || "", color: queuedEvent.payload.color, fields: queuedEvent.payload.fields, timestamp: queuedEvent.createdAt }).init.body)); return true; } }); await vi.advanceTimersByTimeAsync(0); await outbox.drain(1000); expect(sentEvents).toHaveLength(1); expect(sentEvents[0].payload.fields[0]?.value).toBe("Download · Download"); expect(outbox.getStatus().queued).toBe(0); expect(persisted(filePath).events).toEqual([]); const emittedText = `${JSON.stringify(sentEvents)} ${requestBodies.join(" ")} ${fs.readFileSync(filePath, "utf8")}`; for (const sensitiveValue of privateFailureValues) { expect(emittedText).not.toContain(sensitiveValue); } }); it("persists an enqueue while an earlier delivery is blocked", async () => { const filePath = createOutboxFile(); let releaseSend = (_sent: boolean) => {}; let markSendStarted = () => {}; const sendStarted = new Promise((resolve) => { markSendStarted = resolve; }); const sendResult = new Promise((resolve) => { releaseSend = resolve; }); const outbox = new NotificationOutbox({ filePath, send: async () => { markSendStarted(); return sendResult; }, now: () => 1000 }); await outbox.enqueue(event("blocked")); const draining = outbox.drain(); await sendStarted; const lateEnqueue = outbox.enqueue(event("late-digest", { type: "package_completed", priority: "success" })); const persistedBeforeRelease = await Promise.race([ lateEnqueue.then(() => true), new Promise((resolve) => setTimeout(() => resolve(false), 25)) ]); const stateBeforeRelease = persisted(filePath); releaseSend(true); await draining; await lateEnqueue; expect(persistedBeforeRelease).toBe(true); expect(stateBeforeRelease.events.map((queuedEvent) => queuedEvent.id)).toContain("late-digest"); }); it("acknowledges a successful in-flight delivery after a parallel enqueue expires it", async () => { const filePath = createOutboxFile(); let now = 1000; let releaseSend = (_sent: boolean) => {}; let markSendStarted = () => {}; const sendStarted = new Promise((resolve) => { markSendStarted = resolve; }); const sendResult = new Promise((resolve) => { releaseSend = resolve; }); const delivered: Array<{ id: string; deliveredAt: number }> = []; const outbox = new NotificationOutbox({ filePath, now: () => now, send: async () => { markSendStarted(); return sendResult; }, onDelivered: (queuedEvent, deliveredAt) => { delivered.push({ id: queuedEvent.id, deliveredAt }); } }); await outbox.enqueue(event("expires-in-flight", { expiresAt: 1500 })); const draining = outbox.drain(); await sendStarted; now = 2000; await outbox.enqueue(event("late", { createdAt: 2000, nextAttemptAt: 3000 })); expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toContain("expires-in-flight"); releaseSend(true); await draining; expect(delivered).toEqual([{ id: "expires-in-flight", deliveredAt: 2000 }]); expect(outbox.getStatus()).toEqual({ queued: 1, lastSuccessAt: 2000, lastFailureAt: 0 }); expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual(["late"]); }); it("keeps a failed in-flight delivery queued for retry during a capacity-enforcing enqueue", async () => { const filePath = createOutboxFile(); const queuedEvents = [ event("fails-in-flight", { createdAt: 1 }), ...Array.from({ length: 249 }, (_, index) => event(`queued-${index}`, { createdAt: index + 2 })) ]; fs.writeFileSync(filePath, JSON.stringify({ version: 1, events: queuedEvents, lastSuccessAt: 0, lastFailureAt: 0 }), "utf8"); let releaseSend = (_sent: boolean) => {}; let markSendStarted = () => {}; const sendStarted = new Promise((resolve) => { markSendStarted = resolve; }); const sendResult = new Promise((resolve) => { releaseSend = resolve; }); const outbox = new NotificationOutbox({ filePath, now: () => 1000, send: async () => { markSendStarted(); return sendResult; } }); const draining = outbox.drain(); await sendStarted; await outbox.enqueue(event("late-capacity", { createdAt: 10000 })); expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toContain("fails-in-flight"); releaseSend(false); await draining; const state = persisted(filePath); expect(state.events).toHaveLength(250); expect(state.events[0]).toMatchObject({ id: "fails-in-flight", attempts: 1, nextAttemptAt: 2000 }); expect(state.lastFailureAt).toBe(1000); }); it("acknowledges only successful delivery with its actual completion time", async () => { const filePath = createOutboxFile(); let now = 1000; const delivered: Array<{ id: string; deliveredAt: number }> = []; const failedOutbox = new NotificationOutbox({ filePath, now: () => now, send: async () => { now = 2000; return false; }, onDelivered: (queuedEvent, deliveredAt) => { delivered.push({ id: queuedEvent.id, deliveredAt }); } }); await failedOutbox.enqueue(event("failed")); await failedOutbox.drain(); expect(delivered).toEqual([]); const deliveredFilePath = createOutboxFile(); now = 3000; const deliveredOutbox = new NotificationOutbox({ filePath: deliveredFilePath, now: () => now, send: async () => true, onDelivered: (queuedEvent, deliveredAt) => { delivered.push({ id: queuedEvent.id, deliveredAt }); } }); await deliveredOutbox.enqueue(event("delivered", { nextAttemptAt: 3000 })); await deliveredOutbox.drain(); expect(delivered).toEqual([{ id: "delivered", deliveredAt: 3000 }]); }); it("does not redeliver or block later events when delivery acknowledgement fails", async () => { const filePath = createOutboxFile(); const sent: string[] = []; const outbox = new NotificationOutbox({ filePath, now: () => 1000, send: async (queuedEvent) => { sent.push(queuedEvent.id); return true; }, onDelivered: (queuedEvent) => { if (queuedEvent.id === "first") { throw new Error("health state unavailable"); } } }); await outbox.enqueue(event("first")); await outbox.enqueue(event("second")); await expect(outbox.drain()).resolves.toBeUndefined(); expect(sent).toEqual(["first", "second"]); expect(persisted(filePath).events).toEqual([]); }); 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); }); });