import { describe, expect, it, jest, beforeEach } from "@jest/globals"; import { NotificationsService } from "../../../src/domain/notifications/notification.service.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts"; import type { Notification } from "../../../src/domain/notifications/notification.type.ts"; import type { Check } from "../../../src/domain/checks/check.type.ts"; import type { MonitorActionDecision } from "../../../src/worker/worker.interface.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── const createProvider = () => ({ sendMessage: jest.fn<() => Promise>().mockResolvedValue(true), sendTestAlert: jest.fn<() => Promise>().mockResolvedValue(true), }); const createNotificationsRepo = () => ({ create: jest.fn(), findById: jest.fn(), findNotificationsByIds: jest.fn(), findByTeamId: jest.fn(), updateById: jest.fn(), deleteById: jest.fn(), }); const createMonitorsRepo = () => ({ removeNotificationFromMonitors: jest.fn(), }); const createSettingsService = (clientHost = "https://app.example.com") => ({ getSettings: jest.fn().mockReturnValue({ clientHost }), removeEgressNotification: jest.fn().mockResolvedValue(undefined), }); const createMessageBuilder = () => ({ buildMessage: jest.fn().mockReturnValue({ type: "monitor_down", content: { title: "Down" } }), buildThresholdBreachMessage: jest.fn(), }); const createService = (overrides?: Record) => { const logger = createMockLogger(); const notificationsRepository = createNotificationsRepo(); const monitorsRepository = createMonitorsRepo(); const webhookProvider = createProvider(); const emailProvider = createProvider(); const slackProvider = createProvider(); const discordProvider = createProvider(); const pagerDutyProvider = createProvider(); const matrixProvider = createProvider(); const teamsProvider = createProvider(); const telegramProvider = createProvider(); const pushoverProvider = createProvider(); const twilioProvider = createProvider(); const ntfyProvider = createProvider(); const settingsService = createSettingsService(); const notificationMessageBuilder = createMessageBuilder(); const defaults = { logger, notificationsRepository, monitorsRepository, webhookProvider, emailProvider, slackProvider, discordProvider, pagerDutyProvider, matrixProvider, teamsProvider, telegramProvider, pushoverProvider, twilioProvider, ntfyProvider, settingsService, notificationMessageBuilder, ...overrides, }; const service = new NotificationsService({ notificationsRepository: defaults.notificationsRepository, monitorsRepository: defaults.monitorsRepository, providers: { webhook: defaults.webhookProvider, email: defaults.emailProvider, slack: defaults.slackProvider, discord: defaults.discordProvider, pager_duty: defaults.pagerDutyProvider, matrix: defaults.matrixProvider, teams: defaults.teamsProvider, telegram: defaults.telegramProvider, pushover: defaults.pushoverProvider, twilio: defaults.twilioProvider, ntfy: defaults.ntfyProvider, }, settingsService: defaults.settingsService, logger: defaults.logger, notificationMessageBuilder: defaults.notificationMessageBuilder, } as any); return { service, ...defaults }; }; const makeNotification = (overrides?: Partial): Notification => ({ id: "notif-1", userId: "user-1", teamId: "team-1", type: "email", notificationName: "Email Alert", address: "test@example.com", createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", ...overrides, }) as Notification; const makeMonitor = (overrides?: Partial): Monitor => ({ id: "mon-1", teamId: "team-1", name: "Test Monitor", type: "http", notifications: ["notif-1"], ...overrides, }) as Monitor; const makeDecision = (overrides?: Partial): MonitorActionDecision => ({ transition: "status_down", ...overrides, }); const makeCheck = (overrides?: Partial): Check => ({ id: "check-1", metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" }, status: false, responseTime: 100, statusCode: 500, message: "Internal Server Error", createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", ...overrides, }); // ── Tests ──────────────────────────────────────────────────────────────────── describe("NotificationsService", () => { // ── handleNotifications ─────────────────────────────────────────────────── describe("handleNotifications", () => { it("returns false when there is no transition", async () => { const { service } = createService(); const result = await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision({ transition: null })); expect(result).toBe(false); }); it("sends notifications to all configured providers and returns true", async () => { const { service, notificationsRepository, emailProvider } = createService(); (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "email" })]); const result = await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision()); expect(result).toBe(true); expect(emailProvider.sendMessage).toHaveBeenCalledTimes(1); }); it("routes to correct provider for each notification type", async () => { 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 })]); await deps.service.handleNotifications(makeMonitor(), makeCheck(), makeDecision()); 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].sendMessage).toHaveBeenCalledTimes(1); } }); it("returns false and logs warning for unknown notification type", async () => { const { service, notificationsRepository, logger } = createService(); (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "carrier_pigeon" as any })]); const result = await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision()); expect(result).toBe(false); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining("Unknown notification type: carrier_pigeon") }) ); }); it("returns false and logs warning when notificationMessage is undefined", async () => { const notificationMessageBuilder = createMessageBuilder(); notificationMessageBuilder.buildMessage.mockReturnValue(undefined); const { service, notificationsRepository, logger } = createService({ notificationMessageBuilder }); (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]); const result = await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision()); expect(result).toBe(false); expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Notification message not provided" })); }); it("handles monitors with no notification IDs", async () => { const { service, notificationsRepository } = createService(); (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([]); const result = await service.handleNotifications(makeMonitor({ notifications: undefined as any }), makeCheck(), makeDecision()); expect(result).toBe(true); expect(notificationsRepository.findNotificationsByIds).toHaveBeenCalledWith([]); }); it("returns false and logs when some notifications fail", async () => { const { service, notificationsRepository, emailProvider, slackProvider, logger } = createService(); (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([ makeNotification({ id: "n1", type: "email" }), makeNotification({ id: "n2", type: "slack" }), ]); emailProvider.sendMessage.mockResolvedValue(true); slackProvider.sendMessage.mockResolvedValue(false); const result = await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision()); expect(result).toBe(false); expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 success, 1 failure") })); }); it("uses fallback clientHost when settings.clientHost is empty", async () => { const settingsService = createSettingsService(""); const { service, notificationsRepository, notificationMessageBuilder } = createService({ settingsService }); (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification()]); await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision()); expect(notificationMessageBuilder.buildMessage).toHaveBeenCalledWith( expect.anything(), expect.anything(), expect.anything(), "Host not defined" ); }); }); // ── sendTestNotification ───────────────────────────────────────────────── describe("sendTestNotification", () => { 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); 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(); const result = await service.sendTestNotification(makeNotification({ type: "unknown" as any })); expect(result).toBe(false); }); }); // ── testAllNotifications ───────────────────────────────────────────────── describe("testAllNotifications", () => { it("returns true when all test alerts succeed", async () => { const { service, notificationsRepository } = createService(); (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([ makeNotification({ type: "email" }), makeNotification({ type: "slack" }), ]); const result = await service.testAllNotifications(["notif-1", "notif-2"]); expect(result).toBe(true); }); it("returns false when any test alert fails", async () => { const { service, notificationsRepository, emailProvider } = createService(); emailProvider.sendTestAlert.mockResolvedValue(false); (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type: "email" })]); const result = await service.testAllNotifications(["notif-1"]); expect(result).toBe(false); }); it("counts a provider that throws as a failed test and still tests the others", async () => { const { service, notificationsRepository, emailProvider, slackProvider, logger } = createService(); emailProvider.sendTestAlert.mockRejectedValue(new Error("Email is not configured. Set the system email host in settings.")); (notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([ makeNotification({ type: "email" }), makeNotification({ type: "slack" }), ]); const result = await service.testAllNotifications(["notif-1", "notif-2"]); expect(result).toBe(false); expect(slackProvider.sendTestAlert).toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ message: "Test notification failed: Email is not configured. Set the system email host in settings." }) ); }); }); // ── CRUD operations ────────────────────────────────────────────────────── describe("createNotification", () => { it("sets userId and teamId and delegates to repository", async () => { const created = makeNotification(); const { service, notificationsRepository } = createService(); (notificationsRepository.create as jest.Mock).mockResolvedValue(created); const result = await service.createNotification({ type: "email", address: "a@b.com" }, "user-1", "team-1"); expect(result).toBe(created); expect(notificationsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ userId: "user-1", teamId: "team-1", type: "email" })); }); }); describe("findById", () => { it("delegates to repository", async () => { const notification = makeNotification(); const { service, notificationsRepository } = createService(); (notificationsRepository.findById as jest.Mock).mockResolvedValue(notification); const result = await service.findById("notif-1", "team-1"); expect(result).toBe(notification); }); }); describe("findNotificationsByTeamId", () => { it("delegates to repository", async () => { const notifications = [makeNotification()]; const { service, notificationsRepository } = createService(); (notificationsRepository.findByTeamId as jest.Mock).mockResolvedValue(notifications); const result = await service.findNotificationsByTeamId("team-1"); expect(result).toBe(notifications); }); }); describe("updateById", () => { it("delegates to repository", async () => { const updated = makeNotification({ address: "new@example.com" }); const { service, notificationsRepository } = createService(); (notificationsRepository.updateById as jest.Mock).mockResolvedValue(updated); const result = await service.updateById("notif-1", "team-1", { address: "new@example.com" }); expect(result).toBe(updated); }); }); describe("deleteById", () => { it("deletes notification and removes from monitors", async () => { const deleted = makeNotification(); const { service, notificationsRepository, monitorsRepository } = createService(); (notificationsRepository.deleteById as jest.Mock).mockResolvedValue(deleted); const result = await service.deleteById("notif-1", "team-1"); expect(result).toBe(deleted); expect(monitorsRepository.removeNotificationFromMonitors).toHaveBeenCalledWith("notif-1"); }); it("removes the notification from the egress recovered recipients after the team-scoped delete succeeds", async () => { const { service, notificationsRepository, settingsService } = createService(); (notificationsRepository.deleteById as jest.Mock).mockResolvedValue(makeNotification()); await service.deleteById("notif-1", "team-1"); expect(settingsService.removeEgressNotification).toHaveBeenCalledWith("notif-1"); const deleteOrder = (notificationsRepository.deleteById as jest.Mock).mock.invocationCallOrder[0]!; const cascadeOrder = (settingsService.removeEgressNotification as jest.Mock).mock.invocationCallOrder[0]!; expect(deleteOrder).toBeLessThan(cascadeOrder); }); it("does not touch app settings when the delete is refused", async () => { const { service, notificationsRepository, settingsService } = createService(); (notificationsRepository.deleteById as jest.Mock).mockRejectedValue(new Error("Notification not found")); await expect(service.deleteById("notif-1", "other-team")).rejects.toThrow("Notification not found"); expect(settingsService.removeEgressNotification).not.toHaveBeenCalled(); }); }); });