diff --git a/src/alerts/dispatcher.ts b/src/alerts/dispatcher.ts index 42021313..ea8e791b 100644 --- a/src/alerts/dispatcher.ts +++ b/src/alerts/dispatcher.ts @@ -1,6 +1,6 @@ import type Database from "better-sqlite3"; import { getUndeliveredAlerts, markAlertDelivered, incrementRetryCount, MAX_RETRY_COUNT } from "../db/repositories.js"; -import { buildAlertEvent, type AlertEvent } from "./types.js"; +import { buildAlertEvent, type AlertEvent, type AlertChannel } from "./types.js"; import { sendWebhookAlert } from "./webhook.js"; import { sendSlackAlert } from "./slack.js"; import { sendPagerDutyAlert } from "./pagerduty.js"; @@ -10,26 +10,26 @@ import { getLogger } from "../logging/index.js"; const logger = getLogger().child({ component: "AlertDispatcher" }); -// ─── Public contract ───────────────────────────────────────────────────────── - export interface DeliveryResult { - /** Total alerts processed (includes failed). */ attempted: number; - /** Alerts successfully sent and marked delivered = 1. */ delivered: number; - /** Alerts that threw during delivery — retry count incremented. */ failed: number; - /** Alerts that exceeded max retries and were abandoned. */ abandoned: number; - /** Error messages for each failed delivery. */ errors: string[]; } -// ─── Core implementation ────────────────────────────────────────────────────── +export const DEFAULT_CHANNELS: Record = { + webhook: { send: sendWebhookAlert }, + slack: { send: sendSlackAlert }, + pagerduty: { send: sendPagerDutyAlert }, + discord: { send: sendDiscordAlert }, + telegram: { send: sendTelegramAlert }, +}; export async function deliverPendingAlerts( db: Database.Database, network: string, + channels: Record = DEFAULT_CHANNELS, ): Promise { const result: DeliveryResult = { attempted: 0, @@ -40,7 +40,6 @@ export async function deliverPendingAlerts( }; const pending = getUndeliveredAlerts(db, network); - if (pending.length === 0) return result; logger.debug(`Dispatcher: ${pending.length} undelivered alert(s) for network ${network}`); @@ -62,40 +61,36 @@ export async function deliverPendingAlerts( }); try { - await route(alert.channelType, alert.channelTarget, event, alert.webhookSecret); + const channel = channels[alert.channelType]; + if (!channel) throw new Error(`Unknown channel type: ${alert.channelType}`); + await channel.send(alert.channelTarget, event, alert.webhookSecret); markAlertDelivered(db, alert.alertFiredId); result.delivered++; - logger.info( - `Alert delivered — id: ${alert.alertFiredId}, ` + - `channel: ${alert.channelType}, contract: ${alert.contractId}`, + `Alert delivered — id: ${alert.alertFiredId}, channel: ${alert.channelType}, contract: ${alert.contractId}`, ); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); result.failed++; result.errors.push(message); - incrementRetryCount(db, alert.alertFiredId); const nextRetry = alert.retryCount + 1; if (nextRetry >= MAX_RETRY_COUNT) { result.abandoned++; logger.error( - `Alert abandoned after ${MAX_RETRY_COUNT} retries — id: ${alert.alertFiredId}, ` + - `channel: ${alert.channelType}, error: ${message}`, + `Alert abandoned after ${MAX_RETRY_COUNT} retries — id: ${alert.alertFiredId}, channel: ${alert.channelType}, error: ${message}`, ); } else { logger.warn( - `Alert delivery failed (attempt ${nextRetry}/${MAX_RETRY_COUNT}) — ` + - `id: ${alert.alertFiredId}, channel: ${alert.channelType}, error: ${message}`, + `Alert delivery failed (attempt ${nextRetry}/${MAX_RETRY_COUNT}) — id: ${alert.alertFiredId}, channel: ${alert.channelType}, error: ${message}`, ); } } } logger.debug( - `Dispatcher finished — attempted: ${result.attempted}, ` + - `delivered: ${result.delivered}, failed: ${result.failed}, abandoned: ${result.abandoned}`, + `Dispatcher finished — attempted: ${result.attempted}, delivered: ${result.delivered}, failed: ${result.failed}, abandoned: ${result.abandoned}`, ); return result; @@ -106,9 +101,12 @@ export async function deliverSingleAlert( channelTarget: string, event: AlertEvent, webhookSecret?: string | null, + channels: Record = DEFAULT_CHANNELS, ): Promise { try { - await route(channelType, channelTarget, event, webhookSecret ?? null); + const channel = channels[channelType]; + if (!channel) throw new Error(`Unknown channel type: ${channelType}`); + await channel.send(channelTarget, event, webhookSecret ?? null); return true; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); @@ -116,32 +114,3 @@ export async function deliverSingleAlert( return false; } } - -// ─── Private ───────────────────────────────────────────────────────────────── - -async function route( - channelType: string, - channelTarget: string, - event: AlertEvent, - webhookSecret: string | null, -): Promise { - switch (channelType) { - case "webhook": - await sendWebhookAlert(channelTarget, event, webhookSecret); - break; - case "slack": - await sendSlackAlert(channelTarget, event); - break; - case "pagerduty": - await sendPagerDutyAlert(channelTarget, event); - break; - case "discord": - await sendDiscordAlert(channelTarget, event); - break; - case "telegram": - await sendTelegramAlert(channelTarget, event); - break; - default: - throw new Error(`Unknown channel type: ${channelType}`); - } -} \ No newline at end of file diff --git a/src/alerts/types.ts b/src/alerts/types.ts index 9782e5a6..30c27d78 100644 --- a/src/alerts/types.ts +++ b/src/alerts/types.ts @@ -1,12 +1,8 @@ import { formatTimeToCloseLedger } from "../utils/formatting.js"; -// ─── Core event type ───────────────────────────────────────────────────────── - export type AlertSeverity = "critical" | "warning" | "info"; export type AlertEventType = "threshold_crossed" | "alert_resolved" | "resource_alert" | "state_changed"; -// ─── TTL-based alert event ────────────────────────────────────────────────── - export interface TTLAlertEvent { type: "threshold_crossed" | "alert_resolved"; severity: AlertSeverity; @@ -32,8 +28,6 @@ export interface TTLAlertEvent { timestamp: string; } -// ─── Resource-based alert event ───────────────────────────────────────────── - export interface ResourceAlertEvent { type: "resource_alert"; severity: AlertSeverity; @@ -57,42 +51,12 @@ export interface ResourceAlertEvent { timestamp: string; } -// ─── State-change alert event ─────────────────────────────────────────────── +export type AlertEvent = TTLAlertEvent | ResourceAlertEvent; -export interface StateChangeAlertEvent { - type: "state_changed"; - severity: AlertSeverity; - contractId: string; - contractName: string | null; - network: string; - entry: { - keyXdr: string; - type: string; - label: string | null; - }; - diff: { - diffType: "created" | "updated" | "deleted"; - oldValueXdr: string | null; - newValueXdr: string | null; - }; - /** Ledger sequence number at the time of detection. */ - detectedAtLedger: number; - /** ISO 8601 timestamp. */ - timestamp: string; +export interface AlertChannel { + send(target: string, event: AlertEvent, secret?: string | null): Promise; } -// ─── Union of all alert event types ────────────────────────────────────────── - -export type AlertEvent = TTLAlertEvent | ResourceAlertEvent | StateChangeAlertEvent; - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -/** - * Compute alert severity from remaining TTL. - * - critical: less than 25% of threshold remaining - * - warning: less than threshold (but above 25%) - * - info: used for resolution events - */ export function computeSeverity(remainingTTL: number, thresholdLedgers: number, isResolution: boolean): AlertSeverity { if (isResolution) return "info"; if (remainingTTL <= 0) return "critical"; @@ -100,21 +64,12 @@ export function computeSeverity(remainingTTL: number, thresholdLedgers: number, return "warning"; } -/** - * Compute alert severity from resource usage percentage. - * - critical: 95% or higher - * - warning: 80-95% - * - info: not used for resource alerts - */ export function computeResourceSeverity(usagePercent: number): AlertSeverity { if (usagePercent >= 95) return "critical"; if (usagePercent >= 80) return "warning"; return "info"; } -/** - * Build a TTL-based AlertEvent from raw data. - */ export function buildAlertEvent(opts: { type: "threshold_crossed" | "alert_resolved"; contractId: string; @@ -148,9 +103,6 @@ export function buildAlertEvent(opts: { }; } -/** - * Build a resource-based AlertEvent from raw data. - */ export function buildResourceAlertEvent(opts: { contractId: string; contractName: string | null; diff --git a/tests/alerts/dispatcher.test.ts b/tests/alerts/dispatcher.test.ts index f453e3d0..474ae05a 100644 --- a/tests/alerts/dispatcher.test.ts +++ b/tests/alerts/dispatcher.test.ts @@ -8,30 +8,14 @@ import { recordAlertFired, MAX_RETRY_COUNT, } from "../../src/db/repositories"; - -// ─── Mocks ─────────────────────────────────────────────────────────────────── - -const mockSendWebhookAlert = vi.fn(); -const mockSendSlackAlert = vi.fn(); -const mockSendPagerDutyAlert = vi.fn(); - -vi.mock("../../src/alerts/webhook.js", () => ({ - sendWebhookAlert: (...args: unknown[]) => mockSendWebhookAlert(...args), -})); - -vi.mock("../../src/alerts/slack.js", () => ({ - sendSlackAlert: (...args: unknown[]) => mockSendSlackAlert(...args), -})); - -vi.mock("../../src/alerts/pagerduty.js", () => ({ - sendPagerDutyAlert: (...args: unknown[]) => mockSendPagerDutyAlert(...args), -})); - import { deliverPendingAlerts } from "../../src/alerts/dispatcher"; +import type { AlertChannel } from "../../src/alerts/types"; -// ─── Helpers ───────────────────────────────────────────────────────────────── +function mockChannel(): AlertChannel & { send: ReturnType } { + return { send: vi.fn().mockResolvedValue(undefined) }; +} -function seedContractWithAlert( +function seedAlert( db: Database.Database, opts: { contractId: string; @@ -44,16 +28,12 @@ function seedContractWithAlert( thresholdLedgers?: number; ttlAtFire?: number; webhookSecret?: string; - } + }, ): { entryId: number; alertConfigId: number; alertFiredId: number } { const network = opts.network ?? "testnet"; const entryKeyXdr = opts.entryKeyXdr ?? `key-${opts.contractId}`; - insertContract(db, { - id: opts.contractId, - name: opts.contractName, - network, - }); + insertContract(db, { id: opts.contractId, name: opts.contractName, network }); upsertEntry(db, { contract_id: opts.contractId, entry_key_xdr: entryKeyXdr, @@ -92,22 +72,22 @@ function seedContractWithAlert( return { entryId: entry.id, alertConfigId: config.id, alertFiredId: fired.id }; } -// ─── Tests ─────────────────────────────────────────────────────────────────── - describe("deliverPendingAlerts", () => { let db: Database.Database; + let channels: Record }>; beforeEach(() => { db = getDatabaseForTesting(); - vi.clearAllMocks(); + channels = { + webhook: mockChannel(), + slack: mockChannel(), + pagerduty: mockChannel(), + }; }); - // ========================================================================= - // 1. RETURN SHAPE - // ========================================================================= describe("Return shape", () => { it("returns a DeliveryResult with all required fields when nothing to deliver", async () => { - const result = await deliverPendingAlerts(db, "testnet"); + const result = await deliverPendingAlerts(db, "testnet", channels); expect(result).toHaveProperty("attempted"); expect(result).toHaveProperty("delivered"); @@ -118,7 +98,7 @@ describe("deliverPendingAlerts", () => { }); it("returns zeros when there are no undelivered alerts", async () => { - const result = await deliverPendingAlerts(db, "testnet"); + const result = await deliverPendingAlerts(db, "testnet", channels); expect(result.attempted).toBe(0); expect(result.delivered).toBe(0); @@ -128,57 +108,38 @@ describe("deliverPendingAlerts", () => { }); }); - // ========================================================================= - // 2. CHANNEL ROUTING - // ========================================================================= describe("Channel routing", () => { - it("routes webhook alerts to sendWebhookAlert", async () => { - mockSendWebhookAlert.mockResolvedValue(undefined); - seedContractWithAlert(db, { - contractId: "CA", - channelType: "webhook", - channelTarget: "https://example.com/hook", - }); + it("routes webhook alerts to the webhook channel", async () => { + seedAlert(db, { contractId: "CA", channelType: "webhook", channelTarget: "https://example.com/hook" }); - await deliverPendingAlerts(db, "testnet"); + await deliverPendingAlerts(db, "testnet", channels); - expect(mockSendWebhookAlert).toHaveBeenCalledTimes(1); - expect(mockSendSlackAlert).not.toHaveBeenCalled(); + expect(channels.webhook.send).toHaveBeenCalledTimes(1); + expect(channels.slack.send).not.toHaveBeenCalled(); }); - it("routes slack alerts to sendSlackAlert", async () => { - mockSendSlackAlert.mockResolvedValue(undefined); - seedContractWithAlert(db, { - contractId: "CA", - channelType: "slack", - channelTarget: "#oncall", - }); + it("routes slack alerts to the slack channel", async () => { + seedAlert(db, { contractId: "CA", channelType: "slack", channelTarget: "#oncall" }); - await deliverPendingAlerts(db, "testnet"); + await deliverPendingAlerts(db, "testnet", channels); - expect(mockSendSlackAlert).toHaveBeenCalledTimes(1); - expect(mockSendWebhookAlert).not.toHaveBeenCalled(); - expect(mockSendPagerDutyAlert).not.toHaveBeenCalled(); + expect(channels.slack.send).toHaveBeenCalledTimes(1); + expect(channels.webhook.send).not.toHaveBeenCalled(); + expect(channels.pagerduty.send).not.toHaveBeenCalled(); }); - it("routes pagerduty alerts to sendPagerDutyAlert", async () => { - mockSendPagerDutyAlert.mockResolvedValue(undefined); - seedContractWithAlert(db, { - contractId: "CA", - channelType: "pagerduty", - channelTarget: "routing-key-123", - }); + it("routes pagerduty alerts to the pagerduty channel", async () => { + seedAlert(db, { contractId: "CA", channelType: "pagerduty", channelTarget: "routing-key-123" }); - await deliverPendingAlerts(db, "testnet"); + await deliverPendingAlerts(db, "testnet", channels); - expect(mockSendPagerDutyAlert).toHaveBeenCalledTimes(1); - expect(mockSendWebhookAlert).not.toHaveBeenCalled(); - expect(mockSendSlackAlert).not.toHaveBeenCalled(); + expect(channels.pagerduty.send).toHaveBeenCalledTimes(1); + expect(channels.webhook.send).not.toHaveBeenCalled(); + expect(channels.slack.send).not.toHaveBeenCalled(); }); - it("calls sendWebhookAlert with the correct URL, event payload, and secret", async () => { - mockSendWebhookAlert.mockResolvedValue(undefined); - seedContractWithAlert(db, { + it("passes correct target, event payload, and secret to webhook channel", async () => { + seedAlert(db, { contractId: "CTEST1234", contractName: "test-contract", channelType: "webhook", @@ -188,9 +149,9 @@ describe("deliverPendingAlerts", () => { webhookSecret: "test-secret-123", }); - await deliverPendingAlerts(db, "testnet"); + await deliverPendingAlerts(db, "testnet", channels); - const [url, event, secret] = mockSendWebhookAlert.mock.calls[0]!; + const [url, event, secret] = channels.webhook.send.mock.calls[0]!; expect(url).toBe("https://ops.example.com/hook"); expect(secret).toBe("test-secret-123"); expect(event.type).toBe("threshold_crossed"); @@ -204,31 +165,22 @@ describe("deliverPendingAlerts", () => { expect(typeof event.timestamp).toBe("string"); }); - it("calls sendSlackAlert with the correct channel and event", async () => { - mockSendSlackAlert.mockResolvedValue(undefined); - seedContractWithAlert(db, { - contractId: "CA", - channelType: "slack", - channelTarget: "#my-alerts", - }); + it("passes correct target and event to slack channel", async () => { + seedAlert(db, { contractId: "CA", channelType: "slack", channelTarget: "#my-alerts" }); - await deliverPendingAlerts(db, "testnet"); + await deliverPendingAlerts(db, "testnet", channels); - const [channel, event] = mockSendSlackAlert.mock.calls[0]!; + const [channel, event] = channels.slack.send.mock.calls[0]!; expect(channel).toBe("#my-alerts"); expect(event.type).toBe("threshold_crossed"); }); }); - // ========================================================================= - // 3. DELIVERED FLAG MANAGEMENT - // ========================================================================= describe("Delivered flag management", () => { it("marks the alert as delivered in the DB after successful send", async () => { - mockSendWebhookAlert.mockResolvedValue(undefined); - const { alertFiredId } = seedContractWithAlert(db, { contractId: "CA" }); + const { alertFiredId } = seedAlert(db, { contractId: "CA" }); - await deliverPendingAlerts(db, "testnet"); + await deliverPendingAlerts(db, "testnet", channels); const row = db .prepare("SELECT delivered FROM alerts_fired WHERE id = ?") @@ -237,10 +189,10 @@ describe("deliverPendingAlerts", () => { }); it("does NOT mark as delivered when send fails", async () => { - mockSendWebhookAlert.mockRejectedValue(new Error("connection refused")); - const { alertFiredId } = seedContractWithAlert(db, { contractId: "CA" }); + channels.webhook.send.mockRejectedValue(new Error("connection refused")); + const { alertFiredId } = seedAlert(db, { contractId: "CA" }); - await deliverPendingAlerts(db, "testnet"); + await deliverPendingAlerts(db, "testnet", channels); const row = db .prepare("SELECT delivered FROM alerts_fired WHERE id = ?") @@ -249,28 +201,24 @@ describe("deliverPendingAlerts", () => { }); it("does not re-deliver already-delivered alerts", async () => { - mockSendWebhookAlert.mockResolvedValue(undefined); - seedContractWithAlert(db, { contractId: "CA" }); + seedAlert(db, { contractId: "CA" }); - // First delivery - await deliverPendingAlerts(db, "testnet"); - expect(mockSendWebhookAlert).toHaveBeenCalledTimes(1); + await deliverPendingAlerts(db, "testnet", channels); + expect(channels.webhook.send).toHaveBeenCalledTimes(1); - // Second delivery cycle — already marked as delivered - await deliverPendingAlerts(db, "testnet"); - expect(mockSendWebhookAlert).toHaveBeenCalledTimes(1); + await deliverPendingAlerts(db, "testnet", channels); + expect(channels.webhook.send).toHaveBeenCalledTimes(1); }); it("retries a failed alert on the next cycle", async () => { - mockSendWebhookAlert + channels.webhook.send .mockRejectedValueOnce(new Error("Slack down")) .mockResolvedValue(undefined); - const { alertFiredId } = seedContractWithAlert(db, { contractId: "CA" }); + const { alertFiredId } = seedAlert(db, { contractId: "CA" }); - // First cycle — fails - await deliverPendingAlerts(db, "testnet"); - expect(mockSendWebhookAlert).toHaveBeenCalledTimes(1); + await deliverPendingAlerts(db, "testnet", channels); + expect(channels.webhook.send).toHaveBeenCalledTimes(1); let row = db .prepare("SELECT delivered, retry_count FROM alerts_fired WHERE id = ?") @@ -278,9 +226,8 @@ describe("deliverPendingAlerts", () => { expect(row.delivered).toBe(0); expect(row.retry_count).toBe(1); - // Second cycle — succeeds - await deliverPendingAlerts(db, "testnet"); - expect(mockSendWebhookAlert).toHaveBeenCalledTimes(2); + await deliverPendingAlerts(db, "testnet", channels); + expect(channels.webhook.send).toHaveBeenCalledTimes(2); row = db .prepare("SELECT delivered, retry_count FROM alerts_fired WHERE id = ?") @@ -289,24 +236,19 @@ describe("deliverPendingAlerts", () => { }); }); - // ========================================================================= - // 4. RETRY LIMITS - // ========================================================================= describe("Retry limits", () => { it("stops retrying after MAX_RETRY_COUNT failures", async () => { - mockSendWebhookAlert.mockRejectedValue(new Error("permanent failure")); - const { alertFiredId } = seedContractWithAlert(db, { contractId: "CA" }); + channels.webhook.send.mockRejectedValue(new Error("permanent failure")); + const { alertFiredId } = seedAlert(db, { contractId: "CA" }); - // Run MAX_RETRY_COUNT cycles — each should attempt delivery for (let i = 0; i < MAX_RETRY_COUNT; i++) { - await deliverPendingAlerts(db, "testnet"); + await deliverPendingAlerts(db, "testnet", channels); } - expect(mockSendWebhookAlert).toHaveBeenCalledTimes(MAX_RETRY_COUNT); + expect(channels.webhook.send).toHaveBeenCalledTimes(MAX_RETRY_COUNT); - // Next cycle should NOT attempt delivery — alert excluded by retry cap - await deliverPendingAlerts(db, "testnet"); - expect(mockSendWebhookAlert).toHaveBeenCalledTimes(MAX_RETRY_COUNT); + await deliverPendingAlerts(db, "testnet", channels); + expect(channels.webhook.send).toHaveBeenCalledTimes(MAX_RETRY_COUNT); const row = db .prepare("SELECT retry_count, delivered FROM alerts_fired WHERE id = ?") @@ -316,53 +258,61 @@ describe("deliverPendingAlerts", () => { }); it("reports abandoned count in result", async () => { - mockSendWebhookAlert.mockRejectedValue(new Error("fail")); - const { alertFiredId } = seedContractWithAlert(db, { contractId: "CA" }); + channels.webhook.send.mockRejectedValue(new Error("fail")); + const { alertFiredId } = seedAlert(db, { contractId: "CA" }); - // Set retry_count to MAX_RETRY_COUNT - 1 so next failure abandons it db.prepare("UPDATE alerts_fired SET retry_count = ? WHERE id = ?") .run(MAX_RETRY_COUNT - 1, alertFiredId); - const result = await deliverPendingAlerts(db, "testnet"); + const result = await deliverPendingAlerts(db, "testnet", channels); expect(result.abandoned).toBe(1); }); }); - // ========================================================================= - // 5. ERROR RESILIENCE - // ========================================================================= - describe("Error resilience", () => { + describe("Error resilience / channel isolation", () => { it("never throws even if all deliveries fail", async () => { - mockSendWebhookAlert.mockRejectedValue(new Error("all down")); - seedContractWithAlert(db, { contractId: "CA", entryKeyXdr: "key-a" }); - seedContractWithAlert(db, { contractId: "CB", entryKeyXdr: "key-b" }); + channels.webhook.send.mockRejectedValue(new Error("all down")); + seedAlert(db, { contractId: "CA", entryKeyXdr: "key-a" }); + seedAlert(db, { contractId: "CB", entryKeyXdr: "key-b" }); - await expect( - deliverPendingAlerts(db, "testnet"), - ).resolves.not.toThrow(); + await expect(deliverPendingAlerts(db, "testnet", channels)).resolves.not.toThrow(); }); it("continues delivering subsequent alerts even if one fails", async () => { - mockSendWebhookAlert + channels.webhook.send .mockRejectedValueOnce(new Error("first failed")) .mockResolvedValue(undefined); - seedContractWithAlert(db, { contractId: "CA", entryKeyXdr: "key-a" }); - seedContractWithAlert(db, { contractId: "CB", entryKeyXdr: "key-b" }); + seedAlert(db, { contractId: "CA", entryKeyXdr: "key-a" }); + seedAlert(db, { contractId: "CB", entryKeyXdr: "key-b" }); + + const result = await deliverPendingAlerts(db, "testnet", channels); + + expect(channels.webhook.send).toHaveBeenCalledTimes(2); + expect(result.delivered).toBe(1); + expect(result.failed).toBe(1); + }); + + it("failing webhook does not block slack delivery", async () => { + channels.webhook.send.mockRejectedValue(new Error("webhook down")); - const result = await deliverPendingAlerts(db, "testnet"); + seedAlert(db, { contractId: "CA", entryKeyXdr: "key-a", channelType: "webhook" }); + seedAlert(db, { contractId: "CB", entryKeyXdr: "key-b", channelType: "slack", channelTarget: "#alerts" }); - expect(mockSendWebhookAlert).toHaveBeenCalledTimes(2); + const result = await deliverPendingAlerts(db, "testnet", channels); + + expect(channels.webhook.send).toHaveBeenCalledTimes(1); + expect(channels.slack.send).toHaveBeenCalledTimes(1); expect(result.delivered).toBe(1); expect(result.failed).toBe(1); }); it("collects error messages for all failed deliveries", async () => { - mockSendWebhookAlert.mockRejectedValue(new Error("connection timeout")); - seedContractWithAlert(db, { contractId: "CA", entryKeyXdr: "key-a" }); - seedContractWithAlert(db, { contractId: "CB", entryKeyXdr: "key-b" }); + channels.webhook.send.mockRejectedValue(new Error("connection timeout")); + seedAlert(db, { contractId: "CA", entryKeyXdr: "key-a" }); + seedAlert(db, { contractId: "CB", entryKeyXdr: "key-b" }); - const result = await deliverPendingAlerts(db, "testnet"); + const result = await deliverPendingAlerts(db, "testnet", channels); expect(result.errors).toHaveLength(2); for (const err of result.errors) { @@ -371,29 +321,36 @@ describe("deliverPendingAlerts", () => { }); it("handles non-Error exceptions from delivery handlers", async () => { - mockSendWebhookAlert.mockRejectedValue("string error"); - seedContractWithAlert(db, { contractId: "CA" }); + channels.webhook.send.mockRejectedValue("string error"); + seedAlert(db, { contractId: "CA" }); - const result = await deliverPendingAlerts(db, "testnet"); + const result = await deliverPendingAlerts(db, "testnet", channels); expect(result.failed).toBe(1); expect(result.errors).toHaveLength(1); }); + + it("reports unknown channel type as a failure without crashing", async () => { + seedAlert(db, { contractId: "CA", channelType: "webhook" }); + const incompleteChannels = { slack: mockChannel() }; + + const result = await deliverPendingAlerts(db, "testnet", incompleteChannels); + + expect(result.failed).toBe(1); + expect(result.errors[0]).toContain("Unknown channel type: webhook"); + }); }); - // ========================================================================= - // 6. COUNTING - // ========================================================================= describe("Result counting", () => { it("counts attempted as total alerts processed regardless of outcome", async () => { - mockSendWebhookAlert + channels.webhook.send .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(new Error("fail")); - seedContractWithAlert(db, { contractId: "CA", entryKeyXdr: "key-a" }); - seedContractWithAlert(db, { contractId: "CB", entryKeyXdr: "key-b" }); + seedAlert(db, { contractId: "CA", entryKeyXdr: "key-a" }); + seedAlert(db, { contractId: "CB", entryKeyXdr: "key-b" }); - const result = await deliverPendingAlerts(db, "testnet"); + const result = await deliverPendingAlerts(db, "testnet", channels); expect(result.attempted).toBe(2); expect(result.delivered).toBe(1); @@ -401,22 +358,10 @@ describe("deliverPendingAlerts", () => { }); it("counts all successful deliveries across channels", async () => { - mockSendWebhookAlert.mockResolvedValue(undefined); - mockSendSlackAlert.mockResolvedValue(undefined); - - seedContractWithAlert(db, { - contractId: "CA", - entryKeyXdr: "key-a", - channelType: "webhook", - }); - seedContractWithAlert(db, { - contractId: "CB", - entryKeyXdr: "key-b", - channelType: "slack", - channelTarget: "#alerts", - }); + seedAlert(db, { contractId: "CA", entryKeyXdr: "key-a", channelType: "webhook" }); + seedAlert(db, { contractId: "CB", entryKeyXdr: "key-b", channelType: "slack", channelTarget: "#alerts" }); - const result = await deliverPendingAlerts(db, "testnet"); + const result = await deliverPendingAlerts(db, "testnet", channels); expect(result.attempted).toBe(2); expect(result.delivered).toBe(2); @@ -424,89 +369,72 @@ describe("deliverPendingAlerts", () => { }); }); - // ========================================================================= - // 7. NETWORK ISOLATION - // ========================================================================= describe("Network isolation", () => { it("only delivers alerts for the specified network", async () => { - mockSendWebhookAlert.mockResolvedValue(undefined); - seedContractWithAlert(db, { contractId: "TESTNET_C", network: "testnet" }); - seedContractWithAlert(db, { contractId: "MAINNET_C", network: "mainnet" }); + seedAlert(db, { contractId: "TESTNET_C", network: "testnet" }); + seedAlert(db, { contractId: "MAINNET_C", network: "mainnet" }); - const result = await deliverPendingAlerts(db, "testnet"); + const result = await deliverPendingAlerts(db, "testnet", channels); - expect(mockSendWebhookAlert).toHaveBeenCalledTimes(1); + expect(channels.webhook.send).toHaveBeenCalledTimes(1); expect(result.attempted).toBe(1); }); it("delivers nothing when no alerts exist for the given network", async () => { - seedContractWithAlert(db, { contractId: "MAINNET_C", network: "mainnet" }); + seedAlert(db, { contractId: "MAINNET_C", network: "mainnet" }); - const result = await deliverPendingAlerts(db, "testnet"); + const result = await deliverPendingAlerts(db, "testnet", channels); - expect(mockSendWebhookAlert).not.toHaveBeenCalled(); + expect(channels.webhook.send).not.toHaveBeenCalled(); expect(result.attempted).toBe(0); }); }); - // ========================================================================= - // 8. PAYLOAD CORRECTNESS - // ========================================================================= describe("Payload correctness", () => { it("event timestamp is a valid ISO 8601 string", async () => { - mockSendWebhookAlert.mockResolvedValue(undefined); - seedContractWithAlert(db, { contractId: "CA" }); + seedAlert(db, { contractId: "CA" }); - await deliverPendingAlerts(db, "testnet"); + await deliverPendingAlerts(db, "testnet", channels); - const [, event] = mockSendWebhookAlert.mock.calls[0]!; + const [, event] = channels.webhook.send.mock.calls[0]!; expect(() => new Date(event.timestamp)).not.toThrow(); expect(new Date(event.timestamp).toISOString()).toBe(event.timestamp); }); it("event entry.keyXdr matches the stored entry_key_xdr", async () => { - mockSendWebhookAlert.mockResolvedValue(undefined); - seedContractWithAlert(db, { - contractId: "CA", - entryKeyXdr: "special-xdr-key", - }); + seedAlert(db, { contractId: "CA", entryKeyXdr: "special-xdr-key" }); - await deliverPendingAlerts(db, "testnet"); + await deliverPendingAlerts(db, "testnet", channels); - const [, event] = mockSendWebhookAlert.mock.calls[0]!; + const [, event] = channels.webhook.send.mock.calls[0]!; expect(event.entry.keyXdr).toBe("special-xdr-key"); }); it("event firedAtLedger matches the stored fired_at_ledger", async () => { - mockSendWebhookAlert.mockResolvedValue(undefined); - seedContractWithAlert(db, { - contractId: "CA", - }); + seedAlert(db, { contractId: "CA" }); - await deliverPendingAlerts(db, "testnet"); + await deliverPendingAlerts(db, "testnet", channels); - const [, event] = mockSendWebhookAlert.mock.calls[0]!; + const [, event] = channels.webhook.send.mock.calls[0]!; expect(event.firedAtLedger).toBe(2_500_000); }); it("approximateTimeRemaining is a non-empty string", async () => { - mockSendWebhookAlert.mockResolvedValue(undefined); - seedContractWithAlert(db, { contractId: "CA", ttlAtFire: 50_000 }); + seedAlert(db, { contractId: "CA", ttlAtFire: 50_000 }); - await deliverPendingAlerts(db, "testnet"); + await deliverPendingAlerts(db, "testnet", channels); - const [, event] = mockSendWebhookAlert.mock.calls[0]!; + const [, event] = channels.webhook.send.mock.calls[0]!; expect(typeof event.threshold.approximateTimeRemaining).toBe("string"); expect(event.threshold.approximateTimeRemaining.length).toBeGreaterThan(0); }); it("event includes severity field", async () => { - mockSendWebhookAlert.mockResolvedValue(undefined); - seedContractWithAlert(db, { contractId: "CA", ttlAtFire: 1_000 }); + seedAlert(db, { contractId: "CA", ttlAtFire: 1_000 }); - await deliverPendingAlerts(db, "testnet"); + await deliverPendingAlerts(db, "testnet", channels); - const [, event] = mockSendWebhookAlert.mock.calls[0]!; + const [, event] = channels.webhook.send.mock.calls[0]!; expect(event.severity).toBe("critical"); }); });