From 8a83bc99e996354c7d50938a4da92e3ba5e61aaa Mon Sep 17 00:00:00 2001 From: joaquimpeixoto Date: Wed, 27 May 2026 16:57:11 +0100 Subject: [PATCH] feat: add ntfy notification support --- client/src/Hooks/useNotificationForm.ts | 8 ++ .../src/Pages/Notifications/create/index.tsx | 45 +++++- client/src/Types/Notification.ts | 2 + client/src/Validation/notifications.ts | 7 + client/src/locales/en.json | 8 ++ server/openapi.json | 109 ++++++++++++++- server/openapi/routes/notification.ts | 4 + server/src/config/services.ts | 3 + server/src/db/models/Notification.ts | 15 +- .../MongoNotificationsRepository.ts | 1 + server/src/service/index.ts | 1 + .../notificationProviders/ntfy.ts | 128 ++++++++++++++++++ .../infrastructure/notificationsService.ts | 7 + server/src/types/notification.ts | 2 + .../src/validation/notificationValidation.ts | 7 + .../notifications/ntfyProvider.test.ts | 112 +++++++++++++++ .../services/notificationsService.test.ts | 60 ++++---- 17 files changed, 492 insertions(+), 27 deletions(-) create mode 100644 server/src/service/infrastructure/notificationProviders/ntfy.ts create mode 100644 server/test/unit/providers/notifications/ntfyProvider.test.ts diff --git a/client/src/Hooks/useNotificationForm.ts b/client/src/Hooks/useNotificationForm.ts index d08f17548..0051ddafd 100644 --- a/client/src/Hooks/useNotificationForm.ts +++ b/client/src/Hooks/useNotificationForm.ts @@ -78,6 +78,14 @@ function buildDefaults(data: Notification | null): NotificationFormData { accessToken: data.accessToken || "", }; } + if (data?.type === "ntfy") { + return { + type: "ntfy", + notificationName: data.notificationName || "", + address: data.address || "", + topic: data.topic || "", + }; + } // Default: email (covers both data === null and data.type === "email") return { type: "email", diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 0f6d9b652..84d264616 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -150,7 +150,8 @@ const NotificationsCreatePage = () => { {watchedType !== "matrix" && watchedType !== "telegram" && watchedType !== "pushover" && - watchedType !== "twilio" && ( + watchedType !== "twilio" && + watchedType !== "ntfy" && ( { } /> )} + {watchedType === "ntfy" && ( + + ( + + )} + /> + ( + + )} + /> + + } + /> + )} {watchedType === "telegram" && ( ; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 42b4bf34f..f1d17aa14 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1155,6 +1155,14 @@ "placeholderFromNumber": "+15551234567", "optionToNumber": "To number (recipient)", "placeholderToNumber": "+15559876543" + }, + "ntfy": { + "title": "ntfy configuration", + "description": "Configure an ntfy server and topic for push notifications.", + "optionServerUrl": "Server URL", + "placeholderServerUrl": "https://ntfy.sh", + "optionTopic": "Topic", + "placeholderTopic": "checkmate-alerts" } }, "table": { diff --git a/server/openapi.json b/server/openapi.json index 26c58fa29..2a5abd6e0 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -392,6 +392,12 @@ "type": "string" } }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, "secret": { "type": "string" }, @@ -469,6 +475,7 @@ "ignoreTlsErrors", "useAdvancedMatching", "notifications", + "tags", "cpuAlertThreshold", "memoryAlertThreshold", "diskAlertThreshold", @@ -545,6 +552,9 @@ }, { "$ref": "#/components/schemas/TwilioNotification" + }, + { + "$ref": "#/components/schemas/NtfyNotification" } ], "discriminator": { @@ -559,7 +569,8 @@ "teams": "#/components/schemas/TeamsNotification", "telegram": "#/components/schemas/TelegramNotification", "pushover": "#/components/schemas/PushoverNotification", - "twilio": "#/components/schemas/TwilioNotification" + "twilio": "#/components/schemas/TwilioNotification", + "ntfy": "#/components/schemas/NtfyNotification" } } }, @@ -1003,6 +1014,34 @@ "phone": "+15551234567", "twilioPhoneNumber": "+15557654321" } + }, + "NtfyNotification": { + "type": "object", + "properties": { + "notificationName": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "enum": ["ntfy"] + }, + "address": { + "type": "string", + "format": "uri" + }, + "topic": { + "type": "string", + "minLength": 1 + } + }, + "required": ["notificationName", "type", "address", "topic"], + "example": { + "notificationName": "ntfy topic", + "type": "ntfy", + "address": "https://ntfy.sh", + "topic": "checkmate-alerts" + } } }, "parameters": {} @@ -2362,6 +2401,16 @@ "profilePicture": { "type": "string" }, + "password": { + "type": "string", + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" + }, + "newPassword": { + "type": "string", + "minLength": 8, + "pattern": "^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/])[A-Za-z0-9!?@#$%^&*()\\-_=+[\\]{};:'\",.~`|\\\\/]+$" + }, "profileImage": { "type": "string", "format": "binary" @@ -5207,7 +5256,8 @@ "type": "array", "items": { "type": "string" - } + }, + "minItems": 1 }, "duration": { "type": "number" @@ -5478,6 +5528,24 @@ "required": false, "name": "filter", "in": "query" + }, + { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "required": false, + "name": "tags", + "in": "query" } ], "responses": { @@ -5672,6 +5740,24 @@ "name": "type", "in": "query" }, + { + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "required": false, + "name": "tags", + "in": "query" + }, { "schema": { "type": "boolean" @@ -6924,6 +7010,12 @@ "type": "string" } }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, "secret": { "type": "string" }, @@ -7586,6 +7678,13 @@ }, "default": [] }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, "secret": { "type": "string" }, @@ -8104,6 +8203,12 @@ "type": "string" } }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, "secret": { "type": "string" }, diff --git a/server/openapi/routes/notification.ts b/server/openapi/routes/notification.ts index b34c542e7..c66e387f6 100644 --- a/server/openapi/routes/notification.ts +++ b/server/openapi/routes/notification.ts @@ -68,6 +68,10 @@ const notificationVariantMeta: Record { diff --git a/server/src/config/services.ts b/server/src/config/services.ts index 4eb6c1081..349f4bc1d 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -33,6 +33,7 @@ import { TelegramProvider, PushoverProvider, TwilioProvider, + NtfyProvider, // Interfaces INetworkService, IEmailService, @@ -304,6 +305,7 @@ export const initializeServices = async ({ const telegramProvider = new TelegramProvider(logger); const pushoverProvider = new PushoverProvider(logger); const twilioProvider = new TwilioProvider(logger); + const ntfyProvider = new NtfyProvider(logger); const notificationsService = new NotificationsService( notificationsRepository, @@ -318,6 +320,7 @@ export const initializeServices = async ({ telegramProvider, pushoverProvider, twilioProvider, + ntfyProvider, settingsService, logger, notificationMessageBuilder diff --git a/server/src/db/models/Notification.ts b/server/src/db/models/Notification.ts index 2f7effa17..93202bb8f 100755 --- a/server/src/db/models/Notification.ts +++ b/server/src/db/models/Notification.ts @@ -25,7 +25,19 @@ const NotificationSchema = new Schema( }, type: { type: String, - enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams", "telegram", "pushover", "twilio"] as NotificationChannel[], + enum: [ + "email", + "slack", + "discord", + "webhook", + "pager_duty", + "matrix", + "teams", + "telegram", + "pushover", + "twilio", + "ntfy", + ] as NotificationChannel[], required: true, }, notificationName: { @@ -39,6 +51,7 @@ const NotificationSchema = new Schema( accessToken: { type: String }, accountSid: { type: String }, twilioPhoneNumber: { type: String }, + topic: { type: String }, }, { timestamps: true, diff --git a/server/src/repositories/notifications/MongoNotificationsRepository.ts b/server/src/repositories/notifications/MongoNotificationsRepository.ts index 5303aa6ab..e556fa206 100644 --- a/server/src/repositories/notifications/MongoNotificationsRepository.ts +++ b/server/src/repositories/notifications/MongoNotificationsRepository.ts @@ -34,6 +34,7 @@ class MongoNotificationsRepository implements INotificationsRepository { accessToken: doc.accessToken ?? undefined, accountSid: doc.accountSid ?? undefined, twilioPhoneNumber: doc.twilioPhoneNumber ?? undefined, + topic: doc.topic ?? undefined, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; diff --git a/server/src/service/index.ts b/server/src/service/index.ts index 9bac381f6..539162e2f 100644 --- a/server/src/service/index.ts +++ b/server/src/service/index.ts @@ -33,6 +33,7 @@ export * from "@/service/infrastructure/notificationProviders/webhook.js"; export * from "@/service/infrastructure/notificationProviders/telegram.js"; export * from "@/service/infrastructure/notificationProviders/pushover.js"; export * from "@/service/infrastructure/notificationProviders/twilio.js"; +export * from "@/service/infrastructure/notificationProviders/ntfy.js"; // System services export * from "@/service/system/settingsService.js"; diff --git a/server/src/service/infrastructure/notificationProviders/ntfy.ts b/server/src/service/infrastructure/notificationProviders/ntfy.ts new file mode 100644 index 000000000..8811cce47 --- /dev/null +++ b/server/src/service/infrastructure/notificationProviders/ntfy.ts @@ -0,0 +1,128 @@ +const SERVICE_NAME = "NtfyProvider"; +import type { Notification } from "@/types/index.js"; +import { NotificationProvider } from "@/service/infrastructure/notificationProviders/INotificationProvider.js"; +import type { NotificationMessage } from "@/types/notificationMessage.js"; +import { getTestMessage } from "@/service/infrastructure/notificationProviders/utils.js"; +import got from "got"; + +export class NtfyProvider extends NotificationProvider { + async sendTestAlert(notification: Partial): Promise { + if (!notification.address || !notification.topic) { + return false; + } + + try { + await got.post(this.buildTopicUrl(notification.address, notification.topic), { + body: getTestMessage(), + headers: { + Title: "Checkmate test notification", + Priority: "default", + }, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "ntfy test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: err?.stack, + }); + return false; + } + } + + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address || !notification.topic) { + return false; + } + + try { + await got.post(this.buildTopicUrl(notification.address, notification.topic), { + body: this.buildNtfyText(message), + headers: { + Title: message.content.title, + Priority: this.mapPriority(message.severity), + Tags: this.mapTags(message.severity), + }, + ...this.gotRequestOptions(), + }); + this.logger.info({ + message: "ntfy notification sent", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const err = error as Error; + this.logger.warn({ + message: "ntfy alert failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: err?.stack, + }); + return false; + } + } + + private buildTopicUrl(address: string, topic: string): string { + return `${address.replace(/\/+$/, "")}/${encodeURIComponent(topic)}`; + } + + private buildNtfyText(message: NotificationMessage): string { + const lines = [ + message.content.summary, + "", + "Monitor Details:", + `- Name: ${message.monitor.name}`, + `- URL: ${message.monitor.url}`, + `- Type: ${message.monitor.type}`, + `- Status: ${message.monitor.status}`, + ]; + + if (message.content.details && message.content.details.length > 0) { + lines.push("", "Additional Information:"); + message.content.details.forEach((detail) => lines.push(`- ${detail}`)); + } + + if (message.content.thresholds && message.content.thresholds.length > 0) { + lines.push("", "Threshold Breaches:"); + message.content.thresholds.forEach((breach) => { + lines.push(`- ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`); + }); + } + + if (message.content.incident) { + lines.push("", `${message.clientHost}/infrastructure/${message.monitor.id}`); + } + + return lines.join("\n"); + } + + private mapPriority(severity: NotificationMessage["severity"]): string { + switch (severity) { + case "critical": + return "high"; + case "warning": + return "default"; + case "success": + return "low"; + default: + return "default"; + } + } + + private mapTags(severity: NotificationMessage["severity"]): string { + switch (severity) { + case "critical": + return "rotating_light"; + case "warning": + return "warning"; + case "success": + return "white_check_mark"; + default: + return "information_source"; + } + } +} diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index 90e9d637a..d63d39f9b 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -36,6 +36,7 @@ export class NotificationsService implements INotificationsService { private telegramProvider: INotificationProvider; private pushoverProvider: INotificationProvider; private twilioProvider: INotificationProvider; + private ntfyProvider: INotificationProvider; private logger: ILogger; private settingsService: ISettingsService; private notificationMessageBuilder: INotificationMessageBuilder; @@ -53,6 +54,7 @@ export class NotificationsService implements INotificationsService { telegramProvider: INotificationProvider, pushoverProvider: INotificationProvider, twilioProvider: INotificationProvider, + ntfyProvider: INotificationProvider, settingsService: ISettingsService, logger: ILogger, notificationMessageBuilder: INotificationMessageBuilder @@ -69,6 +71,7 @@ export class NotificationsService implements INotificationsService { this.telegramProvider = telegramProvider; this.pushoverProvider = pushoverProvider; this.twilioProvider = twilioProvider; + this.ntfyProvider = ntfyProvider; this.settingsService = settingsService; this.logger = logger; this.notificationMessageBuilder = notificationMessageBuilder; @@ -112,6 +115,8 @@ export class NotificationsService implements INotificationsService { return await this.pushoverProvider.sendMessage!(notification, notificationMessage); case "twilio": return await this.twilioProvider.sendMessage!(notification, notificationMessage); + case "ntfy": + return await this.ntfyProvider.sendMessage!(notification, notificationMessage); default: this.logger.warn({ message: `Unknown notification type: ${notification.type}`, @@ -178,6 +183,8 @@ export class NotificationsService implements INotificationsService { return await this.pushoverProvider.sendTestAlert(notification); case "twilio": return await this.twilioProvider.sendTestAlert(notification); + case "ntfy": + return await this.ntfyProvider.sendTestAlert(notification); default: return false; } diff --git a/server/src/types/notification.ts b/server/src/types/notification.ts index 9d5b914c0..0e389f9e2 100644 --- a/server/src/types/notification.ts +++ b/server/src/types/notification.ts @@ -9,6 +9,7 @@ export const NotificationChannels = [ "telegram", "pushover", "twilio", + "ntfy", ] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; @@ -25,6 +26,7 @@ export interface Notification { accessToken?: string; accountSid?: string; twilioPhoneNumber?: string; + topic?: string; createdAt: string; updatedAt: string; } diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 523cb2a6e..bd3cdd6c2 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -88,6 +88,13 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ phone: z.string().min(1, "Recipient phone number is required"), twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), }), + // ntfy notification + z.object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("ntfy"), + address: z.url({ message: "Please enter a valid ntfy server URL" }), + topic: z.string().min(1, "Topic is required"), + }), ]); export const testNotificationBodyValidation = createNotificationBodyValidation; diff --git a/server/test/unit/providers/notifications/ntfyProvider.test.ts b/server/test/unit/providers/notifications/ntfyProvider.test.ts new file mode 100644 index 000000000..d335b5d01 --- /dev/null +++ b/server/test/unit/providers/notifications/ntfyProvider.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts"; +import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; + +const mockGotPost = jest.fn().mockResolvedValue({}); +jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); + +const { NtfyProvider } = await import("../../../../src/service/infrastructure/notificationProviders/ntfy.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + return { provider: new NtfyProvider(logger as any), logger }; +}; + +const makeNtfyNotification = (overrides = {}) => + makeNotification({ + address: "https://ntfy.sh/", + topic: "checkmate-alerts", + ...overrides, + }); + +testNotificationProviderContract("NtfyProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeNtfyNotification(), +}); + +describe("NtfyProvider", () => { + beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); + + describe("sendTestAlert", () => { + it("posts the test message to the configured topic", async () => { + expect(await createProvider().provider.sendTestAlert(makeNtfyNotification())).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://ntfy.sh/checkmate-alerts", + expect.objectContaining({ + body: expect.any(String), + headers: expect.objectContaining({ Title: "Checkmate test notification" }), + }) + ); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNtfyNotification({ address: "" }))).toBe(false); + }); + + it("returns false when topic is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNtfyNotification({ topic: "" }))).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNtfyNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalled(); + }); + }); + + describe("sendMessage", () => { + it("posts plain text with severity headers to the configured topic", async () => { + const { provider } = createProvider(); + expect(await provider.sendMessage(makeNtfyNotification() as any, makeMessage())).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://ntfy.sh/checkmate-alerts", + expect.objectContaining({ + body: expect.stringContaining("Monitor Details"), + headers: expect.objectContaining({ + Title: "Monitor Down: Test Monitor", + Priority: "high", + Tags: "rotating_light", + }), + }) + ); + }); + + it("url-encodes topics", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNtfyNotification({ topic: "ops alerts" }) as any, makeMessage()); + expect(mockGotPost.mock.calls[0][0]).toBe("https://ntfy.sh/ops%20alerts"); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendMessage(makeNtfyNotification({ address: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false when topic is missing", async () => { + expect(await createProvider().provider.sendMessage(makeNtfyNotification({ topic: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeNtfyNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("includes threshold breaches in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNtfyNotification() as any, makeMessageWithThresholds()); + expect(mockGotPost.mock.calls[0][1].body).toContain("CPU"); + }); + + it("includes incident links in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNtfyNotification() as any, makeMessageWithIncident()); + expect(mockGotPost.mock.calls[0][1].body).toContain("/infrastructure/mon-1"); + }); + }); +}); diff --git a/server/test/unit/services/notificationsService.test.ts b/server/test/unit/services/notificationsService.test.ts index cdc1ad320..3afba1e2e 100644 --- a/server/test/unit/services/notificationsService.test.ts +++ b/server/test/unit/services/notificationsService.test.ts @@ -47,6 +47,7 @@ const createService = (overrides?: Record) => { const telegramProvider = createProvider(); const pushoverProvider = createProvider(); const twilioProvider = createProvider(); + const ntfyProvider = createProvider(); const settingsService = createSettingsService(); const notificationMessageBuilder = createMessageBuilder(); @@ -64,6 +65,7 @@ const createService = (overrides?: Record) => { telegramProvider, pushoverProvider, twilioProvider, + ntfyProvider, settingsService, notificationMessageBuilder, ...overrides, @@ -82,6 +84,7 @@ const createService = (overrides?: Record) => { defaults.telegramProvider as any, defaults.pushoverProvider as any, defaults.twilioProvider as any, + defaults.ntfyProvider as any, defaults.settingsService as any, defaults.logger as any, defaults.notificationMessageBuilder as any @@ -147,7 +150,7 @@ describe("NotificationsService", () => { }); it("routes to correct provider for each notification type", async () => { - const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram", "pushover", "twilio"] as const; + const types = ["webhook", "slack", "matrix", "pager_duty", "discord", "email", "teams", "telegram", "pushover", "twilio", "ntfy"] as const; for (const type of types) { const deps = createService(); (deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]); @@ -165,6 +168,7 @@ describe("NotificationsService", () => { telegram: deps.telegramProvider, pushover: deps.pushoverProvider, twilio: deps.twilioProvider, + ntfy: deps.ntfyProvider, }; expect(providerMap[type].sendMessage).toHaveBeenCalledTimes(1); } @@ -238,30 +242,40 @@ describe("NotificationsService", () => { // ── sendTestNotification ───────────────────────────────────────────────── describe("sendTestNotification", () => { - it.each([["email"], ["slack"], ["discord"], ["pager_duty"], ["matrix"], ["webhook"], ["teams"], ["telegram"], ["pushover"], ["twilio"]] as const)( - "routes %s to the correct provider", - async (type) => { - const deps = createService(); - const notification = makeNotification({ type: type as any }); + it.each([ + ["email"], + ["slack"], + ["discord"], + ["pager_duty"], + ["matrix"], + ["webhook"], + ["teams"], + ["telegram"], + ["pushover"], + ["twilio"], + ["ntfy"], + ] as const)("routes %s to the correct provider", async (type) => { + const deps = createService(); + const notification = makeNotification({ type: type as any }); - const result = await deps.service.sendTestNotification(notification); + const result = await deps.service.sendTestNotification(notification); - expect(result).toBe(true); - const providerMap: Record> = { - webhook: deps.webhookProvider, - slack: deps.slackProvider, - matrix: deps.matrixProvider, - pager_duty: deps.pagerDutyProvider, - discord: deps.discordProvider, - email: deps.emailProvider, - teams: deps.teamsProvider, - telegram: deps.telegramProvider, - pushover: deps.pushoverProvider, - twilio: deps.twilioProvider, - }; - expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification); - } - ); + expect(result).toBe(true); + const providerMap: Record> = { + webhook: deps.webhookProvider, + slack: deps.slackProvider, + matrix: deps.matrixProvider, + pager_duty: deps.pagerDutyProvider, + discord: deps.discordProvider, + email: deps.emailProvider, + teams: deps.teamsProvider, + telegram: deps.telegramProvider, + pushover: deps.pushoverProvider, + twilio: deps.twilioProvider, + ntfy: deps.ntfyProvider, + }; + expect(providerMap[type].sendTestAlert).toHaveBeenCalledWith(notification); + }); it("returns false for unknown notification type", async () => { const { service } = createService();