fix(notifications): harden final delivery lifecycle
Enforce one shared shutdown deadline while persisting late digests during blocked sends. Start stall cooldowns from serialized delivery acknowledgements, atomically rewrite cleaned legacy outboxes, retain run summaries for 24 hours, and prevent duplicate history or synthetic post-process start times.
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
type DownloadHealthSnapshot,
|
||||
type DownloadHealthState
|
||||
} from "../src/main/download-health-monitor";
|
||||
import { NotificationOutbox } from "../src/main/notification-outbox";
|
||||
|
||||
const RUN_FINGERPRINT = "a".repeat(64);
|
||||
const QUEUE_FINGERPRINT = "b".repeat(64);
|
||||
@@ -250,8 +251,13 @@ describe("evaluateDownloadHealth", () => {
|
||||
expect(result.state.alertedAt).toBe(0);
|
||||
});
|
||||
|
||||
it("applies a ten-minute cooldown before another incident event", () => {
|
||||
const firstAlert = alertedState();
|
||||
it("applies a ten-minute cooldown after a delivered incident event", () => {
|
||||
const firstAlert = createDownloadHealthState({
|
||||
...alertedState(),
|
||||
lastAlertAt: 90_000,
|
||||
cooldownUntil: 690_000,
|
||||
lastDeliveredStallEventId: `health:stall:${RUN_FINGERPRINT.slice(0, 16)}:0`
|
||||
});
|
||||
const recovered = evaluate(firstAlert, snapshot({ itemCompletionSequence: 1 }), 105_000).state;
|
||||
const duringCooldown = sampleTimes(recovered, [120_000, 165_000, 210_000]);
|
||||
const afterCooldown = evaluate(duringCooldown.state, snapshot(), 690_000);
|
||||
@@ -263,6 +269,83 @@ describe("evaluateDownloadHealth", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("starts the cooldown at actual Discord delivery after a buffered outage", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-delivery-ack-"));
|
||||
tempDirs.push(root);
|
||||
const healthFile = path.join(root, "health.json");
|
||||
const outboxFile = path.join(root, "outbox.json");
|
||||
const suspicious = sampleTimes(createDownloadHealthState(), [0, 45_000]).state;
|
||||
const monitor = new DownloadHealthMonitor(healthFile, suspicious);
|
||||
let now = 90_000;
|
||||
let deliveryAvailable = false;
|
||||
const deliveredIds: string[] = [];
|
||||
const outbox = new NotificationOutbox({
|
||||
filePath: outboxFile,
|
||||
now: () => now,
|
||||
send: async (queuedEvent) => {
|
||||
if (deliveryAvailable) deliveredIds.push(queuedEvent.id);
|
||||
return deliveryAvailable;
|
||||
},
|
||||
onDelivered: (queuedEvent, deliveredAt) => {
|
||||
return monitor.acknowledgeDelivery(queuedEvent, deliveredAt, 600_000);
|
||||
}
|
||||
});
|
||||
|
||||
const confirmed = await monitor.sample(snapshot(), now, {
|
||||
stallAfterMs: 90_000,
|
||||
cooldownMs: 600_000,
|
||||
notifyOnStall: true,
|
||||
notifyOnRecovery: true
|
||||
}, (event) => outbox.enqueue(event));
|
||||
await outbox.drain();
|
||||
now = 300_000;
|
||||
const repeated = await monitor.sample(snapshot(), now, {
|
||||
stallAfterMs: 90_000,
|
||||
cooldownMs: 600_000,
|
||||
notifyOnStall: true,
|
||||
notifyOnRecovery: true
|
||||
}, (event) => outbox.enqueue(event));
|
||||
|
||||
expect(confirmed.events).toHaveLength(1);
|
||||
expect(repeated.events).toEqual([]);
|
||||
expect(outbox.getStatus().queued).toBe(1);
|
||||
expect(monitor.getState().cooldownUntil).toBe(0);
|
||||
|
||||
deliveryAvailable = true;
|
||||
now = 420_000;
|
||||
await outbox.drain(now);
|
||||
|
||||
expect(deliveredIds).toEqual([confirmed.events[0].id]);
|
||||
expect(monitor.getState().lastAlertAt).toBe(420_000);
|
||||
expect(monitor.getState().cooldownUntil).toBe(1_020_000);
|
||||
});
|
||||
|
||||
it("serializes a delivery acknowledgement behind a concurrent recovery sample", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-health-ack-race-"));
|
||||
tempDirs.push(root);
|
||||
const filePath = path.join(root, "health.json");
|
||||
const confirmed = sampleTimes(createDownloadHealthState(), [0, 45_000, 90_000]);
|
||||
const monitor = new DownloadHealthMonitor(filePath, confirmed.state);
|
||||
let releaseEnqueue = () => {};
|
||||
const enqueueBlocked = new Promise<void>((resolve) => { releaseEnqueue = resolve; });
|
||||
const recovery = monitor.sample(snapshot({ itemCompletionSequence: 1 }), 105_000, {
|
||||
stallAfterMs: 90_000,
|
||||
cooldownMs: 600_000,
|
||||
notifyOnStall: true,
|
||||
notifyOnRecovery: true
|
||||
}, async () => enqueueBlocked);
|
||||
await Promise.resolve();
|
||||
|
||||
const acknowledgement = monitor.acknowledgeDelivery(confirmed.events[0], 300_000, 600_000);
|
||||
releaseEnqueue();
|
||||
await Promise.all([recovery, acknowledgement]);
|
||||
|
||||
expect(monitor.getState().status).toBe("healthy");
|
||||
expect(monitor.getState().lastAlertAt).toBe(300_000);
|
||||
expect(monitor.getState().cooldownUntil).toBe(900_000);
|
||||
expect(monitor.getState().lastDeliveredStallEventId).toBe(confirmed.events[0].id);
|
||||
});
|
||||
|
||||
it("keeps the incident event id stable when outbox persistence rejects the state transition", () => {
|
||||
const suspicious = sampleTimes(createDownloadHealthState(), [0, 45_000]).state;
|
||||
const firstAttempt = evaluate(suspicious, snapshot(), 90_000);
|
||||
@@ -367,7 +450,8 @@ describe("download health restart persistence", () => {
|
||||
...alertedState(),
|
||||
privateUrl: "https://private.example.test/file",
|
||||
privatePath: "C:\\private\\download.bin",
|
||||
privateAccount: "private@example.test"
|
||||
privateAccount: "private@example.test",
|
||||
lastDeliveredStallEventId: "https://private.example.test/stall"
|
||||
} as DownloadHealthState;
|
||||
|
||||
saveDownloadHealthState(filePath, state);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const electron = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (...args: unknown[]) => void>();
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
saveDownloadHealthState,
|
||||
type DownloadHealthSnapshot
|
||||
} from "../src/main/download-health-monitor";
|
||||
import { NotificationOutbox, type NotificationEvent } from "../src/main/notification-outbox";
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve = () => {};
|
||||
@@ -50,6 +51,23 @@ function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function shutdownEvent(id: string, priority: "success" | "error" = "success"): NotificationEvent {
|
||||
return {
|
||||
id,
|
||||
type: "package_completed",
|
||||
priority,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + 6 * 60 * 60 * 1000,
|
||||
attempts: 0,
|
||||
nextAttemptAt: Date.now(),
|
||||
payload: { title: "Paket-Digest", fields: [] }
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("main shutdown lifecycle", () => {
|
||||
it("AppController waits for the bounded outbox drain before disposing runtime owners", async () => {
|
||||
const drain = deferred();
|
||||
@@ -70,11 +88,120 @@ describe("main shutdown lifecycle", () => {
|
||||
const shutdown = controller.shutdown();
|
||||
|
||||
expect(shutdown).toBeInstanceOf(Promise);
|
||||
expect(controller.notificationOutbox.drainForShutdown).toHaveBeenCalledWith(3000);
|
||||
expect(manager.prepareForShutdown).not.toHaveBeenCalled();
|
||||
const drainBudget = controller.notificationOutbox.drainForShutdown.mock.calls[0][0];
|
||||
expect(drainBudget).toBeGreaterThan(0);
|
||||
expect(drainBudget).toBeLessThanOrEqual(3000);
|
||||
expect(manager.prepareForShutdown).toHaveBeenCalledTimes(1);
|
||||
drain.resolve();
|
||||
await shutdown;
|
||||
expect(manager.prepareForShutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses one three-second deadline even when a running health evaluation never settles", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const runningEvaluation = deferred();
|
||||
const controller = Object.create(AppController.prototype) as any;
|
||||
controller.downloadHealthTimer = setInterval(() => {}, 60_000);
|
||||
controller.downloadHealthEvaluation = runningEvaluation.promise;
|
||||
controller.downloadHealthMonitor = null;
|
||||
controller.runtimeStatsTimer = null;
|
||||
controller.notificationOutbox = { drainForShutdown: vi.fn(async () => undefined) };
|
||||
controller.manager = {
|
||||
suspendDownloadHealthMonitoring: vi.fn(),
|
||||
prepareForShutdown: vi.fn(),
|
||||
flushNotificationsForShutdown: vi.fn(async () => undefined)
|
||||
};
|
||||
controller.megaWebFallback = { dispose: vi.fn() };
|
||||
controller.realDebridWebFallbacks = new Map();
|
||||
controller.pendingRealDebridWebAccountIds = new Map();
|
||||
controller.allDebridWebFallback = { dispose: vi.fn() };
|
||||
controller.bestDebridWebFallback = { dispose: vi.fn() };
|
||||
controller.shutdownLogStorage = vi.fn();
|
||||
controller.audit = vi.fn();
|
||||
controller.settings = { historyRetentionMode: "never" };
|
||||
let completed = false;
|
||||
|
||||
const shutdown = controller.shutdown().then(() => { completed = true; });
|
||||
await vi.advanceTimersByTimeAsync(2999);
|
||||
expect(completed).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
const completedAtDeadline = completed;
|
||||
runningEvaluation.resolve();
|
||||
await vi.runAllTimersAsync();
|
||||
await shutdown;
|
||||
|
||||
expect(completedAtDeadline).toBe(true);
|
||||
expect(controller.manager.prepareForShutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("persists a digest completed inside the shared shutdown window while an earlier send is blocked", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-shutdown-outbox-"));
|
||||
const filePath = path.join(root, "outbox.json");
|
||||
let releaseSend = (_sent: boolean) => {};
|
||||
let markSendStarted = () => {};
|
||||
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
|
||||
const blockedSend = new Promise<boolean>((resolve) => { releaseSend = resolve; });
|
||||
const lateEnqueued = deferred();
|
||||
const outbox = new NotificationOutbox({
|
||||
filePath,
|
||||
now: Date.now,
|
||||
send: async (event) => {
|
||||
if (event.id === "already-sending") {
|
||||
markSendStarted();
|
||||
return blockedSend;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
await outbox.enqueue(shutdownEvent("already-sending", "error"));
|
||||
const activeDrain = outbox.drain();
|
||||
await sendStarted;
|
||||
const controller = Object.create(AppController.prototype) as any;
|
||||
controller.downloadHealthTimer = null;
|
||||
controller.downloadHealthEvaluation = null;
|
||||
controller.downloadHealthMonitor = null;
|
||||
controller.runtimeStatsTimer = null;
|
||||
controller.notificationOutbox = outbox;
|
||||
controller.manager = {
|
||||
suspendDownloadHealthMonitoring: vi.fn(),
|
||||
prepareForShutdown: vi.fn(),
|
||||
flushNotificationsForShutdown: vi.fn(() => new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
void outbox.enqueue(shutdownEvent("late-digest")).then(() => {
|
||||
lateEnqueued.resolve();
|
||||
resolve();
|
||||
});
|
||||
}, 500);
|
||||
}))
|
||||
};
|
||||
controller.megaWebFallback = { dispose: vi.fn() };
|
||||
controller.realDebridWebFallbacks = new Map();
|
||||
controller.pendingRealDebridWebAccountIds = new Map();
|
||||
controller.allDebridWebFallback = { dispose: vi.fn() };
|
||||
controller.bestDebridWebFallback = { dispose: vi.fn() };
|
||||
controller.shutdownLogStorage = vi.fn();
|
||||
controller.audit = vi.fn();
|
||||
controller.settings = { historyRetentionMode: "never" };
|
||||
let completed = false;
|
||||
|
||||
const shutdown = controller.shutdown().then(() => { completed = true; });
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await lateEnqueued.promise;
|
||||
const stateDuringWindow = JSON.parse(fs.readFileSync(filePath, "utf8")) as { events: NotificationEvent[] };
|
||||
await vi.advanceTimersByTimeAsync(2500);
|
||||
const completedAtDeadline = completed;
|
||||
releaseSend(true);
|
||||
await vi.runAllTimersAsync();
|
||||
await activeDrain;
|
||||
await shutdown;
|
||||
|
||||
expect(stateDuringWindow.events.map((event) => event.id)).toContain("late-digest");
|
||||
expect(completedAtDeadline).toBe(true);
|
||||
expect(controller.manager.prepareForShutdown.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(controller.manager.flushNotificationsForShutdown.mock.invocationCallOrder[0]);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("prevents quit once, waits for shutdown, then allows exactly one loop-free quit", async () => {
|
||||
|
||||
@@ -296,6 +296,38 @@ describe("NotificationOutbox", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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("drops expired events before persisting or sending", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
const send = vi.fn().mockResolvedValue(true);
|
||||
@@ -512,6 +544,102 @@ describe("NotificationOutbox", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("persists an enqueue while an earlier delivery is blocked", async () => {
|
||||
const filePath = createOutboxFile();
|
||||
let releaseSend = (_sent: boolean) => {};
|
||||
let markSendStarted = () => {};
|
||||
const sendStarted = new Promise<void>((resolve) => { markSendStarted = resolve; });
|
||||
const sendResult = new Promise<boolean>((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<boolean>((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 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();
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { buildRunNotificationEvent, buildRunResult } from "../src/main/notification-events";
|
||||
import type { NotificationEvent } from "../src/main/notification-outbox";
|
||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
||||
import { shutdownItemLogs } from "../src/main/item-log";
|
||||
@@ -154,6 +155,35 @@ describe("authoritative package completion", () => {
|
||||
expect(pkg.terminalAt).toBe(pkg.postProcessCompletedAt);
|
||||
});
|
||||
|
||||
it("keeps postprocess start unset when a queued package never receives a slot", async () => {
|
||||
const { manager, session, history } = setup();
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
pkg.postProcessQueuedAt = Date.now() - 5000;
|
||||
state.runPackageIds.add(pkg.id);
|
||||
|
||||
state.tryFinalizePackageResult(pkg.id);
|
||||
await flushNotifications();
|
||||
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].postProcessStartedAt).toBe(0);
|
||||
expect(history[0].postProcessDurationSeconds).toBe(0);
|
||||
});
|
||||
|
||||
it("records exactly one business history entry when a finalized package is manually deleted", async () => {
|
||||
const { manager, session, history } = setup();
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
state.runPackageIds.add(pkg.id);
|
||||
|
||||
state.tryFinalizePackageResult(pkg.id);
|
||||
await flushNotifications();
|
||||
manager.cancelPackage(pkg.id);
|
||||
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0]).toMatchObject({ id: `hist-${pkg.id}-1`, status: "completed" });
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -304,9 +334,41 @@ describe("authoritative package completion", () => {
|
||||
expect(events.map((event) => event.type)).toEqual(["package_completed"]);
|
||||
expect(events[0].payload.title).toContain("Paket-Digest");
|
||||
});
|
||||
|
||||
it("persists a success digest that finalizes after shutdown flushing has started", async () => {
|
||||
const { manager, session, events } = setup({ notifyPackageSuccessMode: "digest" });
|
||||
const pkg = addPackage(session);
|
||||
const state = internal(manager);
|
||||
state.runPackageIds.add(pkg.id);
|
||||
|
||||
await state.flushNotificationsForShutdown();
|
||||
state.tryFinalizePackageResult(pkg.id);
|
||||
await flushNotifications();
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["package_completed"]);
|
||||
expect(events[0].payload.title).toContain("Paket-Digest");
|
||||
});
|
||||
});
|
||||
|
||||
describe("authoritative run completion", () => {
|
||||
it.each([
|
||||
["successful", 0, "success"],
|
||||
["failed", 1, "error"]
|
||||
] as const)("keeps a %s run_completed event for 24 hours", (_label, failedFiles, priority) => {
|
||||
const notification = buildRunNotificationEvent(buildRunResult({
|
||||
id: `run-${priority}`,
|
||||
stopped: false,
|
||||
startedAt: 1000,
|
||||
completedAt: 2000,
|
||||
packages: [],
|
||||
failedFiles
|
||||
}));
|
||||
|
||||
expect(notification.type).toBe("run_completed");
|
||||
expect(notification.priority).toBe(priority);
|
||||
expect(notification.expiresAt - notification.createdAt).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("emits run_stopped without run_completed for a manual stop", async () => {
|
||||
const { manager, session, events } = setup();
|
||||
const pkg = addPackage(session, ["completed", "queued"]);
|
||||
|
||||
Reference in New Issue
Block a user