From a0f73e4e8da5ce61963de0a372f6625c33733b0e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 9 Apr 2026 11:05:21 -0700 Subject: [PATCH] add notificaiton provider tests --- server/package.json | 2 +- .../notificationProviders/matrix.ts | 2 +- server/test/helpers/notificationMessage.ts | 51 ++++++ .../helpers/notificationProviderContract.ts | 40 +++++ .../{ => network}/advancedMatcher.test.ts | 4 +- .../{ => network}/dockerProvider.test.ts | 10 +- .../{ => network}/gameProvider.test.ts | 10 +- .../{ => network}/grpcProvider.test.ts | 6 +- .../{ => network}/hardwareProvider.test.ts | 8 +- .../{ => network}/httpProvider.test.ts | 8 +- .../{ => network}/pageSpeedProvider.test.ts | 12 +- .../{ => network}/pingProvider.test.ts | 6 +- .../{ => network}/portProvider.test.ts | 8 +- .../providers/{ => network}/utils.test.ts | 2 +- .../{ => network}/webSocketProvider.test.ts | 8 +- .../notifications/discordProvider.test.ts | 132 ++++++++++++++++ .../notifications/emailProvider.test.ts | 149 ++++++++++++++++++ .../notifications/matrixProvider.test.ts | 144 +++++++++++++++++ .../notifications/notificationUtils.test.ts | 34 ++++ .../notifications/pagerdutyProvider.test.ts | 132 ++++++++++++++++ .../notifications/slackProvider.test.ts | 127 +++++++++++++++ .../{ => notifications}/teamsProvider.test.ts | 51 +++++- .../notifications/telegramProvider.test.ts | 115 ++++++++++++++ .../notifications/webhookProvider.test.ts | 102 ++++++++++++ 24 files changed, 1116 insertions(+), 47 deletions(-) create mode 100644 server/test/helpers/notificationMessage.ts create mode 100644 server/test/helpers/notificationProviderContract.ts rename server/test/unit/providers/{ => network}/advancedMatcher.test.ts (98%) rename server/test/unit/providers/{ => network}/dockerProvider.test.ts (96%) rename server/test/unit/providers/{ => network}/gameProvider.test.ts (93%) rename server/test/unit/providers/{ => network}/grpcProvider.test.ts (98%) rename server/test/unit/providers/{ => network}/hardwareProvider.test.ts (89%) rename server/test/unit/providers/{ => network}/httpProvider.test.ts (97%) rename server/test/unit/providers/{ => network}/pageSpeedProvider.test.ts (92%) rename server/test/unit/providers/{ => network}/pingProvider.test.ts (94%) rename server/test/unit/providers/{ => network}/portProvider.test.ts (95%) rename server/test/unit/providers/{ => network}/utils.test.ts (96%) rename server/test/unit/providers/{ => network}/webSocketProvider.test.ts (94%) create mode 100644 server/test/unit/providers/notifications/discordProvider.test.ts create mode 100644 server/test/unit/providers/notifications/emailProvider.test.ts create mode 100644 server/test/unit/providers/notifications/matrixProvider.test.ts create mode 100644 server/test/unit/providers/notifications/notificationUtils.test.ts create mode 100644 server/test/unit/providers/notifications/pagerdutyProvider.test.ts create mode 100644 server/test/unit/providers/notifications/slackProvider.test.ts rename server/test/unit/providers/{ => notifications}/teamsProvider.test.ts (79%) create mode 100644 server/test/unit/providers/notifications/telegramProvider.test.ts create mode 100644 server/test/unit/providers/notifications/webhookProvider.test.ts diff --git a/server/package.json b/server/package.json index 57ee33308..c4de0e1d4 100755 --- a/server/package.json +++ b/server/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage", - "test:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers", + "test:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers/network test/unit/providers/notifications", "dev": "nodemon --exec tsx src/index.js", "start": "node --watch ./dist/index.js", "build": "tsc && tsc-alias && cp -r src/templates dist/templates", diff --git a/server/src/service/infrastructure/notificationProviders/matrix.ts b/server/src/service/infrastructure/notificationProviders/matrix.ts index 413ecd47e..9d92e1f3c 100644 --- a/server/src/service/infrastructure/notificationProviders/matrix.ts +++ b/server/src/service/infrastructure/notificationProviders/matrix.ts @@ -63,7 +63,7 @@ export class MatrixProvider implements INotificationProvider { message: `Matrix notification failed : ${err.message}`, service: SERVICE_NAME, method: "sendMessage", - stack: err?.stack, + stack: err.stack, }); return false; } diff --git a/server/test/helpers/notificationMessage.ts b/server/test/helpers/notificationMessage.ts new file mode 100644 index 000000000..c25644c81 --- /dev/null +++ b/server/test/helpers/notificationMessage.ts @@ -0,0 +1,51 @@ +import type { Notification } from "../../src/types/index.ts"; +import type { NotificationMessage } from "../../src/types/notificationMessage.ts"; + +export const makeNotification = (overrides?: Partial): Notification => + ({ + address: "https://hooks.example.com/webhook", + accessToken: "token-abc", + homeserverUrl: "https://matrix.example.com", + roomId: "!room:example.com", + ...overrides, + }) as Notification; + +export const makeMessage = (overrides?: Partial): NotificationMessage => ({ + type: "monitor_down", + severity: "critical", + monitor: { id: "mon-1", name: "Test Monitor", url: "https://example.com", type: "http", status: "down" }, + content: { + title: "Monitor Down: Test Monitor", + summary: 'Monitor "Test Monitor" is currently down.', + details: ["URL: https://example.com", "Status: Down"], + timestamp: new Date("2025-01-01T00:00:00Z"), + }, + clientHost: "https://app.example.com", + metadata: { teamId: "team-1", notificationReason: "status_change" }, + ...overrides, +}); + +export const makeMessageWithThresholds = (): NotificationMessage => + makeMessage({ + type: "threshold_breach", + severity: "warning", + monitor: { id: "mon-1", name: "Infra Server", url: "https://infra.example.com", type: "hardware", status: "up" }, + content: { + title: "Threshold Exceeded: Infra Server", + summary: "Thresholds exceeded.", + details: ["URL: https://infra.example.com"], + thresholds: [ + { metric: "cpu", currentValue: 90, threshold: 80, unit: "%", formattedValue: "90.0%" }, + { metric: "memory", currentValue: 85, threshold: 70, unit: "%", formattedValue: "85.0%" }, + ], + timestamp: new Date("2025-01-01T00:00:00Z"), + }, + }); + +export const makeMessageWithIncident = (): NotificationMessage => + makeMessage({ + content: { + ...makeMessage().content, + incident: { id: "inc-1", url: "https://app.example.com/incidents/inc-1", createdAt: new Date("2025-01-01T00:00:00Z") }, + }, + }); diff --git a/server/test/helpers/notificationProviderContract.ts b/server/test/helpers/notificationProviderContract.ts new file mode 100644 index 000000000..f95048f24 --- /dev/null +++ b/server/test/helpers/notificationProviderContract.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "@jest/globals"; +import type { INotificationProvider } from "../../src/service/infrastructure/notificationProviders/INotificationProvider.ts"; +import type { Notification } from "../../src/types/index.ts"; +import type { NotificationMessage } from "../../src/types/notificationMessage.ts"; + +const makeMessage = (): NotificationMessage => ({ + type: "monitor_down", + severity: "critical", + monitor: { id: "mon-1", name: "Test", url: "https://example.com", type: "http", status: "down" }, + content: { + title: "Monitor Down: Test", + summary: "Test is down", + details: ["URL: https://example.com"], + timestamp: new Date("2025-01-01T00:00:00Z"), + }, + clientHost: "https://app.example.com", + metadata: { teamId: "team-1", notificationReason: "status_change" }, +}); + +export const testNotificationProviderContract = ( + name: string, + opts: { + create: () => INotificationProvider; + makeNotification: () => Partial; + } +) => { + describe(`INotificationProvider contract: ${name}`, () => { + it("sendTestAlert returns a boolean", async () => { + const provider = opts.create(); + const result = await provider.sendTestAlert(opts.makeNotification()); + expect(typeof result).toBe("boolean"); + }); + + it("sendMessage returns a boolean", async () => { + const provider = opts.create(); + const result = await provider.sendMessage(opts.makeNotification() as Notification, makeMessage()); + expect(typeof result).toBe("boolean"); + }); + }); +}; diff --git a/server/test/unit/providers/advancedMatcher.test.ts b/server/test/unit/providers/network/advancedMatcher.test.ts similarity index 98% rename from server/test/unit/providers/advancedMatcher.test.ts rename to server/test/unit/providers/network/advancedMatcher.test.ts index e0087b201..e0b2c773c 100644 --- a/server/test/unit/providers/advancedMatcher.test.ts +++ b/server/test/unit/providers/network/advancedMatcher.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { AdvancedMatcher } from "../../../src/service/infrastructure/network/AdvancedMatcher.ts"; -import type { Monitor } from "../../../src/types/index.ts"; +import { AdvancedMatcher } from "../../../../src/service/infrastructure/network/AdvancedMatcher.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts similarity index 96% rename from server/test/unit/providers/dockerProvider.test.ts rename to server/test/unit/providers/network/dockerProvider.test.ts index 4306d3ff7..220e0a8fa 100644 --- a/server/test/unit/providers/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { DockerProvider } from "../../../src/service/infrastructure/network/DockerProvider.ts"; -import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; -import { createMockLogger } from "../../helpers/createMockLogger.ts"; -import { NETWORK_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; -import type { Monitor } from "../../../src/types/index.ts"; +import { DockerProvider } from "../../../../src/service/infrastructure/network/DockerProvider.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/gameProvider.test.ts b/server/test/unit/providers/network/gameProvider.test.ts similarity index 93% rename from server/test/unit/providers/gameProvider.test.ts rename to server/test/unit/providers/network/gameProvider.test.ts index c2f671c3c..d34e99621 100644 --- a/server/test/unit/providers/gameProvider.test.ts +++ b/server/test/unit/providers/network/gameProvider.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { GameProvider } from "../../../src/service/infrastructure/network/GameProvider.ts"; -import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; -import { createMockLogger } from "../../helpers/createMockLogger.ts"; -import { NETWORK_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; -import type { Monitor } from "../../../src/types/index.ts"; +import { GameProvider } from "../../../../src/service/infrastructure/network/GameProvider.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/grpcProvider.test.ts b/server/test/unit/providers/network/grpcProvider.test.ts similarity index 98% rename from server/test/unit/providers/grpcProvider.test.ts rename to server/test/unit/providers/network/grpcProvider.test.ts index 2b5de6634..efa2e7992 100644 --- a/server/test/unit/providers/grpcProvider.test.ts +++ b/server/test/unit/providers/network/grpcProvider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { GrpcProvider } from "../../../src/service/infrastructure/network/GrpcProvider.ts"; -import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; -import type { Monitor } from "../../../src/types/index.ts"; +import { GrpcProvider } from "../../../../src/service/infrastructure/network/GrpcProvider.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/hardwareProvider.test.ts b/server/test/unit/providers/network/hardwareProvider.test.ts similarity index 89% rename from server/test/unit/providers/hardwareProvider.test.ts rename to server/test/unit/providers/network/hardwareProvider.test.ts index 22b2f5ae9..2f51454a0 100644 --- a/server/test/unit/providers/hardwareProvider.test.ts +++ b/server/test/unit/providers/network/hardwareProvider.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { HardwareProvider } from "../../../src/service/infrastructure/network/HardwareProvider.ts"; -import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; -import type { HttpProvider } from "../../../src/service/infrastructure/network/HttpProvider.ts"; -import type { Monitor } from "../../../src/types/index.ts"; +import { HardwareProvider } from "../../../../src/service/infrastructure/network/HardwareProvider.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import type { HttpProvider } from "../../../../src/service/infrastructure/network/HttpProvider.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/httpProvider.test.ts b/server/test/unit/providers/network/httpProvider.test.ts similarity index 97% rename from server/test/unit/providers/httpProvider.test.ts rename to server/test/unit/providers/network/httpProvider.test.ts index 7c9a2f10d..1b814d7b2 100644 --- a/server/test/unit/providers/httpProvider.test.ts +++ b/server/test/unit/providers/network/httpProvider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; -import { NETWORK_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; -import type { Monitor } from "../../../src/types/index.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; // ── Mocks ──────────────────────────────────────────────────────────────────── @@ -38,7 +38,7 @@ jest.unstable_mockModule("got", () => ({ }, })); -const { HttpProvider } = await import("../../../src/service/infrastructure/network/HttpProvider.ts"); +const { HttpProvider } = await import("../../../../src/service/infrastructure/network/HttpProvider.ts"); const gotModule = await import("got"); const { HTTPError, RequestError } = gotModule; diff --git a/server/test/unit/providers/pageSpeedProvider.test.ts b/server/test/unit/providers/network/pageSpeedProvider.test.ts similarity index 92% rename from server/test/unit/providers/pageSpeedProvider.test.ts rename to server/test/unit/providers/network/pageSpeedProvider.test.ts index 08c19bbd7..c461b0ad2 100644 --- a/server/test/unit/providers/pageSpeedProvider.test.ts +++ b/server/test/unit/providers/network/pageSpeedProvider.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { PageSpeedProvider } from "../../../src/service/infrastructure/network/PageSpeedProvider.ts"; -import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; -import { createMockLogger } from "../../helpers/createMockLogger.ts"; -import type { HttpProvider } from "../../../src/service/infrastructure/network/HttpProvider.ts"; -import type { ISettingsService } from "../../../src/service/system/settingsService.ts"; -import type { Monitor } from "../../../src/types/index.ts"; +import { PageSpeedProvider } from "../../../../src/service/infrastructure/network/PageSpeedProvider.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import type { HttpProvider } from "../../../../src/service/infrastructure/network/HttpProvider.ts"; +import type { ISettingsService } from "../../../../src/service/system/settingsService.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/pingProvider.test.ts b/server/test/unit/providers/network/pingProvider.test.ts similarity index 94% rename from server/test/unit/providers/pingProvider.test.ts rename to server/test/unit/providers/network/pingProvider.test.ts index 9c8dbd538..2f38eef00 100644 --- a/server/test/unit/providers/pingProvider.test.ts +++ b/server/test/unit/providers/network/pingProvider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { PingProvider } from "../../../src/service/infrastructure/network/PingProvider.ts"; -import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; -import type { Monitor } from "../../../src/types/index.ts"; +import { PingProvider } from "../../../../src/service/infrastructure/network/PingProvider.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/portProvider.test.ts b/server/test/unit/providers/network/portProvider.test.ts similarity index 95% rename from server/test/unit/providers/portProvider.test.ts rename to server/test/unit/providers/network/portProvider.test.ts index a972f7c9c..cbf64af16 100644 --- a/server/test/unit/providers/portProvider.test.ts +++ b/server/test/unit/providers/network/portProvider.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { PortProvider } from "../../../src/service/infrastructure/network/PortProvider.ts"; -import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; -import { NETWORK_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; -import type { Monitor } from "../../../src/types/index.ts"; +import { PortProvider } from "../../../../src/service/infrastructure/network/PortProvider.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/utils.test.ts b/server/test/unit/providers/network/utils.test.ts similarity index 96% rename from server/test/unit/providers/utils.test.ts rename to server/test/unit/providers/network/utils.test.ts index 64772f432..56d8e7f9c 100644 --- a/server/test/unit/providers/utils.test.ts +++ b/server/test/unit/providers/network/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@jest/globals"; -import { timeRequest, NETWORK_ERROR, PING_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; +import { timeRequest, NETWORK_ERROR, PING_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; describe("network utils", () => { describe("timeRequest", () => { diff --git a/server/test/unit/providers/webSocketProvider.test.ts b/server/test/unit/providers/network/webSocketProvider.test.ts similarity index 94% rename from server/test/unit/providers/webSocketProvider.test.ts rename to server/test/unit/providers/network/webSocketProvider.test.ts index d712bb198..73e4416a1 100644 --- a/server/test/unit/providers/webSocketProvider.test.ts +++ b/server/test/unit/providers/network/webSocketProvider.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; -import { WebSocketProvider } from "../../../src/service/infrastructure/network/WebSocketProvider.ts"; -import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; -import { NETWORK_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; -import type { Monitor } from "../../../src/types/index.ts"; +import { WebSocketProvider } from "../../../../src/service/infrastructure/network/WebSocketProvider.ts"; +import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; +import { NETWORK_ERROR } from "../../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../../src/types/index.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/providers/notifications/discordProvider.test.ts b/server/test/unit/providers/notifications/discordProvider.test.ts new file mode 100644 index 000000000..28c30ed22 --- /dev/null +++ b/server/test/unit/providers/notifications/discordProvider.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, jest } 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 { DiscordProvider } = await import("../../../../src/service/infrastructure/notificationProviders/discord.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + const provider = new DiscordProvider(logger as any); + return { provider, logger }; +}; + +testNotificationProviderContract("DiscordProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeNotification(), +}); + +describe("DiscordProvider", () => { + beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); + + describe("sendTestAlert", () => { + it("sends test message and returns true", async () => { + const { provider } = createProvider(); + const result = await provider.sendTestAlert(makeNotification()); + expect(result).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://hooks.example.com/webhook", + expect.objectContaining({ json: expect.objectContaining({ content: expect.any(String) }) }) + ); + }); + + it("returns false when address is missing", async () => { + const { provider } = createProvider(); + expect(await provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("network")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert" })); + }); + + it("handles undefined thrown values", async () => { + mockGotPost.mockRejectedValue(undefined); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); + }); + }); + + describe("sendMessage", () => { + it("sends embed and returns true", async () => { + const { provider } = createProvider(); + const result = await provider.sendMessage(makeNotification() as any, makeMessage()); + expect(result).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://hooks.example.com/webhook", + expect.objectContaining({ json: expect.objectContaining({ embeds: expect.any(Array) }) }) + ); + }); + + it("returns false when address is missing", async () => { + const { provider } = createProvider(); + expect(await provider.sendMessage(makeNotification({ address: "" }) 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(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" })); + }); + + it("handles undefined thrown values in sendMessage", async () => { + mockGotPost.mockRejectedValue(undefined); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); + }); + + it("includes threshold breaches in embed fields", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds()); + const payload = mockGotPost.mock.calls[0][1].json; + const fields = payload.embeds[0].fields; + expect(fields.some((f: any) => f.name === "Threshold Breaches")).toBe(true); + }); + + it("includes details in embed fields", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage()); + const fields = mockGotPost.mock.calls[0][1].json.embeds[0].fields; + expect(fields.some((f: any) => f.name === "Details")).toBe(true); + }); + + it("maps all severity levels to colors", async () => { + const { provider } = createProvider(); + for (const severity of ["critical", "warning", "success", "info"] as const) { + mockGotPost.mockClear(); + await provider.sendMessage(makeNotification() as any, makeMessage({ severity })); + const color = mockGotPost.mock.calls[0][1].json.embeds[0].color; + expect(color).toBeGreaterThan(0); + } + }); + + it("uses info color for unknown severity", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any })); + const color = mockGotPost.mock.calls[0][1].json.embeds[0].color; + expect(color).toBe(0x3b82f6); + }); + + it("omits threshold fields when not present", async () => { + const { provider } = createProvider(); + const msg = makeMessage(); + msg.content.thresholds = undefined; + msg.content.details = undefined; + await provider.sendMessage(makeNotification() as any, msg); + const fields = mockGotPost.mock.calls[0][1].json.embeds[0].fields; + expect(fields.some((f: any) => f.name === "Threshold Breaches")).toBe(false); + expect(fields.some((f: any) => f.name === "Details")).toBe(false); + }); + }); +}); diff --git a/server/test/unit/providers/notifications/emailProvider.test.ts b/server/test/unit/providers/notifications/emailProvider.test.ts new file mode 100644 index 000000000..e0dc8b676 --- /dev/null +++ b/server/test/unit/providers/notifications/emailProvider.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { EmailProvider } from "../../../../src/service/infrastructure/notificationProviders/email.ts"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { makeNotification, makeMessage } from "../../../helpers/notificationMessage.ts"; +import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; +import type { IEmailService } from "../../../../src/service/infrastructure/emailService.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createMockEmailService = () => + ({ + buildEmail: jest.fn().mockResolvedValue("email"), + sendEmail: jest.fn().mockResolvedValue("msg-123"), + }) as unknown as jest.Mocked; + +const createProvider = () => { + const logger = createMockLogger(); + const emailService = createMockEmailService(); + const provider = new EmailProvider(emailService, logger as any); + return { provider, logger, emailService }; +}; + +testNotificationProviderContract("EmailProvider", { + create: () => createProvider().provider, + makeNotification: () => makeNotification(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("EmailProvider", () => { + describe("sendTestAlert", () => { + it("builds test email and sends it", async () => { + const { provider, emailService } = createProvider(); + const result = await provider.sendTestAlert(makeNotification()); + expect(result).toBe(true); + expect(emailService.buildEmail).toHaveBeenCalledWith("testEmailTemplate", { testName: "Monitoring System" }); + expect(emailService.sendEmail).toHaveBeenCalledWith("https://hooks.example.com/webhook", "Test notification", "email"); + }); + + it("returns false when address is missing", async () => { + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Missing address" })); + }); + + it("returns false when buildEmail returns undefined", async () => { + const { provider, emailService, logger } = createProvider(); + (emailService.buildEmail as jest.Mock).mockResolvedValue(undefined); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build test email content" })); + }); + + it("returns false when sendEmail returns falsy", async () => { + const { provider, emailService, logger } = createProvider(); + (emailService.sendEmail as jest.Mock).mockResolvedValue(false); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Email test alert failed" })); + }); + }); + + describe("sendMessage", () => { + it("builds email from message and sends it", async () => { + const { provider, emailService } = createProvider(); + const result = await provider.sendMessage(makeNotification() as any, makeMessage()); + expect(result).toBe(true); + expect(emailService.buildEmail).toHaveBeenCalledWith( + "unifiedNotificationTemplate", + expect.objectContaining({ title: "Monitor Down: Test Monitor" }) + ); + expect(emailService.sendEmail).toHaveBeenCalled(); + }); + + it("returns false when address is missing", async () => { + const { provider } = createProvider(); + expect(await provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false when buildEmail returns undefined", async () => { + const { provider, emailService, logger } = createProvider(); + (emailService.buildEmail as jest.Mock).mockResolvedValue(undefined); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build email content" })); + }); + + it("returns false when sendEmail returns falsy", async () => { + const { provider, emailService, logger } = createProvider(); + (emailService.sendEmail as jest.Mock).mockResolvedValue(undefined); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Email notification failed" })); + }); + + it("builds correct subject for monitor_down", async () => { + const { provider, emailService } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage({ type: "monitor_down" })); + expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor is down", expect.anything()); + }); + + it("builds correct subject for monitor_up", async () => { + const { provider, emailService } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage({ type: "monitor_up" })); + expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor is back up", expect.anything()); + }); + + it("builds correct subject for threshold_breach", async () => { + const { provider, emailService } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage({ type: "threshold_breach" })); + expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor threshold exceeded", expect.anything()); + }); + + it("builds correct subject for threshold_resolved", async () => { + const { provider, emailService } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage({ type: "threshold_resolved" })); + expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Monitor Test Monitor thresholds resolved", expect.anything()); + }); + + it("builds default subject for unknown type", async () => { + const { provider, emailService } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage({ type: "test" })); + expect(emailService.sendEmail).toHaveBeenCalledWith(expect.anything(), "Alert: Test Monitor", expect.anything()); + }); + + it("passes incident url in context when present", async () => { + const { provider, emailService } = createProvider(); + const msg = makeMessage(); + msg.content.incident = { id: "inc-1", url: "https://app.example.com/incidents/inc-1", createdAt: new Date() }; + await provider.sendMessage(makeNotification() as any, msg); + expect(emailService.buildEmail).toHaveBeenCalledWith( + "unifiedNotificationTemplate", + expect.objectContaining({ incidentUrl: "https://app.example.com/incidents/inc-1" }) + ); + }); + + it("maps all severity levels to colors", async () => { + const { provider, emailService } = createProvider(); + const severityColors: Record = { critical: "red", warning: "#f59e0b", info: "#3b82f6", success: "green" }; + for (const [severity, color] of Object.entries(severityColors)) { + (emailService.buildEmail as jest.Mock).mockClear(); + await provider.sendMessage(makeNotification() as any, makeMessage({ severity: severity as any })); + expect(emailService.buildEmail).toHaveBeenCalledWith("unifiedNotificationTemplate", expect.objectContaining({ headerColor: color })); + } + }); + + it("uses default color for unknown severity", async () => { + const { provider, emailService } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessage({ severity: "unknown" as any })); + expect(emailService.buildEmail).toHaveBeenCalledWith("unifiedNotificationTemplate", expect.objectContaining({ headerColor: "#3b82f6" })); + }); + }); +}); diff --git a/server/test/unit/providers/notifications/matrixProvider.test.ts b/server/test/unit/providers/notifications/matrixProvider.test.ts new file mode 100644 index 000000000..f6ad36692 --- /dev/null +++ b/server/test/unit/providers/notifications/matrixProvider.test.ts @@ -0,0 +1,144 @@ +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 mockGotPut = jest.fn().mockResolvedValue({}); +jest.unstable_mockModule("got", () => ({ default: { put: mockGotPut } })); +jest.unstable_mockModule("crypto", () => ({ randomUUID: () => "test-uuid-1234" })); + +const { MatrixProvider } = await import("../../../../src/service/infrastructure/notificationProviders/matrix.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + return { provider: new MatrixProvider(logger as any), logger }; +}; + +testNotificationProviderContract("MatrixProvider", { + create: () => { + mockGotPut.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeNotification(), +}); + +describe("MatrixProvider", () => { + beforeEach(() => mockGotPut.mockReset().mockResolvedValue({})); + + describe("sendTestAlert", () => { + it("sends to Matrix API and returns true", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true); + expect(mockGotPut).toHaveBeenCalledWith( + expect.stringContaining("matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/test-uuid-1234"), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer token-abc" }), + }) + ); + }); + + it("returns false when homeserverUrl is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification({ homeserverUrl: undefined }))).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false); + }); + + it("returns false when roomId is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification({ roomId: undefined }))).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPut.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("handles non-Error thrown values", async () => { + mockGotPut.mockRejectedValue({ message: "custom obj" }); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); + }); + }); + + describe("sendMessage", () => { + it("sends formatted HTML and plain text", async () => { + const { provider } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true); + const body = mockGotPut.mock.calls[0][1].json; + expect(body.msgtype).toBe("m.text"); + expect(body.format).toBe("org.matrix.custom.html"); + expect(body.body).toContain("Monitor Down"); + expect(body.formatted_body).toContain(" { + expect(await createProvider().provider.sendMessage(makeNotification({ homeserverUrl: undefined }) as any, makeMessage())).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPut.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("includes threshold breaches", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds()); + const body = mockGotPut.mock.calls[0][1].json; + expect(body.body).toContain("CPU"); + expect(body.formatted_body).toContain("CPU"); + }); + + it("includes incident link", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithIncident()); + const body = mockGotPut.mock.calls[0][1].json; + expect(body.body).toContain("View Incident"); + expect(body.formatted_body).toContain("View Incident"); + }); + + it("omits optional sections when not present", async () => { + const { provider } = createProvider(); + const msg = makeMessage(); + msg.content.thresholds = undefined; + msg.content.details = undefined; + msg.content.incident = undefined; + await provider.sendMessage(makeNotification() as any, msg); + const body = mockGotPut.mock.calls[0][1].json; + expect(body.body).not.toContain("Threshold"); + expect(body.body).not.toContain("Additional"); + expect(body.body).not.toContain("View Incident"); + }); + + it("escapes HTML special characters", async () => { + const { provider } = createProvider(); + const msg = makeMessage(); + msg.monitor.name = ''; + await provider.sendMessage(makeNotification() as any, msg); + const html = mockGotPut.mock.calls[0][1].json.formatted_body; + expect(html).not.toContain("