Files
Checkmate/server/test/unit/services/notificationsService.test.ts
T

425 lines
17 KiB
TypeScript

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<boolean>>().mockResolvedValue(true),
sendTestAlert: jest.fn<() => Promise<boolean>>().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" } }),
extractThresholdBreaches: jest.fn(),
});
const createService = (overrides?: Record<string, unknown>) => {
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>): 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>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
name: "Test Monitor",
type: "http",
notifications: ["notif-1"],
...overrides,
}) as Monitor;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
shouldCreateIncident: false,
shouldResolveIncident: false,
shouldSendNotification: true,
incidentReason: null,
notificationReason: "status_change",
...overrides,
});
const makeCheck = (overrides?: Partial<Check>): 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 shouldSendNotification is false", async () => {
const { service } = createService();
const result = await service.handleNotifications(makeMonitor(), makeCheck(), makeDecision({ shouldSendNotification: false }));
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<string, ReturnType<typeof createProvider>> = {
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<string, ReturnType<typeof createProvider>> = {
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();
});
});
});