From 365987c28736683beeb012b0661e3a195b1b19d4 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 8 Apr 2026 10:31:24 -0700 Subject: [PATCH 01/23] relocate tests, add statusService tests --- server/package-lock.json | 86 -- server/package.json | 3 +- .../service/infrastructure/statusService.ts | 18 +- server/test/{ => unit}/monitorService.test.ts | 4 +- server/test/unit/statusService.test.ts | 889 ++++++++++++++++++ .../{ => unit}/superSimpleQueueHelper.test.ts | 6 +- server/test/{ => unit}/teamsProvider.test.ts | 8 +- 7 files changed, 906 insertions(+), 108 deletions(-) rename server/test/{ => unit}/monitorService.test.ts (98%) create mode 100644 server/test/unit/statusService.test.ts rename server/test/{ => unit}/superSimpleQueueHelper.test.ts (95%) rename server/test/{ => unit}/teamsProvider.test.ts (96%) diff --git a/server/package-lock.json b/server/package-lock.json index 067a46b64..1b9061d0c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -69,7 +69,6 @@ "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", - "c8": "10.1.3", "eslint": "^9.17.0", "eslint-plugin-mocha": "^10.5.0", "esm": "3.2.25", @@ -1358,16 +1357,6 @@ "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", "license": "Apache-2.0" }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -6053,40 +6042,6 @@ "node": ">= 0.8" } }, - "node_modules/c8": { - "version": "10.1.3", - "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", - "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.1", - "@istanbuljs/schema": "^0.1.3", - "find-up": "^5.0.0", - "foreground-child": "^3.1.1", - "istanbul-lib-coverage": "^3.2.0", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.1.6", - "test-exclude": "^7.0.1", - "v8-to-istanbul": "^9.0.0", - "yargs": "^17.7.2", - "yargs-parser": "^21.1.1" - }, - "bin": { - "c8": "bin/c8.js" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "monocart-coverage-reports": "^2" - }, - "peerDependenciesMeta": { - "monocart-coverage-reports": { - "optional": true - } - } - }, "node_modules/cacheable-lookup": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", @@ -14666,47 +14621,6 @@ "node": ">=6" } }, - "node_modules/test-exclude": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", - "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^9.0.4" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", diff --git a/server/package.json b/server/package.json index 4d1ab4c98..45057ccbf 100755 --- a/server/package.json +++ b/server/package.json @@ -5,7 +5,7 @@ "main": "index.js", "type": "module", "scripts": { - "test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --coverage", "dev": "nodemon --exec tsx src/index.js", "start": "node --watch ./dist/index.js", "build": "tsc && tsc-alias && cp -r src/templates dist/templates", @@ -84,7 +84,6 @@ "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", - "c8": "10.1.3", "eslint": "^9.17.0", "eslint-plugin-mocha": "^10.5.0", "esm": "3.2.25", diff --git a/server/src/service/infrastructure/statusService.ts b/server/src/service/infrastructure/statusService.ts index ef725b80a..1ade8f1f7 100755 --- a/server/src/service/infrastructure/statusService.ts +++ b/server/src/service/infrastructure/statusService.ts @@ -147,13 +147,7 @@ export class StatusService implements IStatusService { } // Calculate uptime percentage - let uptimePercentage; - if (stats.totalChecks > 0) { - uptimePercentage = stats.totalUpChecks / stats.totalChecks; - } else { - uptimePercentage = status === true ? 100 : 0; - } - stats.uptimePercentage = uptimePercentage; + stats.uptimePercentage = stats.totalUpChecks / stats.totalChecks; // latest check stats.lastCheckTimestamp = new Date().getTime(); @@ -268,7 +262,7 @@ export class StatusService implements IStatusService { let thresholdBreaches: { cpu: boolean; memory: boolean; disk: boolean; temp: boolean } | undefined; if (monitor.type === "hardware" && statusResponse.payload) { const payload = statusResponse.payload as HardwareStatusPayload; - const metrics = payload?.data; + const metrics = payload.data; if (metrics) { // Evaluate threshold breaches @@ -278,9 +272,11 @@ export class StatusService implements IStatusService { const memoryUsage = metrics.memory?.usage_percent ?? -1; const memoryBreach = memoryUsage !== -1 && memoryUsage > monitor.memoryAlertThreshold / 100; - const diskBreach = - metrics.disk?.some((d: CheckDiskInfo) => typeof d?.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100) ?? - false; + const diskBreach = metrics.disk + ? metrics.disk.some( + (d: CheckDiskInfo) => d != null && typeof d.usage_percent === "number" && d.usage_percent > monitor.diskAlertThreshold / 100 + ) + : false; const temps = metrics.cpu?.temperature ?? []; const tempBreach = temps.some((temp: number) => temp > monitor.tempAlertThreshold); diff --git a/server/test/monitorService.test.ts b/server/test/unit/monitorService.test.ts similarity index 98% rename from server/test/monitorService.test.ts rename to server/test/unit/monitorService.test.ts index 02e62c75b..598797bc5 100644 --- a/server/test/monitorService.test.ts +++ b/server/test/unit/monitorService.test.ts @@ -1,5 +1,5 @@ import { jest } from "@jest/globals"; -import { MonitorService } from "../src/service/business/monitorService.ts"; +import { MonitorService } from "../../src/service/business/monitorService.ts"; import type { IChecksRepository, IGeoChecksRepository, @@ -7,7 +7,7 @@ import type { IMonitorStatsRepository, IMonitorsRepository, IStatusPagesRepository, -} from "../src/repositories/index.ts"; +} from "../../src/repositories/index.ts"; const createMonitorsRepositoryMock = () => ({ diff --git a/server/test/unit/statusService.test.ts b/server/test/unit/statusService.test.ts new file mode 100644 index 000000000..ec42536ea --- /dev/null +++ b/server/test/unit/statusService.test.ts @@ -0,0 +1,889 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { StatusService } from "../../src/service/infrastructure/statusService.ts"; +import { createMockLogger } from "../helpers/createMockLogger.ts"; +import type { Monitor, MonitorStatusResponse, Check, HardwareStatusPayload } from "../../src/types/index.ts"; +import type { IMonitorsRepository, IMonitorStatsRepository, IChecksRepository } from "../../src/repositories/index.ts"; +import type { IBufferService } from "../../src/service/infrastructure/bufferService.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createBuffer = (): jest.Mocked> => ({ + addToBuffer: jest.fn(), +}); + +const createMonitorsRepo = () => + ({ + findById: jest.fn(), + updateById: jest.fn(), + }) as unknown as jest.Mocked; + +const createMonitorStatsRepo = () => + ({ + findByMonitorId: jest.fn(), + create: jest.fn(), + updateByMonitorId: jest.fn(), + }) as unknown as jest.Mocked; + +const createChecksRepo = () => ({}) as unknown as jest.Mocked; + +const createService = (overrides?: { + logger?: ReturnType; + buffer?: ReturnType; + monitorsRepository?: ReturnType; + monitorStatsRepository?: ReturnType; + checksRepository?: ReturnType; +}) => { + const logger = overrides?.logger ?? createMockLogger(); + const buffer = overrides?.buffer ?? createBuffer(); + const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo(); + const monitorStatsRepository = overrides?.monitorStatsRepository ?? createMonitorStatsRepo(); + const checksRepository = overrides?.checksRepository ?? createChecksRepo(); + + const service = new StatusService(logger as any, buffer as any, monitorsRepository, monitorStatsRepository, checksRepository); + return { service, logger, buffer, monitorsRepository, monitorStatsRepository, checksRepository }; +}; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + userId: "user-1", + teamId: "team-1", + name: "Test Monitor", + type: "http", + url: "https://example.com", + isActive: true, + interval: 60000, + status: "up", + statusWindow: [], + statusWindowSize: 5, + statusWindowThreshold: 80, + recentChecks: [], + cpuAlertThreshold: 80, + memoryAlertThreshold: 80, + diskAlertThreshold: 80, + tempAlertThreshold: 80, + cpuAlertCounter: 5, + memoryAlertCounter: 5, + diskAlertCounter: 5, + tempAlertCounter: 5, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as Monitor; + +const makeStatusResponse = (overrides?: Partial): MonitorStatusResponse => + ({ + monitorId: "mon-1", + teamId: "team-1", + type: "http", + status: true, + code: 200, + message: "OK", + responseTime: 100, + ...overrides, + }) as MonitorStatusResponse; + +const makeCheck = (overrides?: Partial): Check => + ({ + id: "check-1", + metadata: { monitorId: "mon-1", teamId: "team-1" }, + status: true, + responseTime: 100, + statusCode: 200, + message: "OK", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as Check; + +const makeExistingStats = (overrides?: Record) => ({ + id: "stats-1", + monitorId: "mon-1", + avgResponseTime: 100, + maxResponseTime: 200, + totalChecks: 10, + totalUpChecks: 9, + totalDownChecks: 1, + uptimePercentage: 0.9, + lastResponseTime: 90, + lastCheckTimestamp: 1000, + timeOfLastFailure: 500, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("StatusService", () => { + describe("serviceName", () => { + it("returns StatusService", () => { + const { service } = createService(); + expect(service.serviceName).toBe("StatusService"); + }); + }); + + // ── updateRunningStats ─────────────────────────────────────────────────── + + describe("updateRunningStats", () => { + it("creates new stats when none exist", async () => { + const { service, monitorStatsRepository } = createService(); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockRejectedValue(new Error("not found")); + (monitorStatsRepository.create as jest.Mock).mockResolvedValue({}); + + const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 50 })); + + expect(result).toBe(true); + expect(monitorStatsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + monitorId: "mon-1", + totalChecks: 1, + totalUpChecks: 1, + avgResponseTime: 50, + lastResponseTime: 50, + }) + ); + }); + + it("updates existing stats for a successful check", async () => { + const { service, monitorStatsRepository } = createService(); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats()); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({}); + + const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 110 })); + + expect(result).toBe(true); + expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith( + "mon-1", + expect.objectContaining({ + totalChecks: 11, + totalUpChecks: 10, + totalDownChecks: 1, + }) + ); + }); + + it("increments totalDownChecks and resets timeOfLastFailure on failure", async () => { + const { service, monitorStatsRepository } = createService(); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats()); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({}); + + await service.updateRunningStats(makeMonitor(), makeStatusResponse({ status: false, responseTime: 100 })); + + expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith( + "mon-1", + expect.objectContaining({ + totalDownChecks: 2, + timeOfLastFailure: 0, + }) + ); + }); + + it("sets timeOfLastFailure when status is up and it was previously 0", async () => { + const { service, monitorStatsRepository } = createService(); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ timeOfLastFailure: 0 })); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({}); + + await service.updateRunningStats(makeMonitor(), makeStatusResponse({ status: true })); + + expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith( + "mon-1", + expect.objectContaining({ + timeOfLastFailure: expect.any(Number), + }) + ); + const call = (monitorStatsRepository.updateByMonitorId as jest.Mock).mock.calls[0] as [string, Record]; + expect(call[1].timeOfLastFailure).toBeGreaterThan(0); + }); + + it("updates maxResponseTime when new response is higher", async () => { + const { service, monitorStatsRepository } = createService(); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ maxResponseTime: 200 })); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({}); + + await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 300 })); + + expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ maxResponseTime: 300 })); + }); + + it("does not update maxResponseTime when new response is lower", async () => { + const { service, monitorStatsRepository } = createService(); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ maxResponseTime: 200 })); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({}); + + await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 50 })); + + expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ maxResponseTime: 200 })); + }); + + it("initializes avgResponseTime when previous avg was 0", async () => { + const { service, monitorStatsRepository } = createService(); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ avgResponseTime: 0 })); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({}); + + await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 75 })); + + expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ avgResponseTime: 75 })); + }); + + it("computes running average when avgResponseTime is nonzero", async () => { + const { service, monitorStatsRepository } = createService(); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ avgResponseTime: 100, totalChecks: 10 })); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({}); + + await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 210 })); + + const call = (monitorStatsRepository.updateByMonitorId as jest.Mock).mock.calls[0] as [string, Record]; + // (100 * 10 + 210) / 11 = 110 + expect(call[1].avgResponseTime).toBe(110); + }); + + it("leaves avgResponseTime unchanged when responseTime is undefined", async () => { + const { service, monitorStatsRepository } = createService(); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ avgResponseTime: 100 })); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({}); + + await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: undefined })); + + expect(monitorStatsRepository.updateByMonitorId).toHaveBeenCalledWith("mon-1", expect.objectContaining({ avgResponseTime: 100 })); + }); + + it("handles responseTime of 0 (falsy but defined)", async () => { + const { service, monitorStatsRepository } = createService(); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats({ avgResponseTime: 100, maxResponseTime: 200 })); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockResolvedValue({}); + + await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 0 })); + + const call = (monitorStatsRepository.updateByMonitorId as jest.Mock).mock.calls[0] as [string, Record]; + expect(call[1].lastResponseTime).toBe(0); + // responseTime 0 is falsy, so maxResponseTime stays unchanged + expect(call[1].maxResponseTime).toBe(200); + }); + + it("returns false and logs error when an exception is thrown", async () => { + const { service, logger, monitorStatsRepository } = createService(); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats()); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue(new Error("db write failed")); + + const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse()); + + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + service: "StatusService", + method: "updateRunningStats", + message: "db write failed", + }) + ); + }); + + it("logs error with 'Unknown error' for non-Error exceptions", async () => { + const { service, logger, monitorStatsRepository } = createService(); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(makeExistingStats()); + (monitorStatsRepository.updateByMonitorId as jest.Mock).mockRejectedValue("string error"); + + const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse()); + + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + + it("creates new stats when findByMonitorId resolves to null", async () => { + const { service, monitorStatsRepository } = createService(); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(null); + (monitorStatsRepository.create as jest.Mock).mockResolvedValue({}); + + const result = await service.updateRunningStats(makeMonitor(), makeStatusResponse({ responseTime: 50, status: true })); + + expect(result).toBe(true); + expect(monitorStatsRepository.create).toHaveBeenCalled(); + }); + }); + + // ── updateMonitorStatus ────────────────────────────────────────────────── + + describe("updateMonitorStatus", () => { + it("returns early with no status change when statusWindow is not full", async () => { + const monitor = makeMonitor({ statusWindow: [], statusWindowSize: 5, status: "up" }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck()); + + expect(result.statusChanged).toBe(false); + expect(result.prevStatus).toBe("up"); + }); + + it("pushes to statusWindow and trims to statusWindowSize", async () => { + const monitor = makeMonitor({ statusWindow: [true, true, true, true, true], statusWindowSize: 5 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck()); + + // Window should have shifted: [true, true, true, true, false] + expect(monitor.statusWindow).toHaveLength(5); + expect(monitor.statusWindow[4]).toBe(false); + }); + + it("pushes check snapshot to recentChecks and trims to 25", async () => { + const existingChecks = Array.from({ length: 25 }, (_, i) => ({ id: `old-${i}` })); + const monitor = makeMonitor({ recentChecks: existingChecks as any, statusWindow: [], statusWindowSize: 5 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + await service.updateMonitorStatus(makeStatusResponse(), makeCheck({ id: "new-check" })); + + expect(monitor.recentChecks).toHaveLength(25); + expect(monitor.recentChecks[24]).toEqual(expect.objectContaining({ id: "new-check" })); + }); + + it("marks status as down when failure threshold is met", async () => { + // 5/5 failures = 100% >= 80% threshold + const monitor = makeMonitor({ + statusWindow: [false, false, false, false], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "up", + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + + expect(result.statusChanged).toBe(true); + expect(result.monitor.status).toBe("down"); + }); + + it("recovers to up when failure rate drops below threshold", async () => { + // 1/5 failures = 20% < 80% threshold, and monitor was down + const monitor = makeMonitor({ + statusWindow: [true, true, true, false], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "down", + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck({ status: true })); + + expect(result.statusChanged).toBe(true); + expect(result.monitor.status).toBe("up"); + }); + + it("does not change status when already up and below threshold", async () => { + const monitor = makeMonitor({ + statusWindow: [true, true, true, true], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "up", + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: true }), makeCheck()); + + expect(result.statusChanged).toBe(false); + expect(result.monitor.status).toBe("up"); + }); + + it("does not change status when already down and still above threshold", async () => { + const monitor = makeMonitor({ + statusWindow: [false, false, false, false], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "down", + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ status: false }), makeCheck({ status: false })); + + expect(result.statusChanged).toBe(false); + expect(result.monitor.status).toBe("down"); + }); + + it("initializes statusWindow when undefined", async () => { + const monitor = makeMonitor({ statusWindow: undefined as any, statusWindowSize: 5 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + await service.updateMonitorStatus(makeStatusResponse(), makeCheck()); + + expect(monitor.statusWindow).toEqual([true]); + }); + + it("initializes recentChecks when undefined", async () => { + const monitor = makeMonitor({ recentChecks: undefined as any, statusWindowSize: 5 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + await service.updateMonitorStatus(makeStatusResponse(), makeCheck()); + + expect(monitor.recentChecks).toHaveLength(1); + }); + + it("throws AppError when repository throws", async () => { + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockRejectedValue(new Error("db error")); + + await expect(service.updateMonitorStatus(makeStatusResponse(), makeCheck())).rejects.toThrow("Failed to update monitor"); + }); + + it("throws AppError with 'Unknown error' for non-Error exceptions", async () => { + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockRejectedValue("string error"); + + await expect(service.updateMonitorStatus(makeStatusResponse(), makeCheck())).rejects.toThrow("Unknown error"); + }); + + it("returns code and timestamp in result", async () => { + const monitor = makeMonitor({ statusWindow: [true, true, true, true], statusWindowSize: 5 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse({ code: 201 }), makeCheck()); + + expect(result.code).toBe(201); + expect(result.timestamp).toBeGreaterThan(0); + }); + + // ── Hardware threshold breach tests ────────────────────────────────── + + describe("hardware threshold breaches", () => { + const makeHardwareMonitor = (overrides?: Partial) => + makeMonitor({ + type: "hardware", + statusWindow: [true, true, true, true], + statusWindowSize: 5, + status: "up", + cpuAlertThreshold: 80, + memoryAlertThreshold: 80, + diskAlertThreshold: 80, + tempAlertThreshold: 80, + cpuAlertCounter: 5, + memoryAlertCounter: 5, + diskAlertCounter: 5, + tempAlertCounter: 5, + ...overrides, + }); + + const makeHardwareResponse = (payload: HardwareStatusPayload, overrides?: Partial>) => + makeStatusResponse({ + type: "hardware", + status: true, + payload, + ...overrides, + } as any) as MonitorStatusResponse; + + it("detects CPU threshold breach and decrements counter", async () => { + const monitor = makeHardwareMonitor({ cpuAlertCounter: 1 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.5 }, disk: [], host: {} } } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.cpu).toBe(true); + expect(result.thresholdBreaches?.memory).toBe(false); + expect(monitor.cpuAlertCounter).toBe(0); + expect(result.statusChanged).toBe(true); + expect(result.monitor.status).toBe("breached"); + }); + + it("detects memory threshold breach", async () => { + const monitor = makeHardwareMonitor({ memoryAlertCounter: 1 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.5 }, memory: { usage_percent: 0.9 }, disk: [], host: {} } } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.memory).toBe(true); + expect(monitor.memoryAlertCounter).toBe(0); + expect(result.monitor.status).toBe("breached"); + }); + + it("detects disk threshold breach", async () => { + const monitor = makeHardwareMonitor({ diskAlertCounter: 1 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.95 }], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.disk).toBe(true); + expect(monitor.diskAlertCounter).toBe(0); + expect(result.monitor.status).toBe("breached"); + }); + + it("detects temperature threshold breach", async () => { + const monitor = makeHardwareMonitor({ tempAlertCounter: 1 }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1, temperature: [90] }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.temp).toBe(true); + expect(monitor.tempAlertCounter).toBe(0); + expect(result.monitor.status).toBe("breached"); + }); + + it("resets counters to 5 when thresholds are not breached", async () => { + const monitor = makeHardwareMonitor({ + cpuAlertCounter: 2, + memoryAlertCounter: 2, + diskAlertCounter: 2, + tempAlertCounter: 2, + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1, temperature: [30] }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.1 }], host: {} }, + } as any); + await service.updateMonitorStatus(response, makeCheck()); + + expect(monitor.cpuAlertCounter).toBe(5); + expect(monitor.memoryAlertCounter).toBe(5); + expect(monitor.diskAlertCounter).toBe(5); + expect(monitor.tempAlertCounter).toBe(5); + }); + + it("stays breached without statusChanged when already breached and counter still at 0", async () => { + const monitor = makeHardwareMonitor({ + status: "breached", + cpuAlertCounter: 1, + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.monitor.status).toBe("breached"); + expect(result.statusChanged).toBe(false); + }); + + it("recovers from breached to up when all thresholds return to normal", async () => { + const monitor = makeHardwareMonitor({ status: "breached" }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1, temperature: [30] }, memory: { usage_percent: 0.1 }, disk: [{ usage_percent: 0.1 }], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.statusChanged).toBe(true); + expect(result.monitor.status).toBe("up"); + }); + + it("does not override down status with breached", async () => { + // Monitor is down due to failure threshold, hardware breaches should not override + const monitor = makeHardwareMonitor({ + statusWindow: [false, false, false, false], + statusWindowSize: 5, + statusWindowThreshold: 80, + status: "up", + cpuAlertCounter: 1, + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + status: false, + data: { cpu: { usage_percent: 0.9 }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck({ status: false })); + + expect(result.monitor.status).toBe("down"); + }); + + it("handles missing cpu usage_percent (returns -1)", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ data: { cpu: {}, memory: { usage_percent: 0.1 }, disk: [], host: {} } } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.cpu).toBe(false); + }); + + it("handles missing memory usage_percent", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ data: { cpu: { usage_percent: 0.1 }, memory: {}, disk: [], host: {} } } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.memory).toBe(false); + }); + + it("handles empty temperature array", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1, temperature: [] }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.temp).toBe(false); + }); + + it("handles undefined temperature", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.temp).toBe(false); + }); + + it("skips threshold evaluation when payload is undefined", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse(undefined as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches).toBeUndefined(); + }); + + it("skips threshold evaluation when payload.data is undefined", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({} as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches).toBeUndefined(); + }); + + it("does not set thresholdBreaches for non-hardware monitors", async () => { + const monitor = makeMonitor({ + type: "http", + statusWindow: [true, true, true, true], + statusWindowSize: 5, + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const result = await service.updateMonitorStatus(makeStatusResponse(), makeCheck()); + + expect(result.thresholdBreaches).toBeUndefined(); + }); + + it("handles null entry in disk array", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [null as any], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.disk).toBe(false); + }); + + it("handles disk with undefined usage_percent entries", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: [{ device: "/dev/sda" }], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.disk).toBe(false); + }); + + it("handles nullish cpu in metrics", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: undefined, memory: { usage_percent: 0.1 }, disk: [], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.cpu).toBe(false); + expect(result.thresholdBreaches?.temp).toBe(false); + }); + + it("handles nullish memory in metrics", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1 }, memory: undefined, disk: [], host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.memory).toBe(false); + }); + + it("handles nullish disk in metrics", async () => { + const monitor = makeHardwareMonitor(); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.1 }, memory: { usage_percent: 0.1 }, disk: undefined, host: {} }, + } as any); + const result = await service.updateMonitorStatus(response, makeCheck()); + + expect(result.thresholdBreaches?.disk).toBe(false); + }); + + it("counters do not go below 0", async () => { + const monitor = makeHardwareMonitor({ + cpuAlertCounter: 0, + memoryAlertCounter: 0, + diskAlertCounter: 0, + tempAlertCounter: 0, + }); + const { service, monitorsRepository } = createService(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (monitorsRepository.updateById as jest.Mock).mockImplementation((_id: unknown, _tid: unknown, m: unknown) => Promise.resolve(m)); + + const response = makeHardwareResponse({ + data: { cpu: { usage_percent: 0.9, temperature: [90] }, memory: { usage_percent: 0.9 }, disk: [{ usage_percent: 0.95 }], host: {} }, + } as any); + await service.updateMonitorStatus(response, makeCheck()); + + expect(monitor.cpuAlertCounter).toBe(0); + expect(monitor.memoryAlertCounter).toBe(0); + expect(monitor.diskAlertCounter).toBe(0); + expect(monitor.tempAlertCounter).toBe(0); + }); + }); + }); + + // ── insertCheck ────────────────────────────────────────────────────────── + + describe("insertCheck", () => { + it("adds check to buffer and returns true", async () => { + const { service, buffer } = createService(); + const check = makeCheck(); + + const result = await service.insertCheck(check); + + expect(result).toBe(true); + expect(buffer.addToBuffer).toHaveBeenCalledWith(check); + }); + + it("returns false and logs warning when check is undefined", async () => { + const { service, logger } = createService(); + + const result = await service.insertCheck(undefined as any); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Failed to build check", + service: "StatusService", + method: "insertCheck", + }) + ); + }); + + it("logs error when buffer throws", async () => { + const buffer = createBuffer(); + (buffer.addToBuffer as jest.Mock).mockImplementation(() => { + throw new Error("buffer full"); + }); + const { service, logger } = createService({ buffer }); + + const result = await service.insertCheck(makeCheck()); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "buffer full", + service: "StatusService", + method: "insertCheck", + }) + ); + }); + + it("handles nullish check in error path", async () => { + const buffer = createBuffer(); + (buffer.addToBuffer as jest.Mock).mockImplementation(() => { + throw new Error("boom"); + }); + const { service, logger } = createService({ buffer }); + + await service.insertCheck(null as any); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + details: { msg: "Error inserting check for monitor: undefined" }, + }) + ); + }); + + it("logs 'Unknown error' for non-Error exceptions in insertCheck", async () => { + const buffer = createBuffer(); + (buffer.addToBuffer as jest.Mock).mockImplementation(() => { + throw "string error"; + }); + const { service, logger } = createService({ buffer }); + + await service.insertCheck(makeCheck()); + + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); +}); diff --git a/server/test/superSimpleQueueHelper.test.ts b/server/test/unit/superSimpleQueueHelper.test.ts similarity index 95% rename from server/test/superSimpleQueueHelper.test.ts rename to server/test/unit/superSimpleQueueHelper.test.ts index 25db19dbc..8a24718b6 100644 --- a/server/test/superSimpleQueueHelper.test.ts +++ b/server/test/unit/superSimpleQueueHelper.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { SuperSimpleQueueHelper } from "../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; -import type { Monitor } from "../src/types/monitor.ts"; -import { createMockLogger } from "./helpers/createMockLogger.ts"; +import { SuperSimpleQueueHelper } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import type { Monitor } from "../../src/types/monitor.ts"; +import { createMockLogger } from "../helpers/createMockLogger.ts"; const createHelper = (overrides?: Record) => { const maintenanceWindowsRepository = { diff --git a/server/test/teamsProvider.test.ts b/server/test/unit/teamsProvider.test.ts similarity index 96% rename from server/test/teamsProvider.test.ts rename to server/test/unit/teamsProvider.test.ts index d63324f9b..6640826f3 100644 --- a/server/test/teamsProvider.test.ts +++ b/server/test/unit/teamsProvider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; -import type { Notification } from "../src/types/notification.ts"; -import type { NotificationMessage } from "../src/types/notificationMessage.ts"; -import { createMockLogger } from "./helpers/createMockLogger.ts"; +import type { Notification } from "../../src/types/notification.ts"; +import type { NotificationMessage } from "../../src/types/notificationMessage.ts"; +import { createMockLogger } from "../helpers/createMockLogger.ts"; const mockPost = jest.fn(); jest.unstable_mockModule("got", () => ({ @@ -9,7 +9,7 @@ jest.unstable_mockModule("got", () => ({ HTTPError: class HTTPError extends Error {}, })); -const { TeamsProvider } = await import("../src/service/infrastructure/notificationProviders/teams.ts"); +const { TeamsProvider } = await import("../../src/service/infrastructure/notificationProviders/teams.ts"); const createNotification = (overrides?: Partial): Notification => ({ id: "notif-1", From 7329306ccf6b648d3f4a2198cfd4e3f566b02db8 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 8 Apr 2026 10:43:47 -0700 Subject: [PATCH 02/23] add incident serivce tests --- .../src/service/business/incidentService.ts | 15 +- server/test/unit/incidentService.test.ts | 472 ++++++++++++++++++ 2 files changed, 478 insertions(+), 9 deletions(-) create mode 100644 server/test/unit/incidentService.test.ts diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index 4790f9aac..2070a8937 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -95,17 +95,14 @@ export class IncidentService implements IIncidentService { } } - if (decision.shouldResolveIncident) { - if (!activeIncident) { - return null; - } - activeIncident.status = false; - activeIncident.endTime = Date.now().toString(); - activeIncident.resolutionType = "automatic"; - return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident); + if (!decision.shouldResolveIncident || !activeIncident) { + return null; } - return null; + activeIncident.status = false; + activeIncident.endTime = Date.now().toString(); + activeIncident.resolutionType = "automatic"; + return await this.incidentsRepository.updateById(activeIncident.id, activeIncident.teamId, activeIncident); }; private buildThresholdBreachMessage(monitor: Monitor, monitorStatusResponse?: MonitorStatusResponse): string { diff --git a/server/test/unit/incidentService.test.ts b/server/test/unit/incidentService.test.ts new file mode 100644 index 000000000..64512b46c --- /dev/null +++ b/server/test/unit/incidentService.test.ts @@ -0,0 +1,472 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { IncidentService } from "../../src/service/business/incidentService.ts"; +import { createMockLogger } from "../helpers/createMockLogger.ts"; +import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "../../src/repositories/index.ts"; +import type { INotificationMessageBuilder } from "../../src/service/infrastructure/notificationMessageBuilder.ts"; +import type { Monitor } from "../../src/types/monitor.ts"; +import type { Incident } from "../../src/types/index.ts"; +import type { MonitorActionDecision } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createIncidentsRepo = () => + ({ + findActiveByMonitorId: jest.fn(), + findActiveByIncidentId: jest.fn(), + create: jest.fn(), + updateById: jest.fn(), + findById: jest.fn(), + findByTeamId: jest.fn(), + countByTeamId: jest.fn(), + findSummaryByTeamId: jest.fn(), + }) as unknown as jest.Mocked; + +const createMonitorsRepo = () => + ({ + findById: jest.fn(), + }) as unknown as jest.Mocked; + +const createUsersRepo = () => + ({ + findById: jest.fn(), + }) as unknown as jest.Mocked; + +const createMessageBuilder = () => + ({ + extractThresholdBreaches: jest.fn(), + }) as unknown as jest.Mocked; + +const createService = (overrides?: { + logger?: ReturnType; + incidentsRepository?: ReturnType; + monitorsRepository?: ReturnType; + usersRepository?: ReturnType; + notificationMessageBuilder?: ReturnType; +}) => { + const logger = overrides?.logger ?? createMockLogger(); + const incidentsRepository = overrides?.incidentsRepository ?? createIncidentsRepo(); + const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo(); + const usersRepository = overrides?.usersRepository ?? createUsersRepo(); + const notificationMessageBuilder = overrides?.notificationMessageBuilder ?? createMessageBuilder(); + + const service = new IncidentService(logger as any, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder); + return { service, logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder }; +}; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + name: "Test Monitor", + type: "http", + url: "https://example.com", + status: "down", + ...overrides, + }) as Monitor; + +const makeIncident = (overrides?: Partial): Incident => + ({ + id: "inc-1", + monitorId: "mon-1", + teamId: "team-1", + startTime: "1700000000000", + status: true, + statusCode: 500, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as Incident; + +const makeDecision = (overrides?: Partial): MonitorActionDecision => ({ + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: false, + incidentReason: null, + notificationReason: null, + ...overrides, +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("IncidentService", () => { + describe("serviceName", () => { + it("returns incidentService", () => { + const { service } = createService(); + expect(service.serviceName).toBe("incidentService"); + }); + }); + + // ── handleIncident ─────────────────────────────────────────────────────── + + describe("handleIncident", () => { + it("returns null when neither create nor resolve is requested", async () => { + const { service } = createService(); + const result = await service.handleIncident(makeMonitor(), 200, makeDecision()); + expect(result).toBeNull(); + }); + + it("returns existing active incident when shouldCreateIncident and one already exists", async () => { + const existing = makeIncident(); + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(existing); + + const result = await service.handleIncident(makeMonitor(), 500, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" })); + + expect(result).toBe(existing); + expect(incidentsRepository.create).not.toHaveBeenCalled(); + }); + + it("creates a new incident when shouldCreateIncident and no active incident exists", async () => { + const created = makeIncident(); + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null); + (incidentsRepository.create as jest.Mock).mockResolvedValue(created); + + const result = await service.handleIncident(makeMonitor(), 500, makeDecision({ shouldCreateIncident: true, incidentReason: "status_down" })); + + expect(result).toBe(created); + expect(incidentsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + status: true, + statusCode: 500, + }) + ); + }); + + it("uses status code 9999 and builds message for threshold_breach incidents", async () => { + const created = makeIncident(); + const { service, incidentsRepository, notificationMessageBuilder } = createService(); + (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null); + (incidentsRepository.create as jest.Mock).mockResolvedValue(created); + (notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([ + { metric: "cpu", formattedValue: "95%", threshold: 80, unit: "%" }, + ]); + + const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" }); + await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any); + + expect(incidentsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 9999, + message: "CPU: 95% (threshold: 80%)", + }) + ); + }); + + it("uses fallback message when monitorStatusResponse is undefined for threshold_breach", async () => { + const created = makeIncident(); + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null); + (incidentsRepository.create as jest.Mock).mockResolvedValue(created); + + const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" }); + await service.handleIncident(makeMonitor(), 200, decision, undefined); + + expect(incidentsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ message: "Threshold breach detected" })); + }); + + it("uses fallback message when extractThresholdBreaches returns empty array", async () => { + const created = makeIncident(); + const { service, incidentsRepository, notificationMessageBuilder } = createService(); + (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null); + (incidentsRepository.create as jest.Mock).mockResolvedValue(created); + (notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([]); + + const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" }); + await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any); + + expect(incidentsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ message: "Threshold breach detected" })); + }); + + it("joins multiple threshold breaches with commas", async () => { + const created = makeIncident(); + const { service, incidentsRepository, notificationMessageBuilder } = createService(); + (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null); + (incidentsRepository.create as jest.Mock).mockResolvedValue(created); + (notificationMessageBuilder.extractThresholdBreaches as jest.Mock).mockReturnValue([ + { metric: "cpu", formattedValue: "95%", threshold: 80, unit: "%" }, + { metric: "memory", formattedValue: "90%", threshold: 80, unit: "%" }, + ]); + + const decision = makeDecision({ shouldCreateIncident: true, incidentReason: "threshold_breach" }); + await service.handleIncident(makeMonitor(), 200, decision, { monitorId: "mon-1" } as any); + + expect(incidentsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ message: "CPU: 95% (threshold: 80%), MEMORY: 90% (threshold: 80%)" }) + ); + }); + + it("resolves active incident when shouldResolveIncident", async () => { + const active = makeIncident(); + const resolved = makeIncident({ status: false, endTime: "123", resolutionType: "automatic" }); + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(active); + (incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved); + + const result = await service.handleIncident(makeMonitor(), 200, makeDecision({ shouldResolveIncident: true })); + + expect(result).toBe(resolved); + expect(incidentsRepository.updateById).toHaveBeenCalledWith( + "inc-1", + "team-1", + expect.objectContaining({ status: false, resolutionType: "automatic" }) + ); + }); + + it("returns null when shouldResolveIncident but no active incident exists", async () => { + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null); + + const result = await service.handleIncident(makeMonitor(), 200, makeDecision({ shouldResolveIncident: true })); + + expect(result).toBeNull(); + }); + }); + + // ── resolveIncident ────────────────────────────────────────────────────── + + describe("resolveIncident", () => { + it("resolves incident with manual resolution", async () => { + const active = makeIncident(); + const resolved = makeIncident({ status: false }); + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(active); + (incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved); + + const result = await service.resolveIncident("inc-1", "user-1", "team-1", "Fixed it", "user@test.com"); + + expect(result).toBe(resolved); + expect(incidentsRepository.updateById).toHaveBeenCalledWith( + "inc-1", + "team-1", + expect.objectContaining({ + resolutionType: "manual", + status: false, + resolvedBy: "user-1", + resolvedByEmail: "user@test.com", + comment: "Fixed it", + }) + ); + }); + + it("sets resolvedByEmail and comment to null when not provided", async () => { + const active = makeIncident(); + const resolved = makeIncident({ status: false }); + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(active); + (incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved); + + await service.resolveIncident("inc-1", "user-1", "team-1"); + + expect(incidentsRepository.updateById).toHaveBeenCalledWith( + "inc-1", + "team-1", + expect.objectContaining({ + resolvedByEmail: null, + comment: null, + }) + ); + }); + + it("throws when incidentId is missing", async () => { + const { service } = createService(); + await expect(service.resolveIncident("", "user-1", "team-1")).rejects.toThrow("No incident ID in request"); + }); + + it("throws when userId is missing", async () => { + const { service } = createService(); + await expect(service.resolveIncident("inc-1", "", "team-1")).rejects.toThrow("No user ID in request"); + }); + + it("throws when teamId is missing", async () => { + const { service } = createService(); + await expect(service.resolveIncident("inc-1", "user-1", "")).rejects.toThrow("No team ID in request"); + }); + + it("throws when incident is not found", async () => { + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(null); + + await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("Incident not found"); + }); + + it("throws when incident is already resolved", async () => { + const { service, incidentsRepository } = createService(); + (incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(makeIncident({ status: false })); + + await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("Incident is already resolved"); + }); + + it("logs error and rethrows on unexpected failure", async () => { + const { service, logger, incidentsRepository } = createService(); + (incidentsRepository.findActiveByIncidentId as jest.Mock).mockRejectedValue(new Error("db error")); + + await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("db error"); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ service: "incidentService", method: "resolveIncident", message: "db error" }) + ); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { service, logger, incidentsRepository } = createService(); + (incidentsRepository.findActiveByIncidentId as jest.Mock).mockRejectedValue("string error"); + + await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toBe("string error"); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); + + // ── getIncidentsByTeam ─────────────────────────────────────────────────── + + describe("getIncidentsByTeam", () => { + it("returns incidents and count", async () => { + const incidents = [makeIncident()]; + const { service, incidentsRepository } = createService(); + (incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue(incidents); + (incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(1); + + const result = await service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined); + + expect(result).toEqual({ incidents, count: 1 }); + }); + + it("defaults page to 0 and rowsPerPage to 20 when nullish", async () => { + const { service, incidentsRepository } = createService(); + (incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue([]); + (incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(0); + + await service.getIncidentsByTeam("team-1", "desc", "day", null as any, null as any, undefined, undefined, undefined); + + expect(incidentsRepository.findByTeamId).toHaveBeenCalledWith("team-1", expect.anything(), 0, 20, "desc", undefined, undefined, undefined); + }); + + it("passes filter parameters through to repository", async () => { + const { service, incidentsRepository } = createService(); + (incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue([]); + (incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(0); + + await service.getIncidentsByTeam("team-1", "asc", "week", 2, 10, true, "mon-1", "manual"); + + expect(incidentsRepository.findByTeamId).toHaveBeenCalledWith("team-1", expect.any(Date), 2, 10, "asc", true, "mon-1", "manual"); + expect(incidentsRepository.countByTeamId).toHaveBeenCalledWith("team-1", expect.any(Date), true, "mon-1", "manual"); + }); + + it("throws when teamId is missing", async () => { + const { service } = createService(); + await expect(service.getIncidentsByTeam("", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toThrow("No team ID in request"); + }); + + it("logs error and rethrows on unexpected failure", async () => { + const { service, logger, incidentsRepository } = createService(); + (incidentsRepository.findByTeamId as jest.Mock).mockRejectedValue(new Error("db error")); + + await expect(service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toThrow("db error"); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ service: "incidentService", method: "getIncidentsByTeam" })); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { service, logger, incidentsRepository } = createService(); + (incidentsRepository.findByTeamId as jest.Mock).mockRejectedValue(42); + + await expect(service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toBe(42); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); + + // ── getIncidentSummary ─────────────────────────────────────────────────── + + describe("getIncidentSummary", () => { + it("returns summary from repository", async () => { + const summary = { recentIncidents: [], totalIncidents: 0 }; + const { service, incidentsRepository } = createService(); + (incidentsRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue(summary); + + const result = await service.getIncidentSummary("team-1", 5); + + expect(result).toBe(summary); + expect(incidentsRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", 5); + }); + + it("defaults limit to 10 when not provided", async () => { + const { service, incidentsRepository } = createService(); + (incidentsRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue({}); + + await service.getIncidentSummary("team-1"); + + expect(incidentsRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", 10); + }); + + it("throws when teamId is missing", async () => { + const { service } = createService(); + await expect(service.getIncidentSummary("")).rejects.toThrow("No team ID in request"); + }); + + it("logs error and rethrows on unexpected failure", async () => { + const { service, logger, incidentsRepository } = createService(); + (incidentsRepository.findSummaryByTeamId as jest.Mock).mockRejectedValue(new Error("db error")); + + await expect(service.getIncidentSummary("team-1")).rejects.toThrow("db error"); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ service: "incidentService", method: "getIncidentSummary" })); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { service, logger, incidentsRepository } = createService(); + (incidentsRepository.findSummaryByTeamId as jest.Mock).mockRejectedValue("boom"); + + await expect(service.getIncidentSummary("team-1")).rejects.toBe("boom"); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); + + // ── getIncidentById ────────────────────────────────────────────────────── + + describe("getIncidentById", () => { + it("returns incident, monitor, and user when resolvedBy exists", async () => { + const incident = makeIncident({ resolvedBy: "user-1" }); + const monitor = makeMonitor(); + const user = { id: "user-1", email: "user@test.com" }; + const { service, incidentsRepository, monitorsRepository, usersRepository } = createService(); + (incidentsRepository.findById as jest.Mock).mockResolvedValue(incident); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (usersRepository.findById as jest.Mock).mockResolvedValue(user); + + const result = await service.getIncidentById("inc-1", "team-1"); + + expect(result).toEqual({ incident, monitor, user }); + expect(usersRepository.findById).toHaveBeenCalledWith("user-1"); + }); + + it("returns user as null when resolvedBy is not set", async () => { + const incident = makeIncident({ resolvedBy: undefined }); + const monitor = makeMonitor(); + const { service, incidentsRepository, monitorsRepository, usersRepository } = createService(); + (incidentsRepository.findById as jest.Mock).mockResolvedValue(incident); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + + const result = await service.getIncidentById("inc-1", "team-1"); + + expect(result.user).toBeNull(); + expect(usersRepository.findById).not.toHaveBeenCalled(); + }); + + it("logs error and rethrows on unexpected failure", async () => { + const { service, logger, incidentsRepository } = createService(); + (incidentsRepository.findById as jest.Mock).mockRejectedValue(new Error("not found")); + + await expect(service.getIncidentById("inc-1", "team-1")).rejects.toThrow("not found"); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ service: "incidentService", method: "getIncidentById", details: { incidentId: "inc-1" } }) + ); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { service, logger, incidentsRepository } = createService(); + (incidentsRepository.findById as jest.Mock).mockRejectedValue(null); + + await expect(service.getIncidentById("inc-1", "team-1")).rejects.toBeNull(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); +}); From ba5368f942619f41a484e13d3f89f7f3ba62f887 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 8 Apr 2026 11:32:08 -0700 Subject: [PATCH 03/23] add notificationsService tests --- server/package.json | 1 + server/src/service/business/checkService.ts | 20 +- .../{ => providers}/teamsProvider.test.ts | 8 +- .../test/unit/services/checkService.test.ts | 427 ++++++++++++++++++ .../{ => services}/incidentService.test.ts | 14 +- .../{ => services}/monitorService.test.ts | 4 +- .../services/notificationsService.test.ts | 347 ++++++++++++++ .../unit/{ => services}/statusService.test.ts | 10 +- .../superSimpleQueueHelper.test.ts | 6 +- 9 files changed, 806 insertions(+), 31 deletions(-) rename server/test/unit/{ => providers}/teamsProvider.test.ts (95%) create mode 100644 server/test/unit/services/checkService.test.ts rename server/test/unit/{ => services}/incidentService.test.ts (97%) rename server/test/unit/{ => services}/monitorService.test.ts (98%) create mode 100644 server/test/unit/services/notificationsService.test.ts rename server/test/unit/{ => services}/statusService.test.ts (99%) rename server/test/unit/{ => services}/superSimpleQueueHelper.test.ts (95%) diff --git a/server/package.json b/server/package.json index 45057ccbf..42b1783e1 100755 --- a/server/package.json +++ b/server/package.json @@ -6,6 +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/business/*.ts' --collectCoverageFrom='src/service/infrastructure/statusService.ts' --collectCoverageFrom='src/service/infrastructure/notificationsService.ts' --collectCoverageFrom='src/service/infrastructure/SuperSimpleQueue/*.ts' test/unit/services", "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/business/checkService.ts b/server/src/service/business/checkService.ts index 374090709..dd2a66202 100644 --- a/server/src/service/business/checkService.ts +++ b/server/src/service/business/checkService.ts @@ -107,16 +107,16 @@ export class CheckService implements ICheckService { numericUnit: audit.numericUnit, }; }; - check.accessibility = (categories?.accessibility?.score || 0) * 100; - check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100; - check.seo = (categories?.seo?.score || 0) * 100; - check.performance = (categories?.performance?.score || 0) * 100; + check.accessibility = (categories.accessibility?.score || 0) * 100; + check.bestPractices = (categories["best-practices"]?.score || 0) * 100; + check.seo = (categories.seo?.score || 0) * 100; + check.performance = (categories.performance?.score || 0) * 100; check.audits = { - cls: mapAudit(audits?.["cumulative-layout-shift"]), - si: mapAudit(audits?.["speed-index"]), - fcp: mapAudit(audits?.["first-contentful-paint"]), - lcp: mapAudit(audits?.["largest-contentful-paint"]), - tbt: mapAudit(audits?.["total-blocking-time"]), + cls: mapAudit(audits["cumulative-layout-shift"]), + si: mapAudit(audits["speed-index"]), + fcp: mapAudit(audits["first-contentful-paint"]), + lcp: mapAudit(audits["largest-contentful-paint"]), + tbt: mapAudit(audits["total-blocking-time"]), }; } @@ -124,7 +124,7 @@ export class CheckService implements ICheckService { const hardwarePayload = payload as HardwareStatusPayload | undefined; const { cpu, memory, disk, host, net } = hardwarePayload?.data ?? {}; const errorsSource = Array.isArray(hardwarePayload?.errors) - ? hardwarePayload?.errors + ? hardwarePayload.errors : (hardwarePayload?.errors as { errors?: CheckErrorInfo[] } | undefined)?.errors; check.cpu = cpu; check.memory = memory; diff --git a/server/test/unit/teamsProvider.test.ts b/server/test/unit/providers/teamsProvider.test.ts similarity index 95% rename from server/test/unit/teamsProvider.test.ts rename to server/test/unit/providers/teamsProvider.test.ts index 6640826f3..41e1c6ee6 100644 --- a/server/test/unit/teamsProvider.test.ts +++ b/server/test/unit/providers/teamsProvider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; -import type { Notification } from "../../src/types/notification.ts"; -import type { NotificationMessage } from "../../src/types/notificationMessage.ts"; -import { createMockLogger } from "../helpers/createMockLogger.ts"; +import type { Notification } from "../../../src/types/notification.ts"; +import type { NotificationMessage } from "../../../src/types/notificationMessage.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; const mockPost = jest.fn(); jest.unstable_mockModule("got", () => ({ @@ -9,7 +9,7 @@ jest.unstable_mockModule("got", () => ({ HTTPError: class HTTPError extends Error {}, })); -const { TeamsProvider } = await import("../../src/service/infrastructure/notificationProviders/teams.ts"); +const { TeamsProvider } = await import("../../../src/service/infrastructure/notificationProviders/teams.ts"); const createNotification = (overrides?: Partial): Notification => ({ id: "notif-1", diff --git a/server/test/unit/services/checkService.test.ts b/server/test/unit/services/checkService.test.ts new file mode 100644 index 000000000..93919df5f --- /dev/null +++ b/server/test/unit/services/checkService.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { CheckService } from "../../../src/service/business/checkService.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { IChecksRepository, IMonitorsRepository } from "../../../src/repositories/index.ts"; +import type { MonitorStatusResponse, HardwareStatusPayload, PageSpeedStatusPayload } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createMonitorsRepo = () => + ({ + findById: jest.fn().mockResolvedValue({}), + }) as unknown as jest.Mocked; + +const createChecksRepo = () => + ({ + createChecks: jest.fn().mockImplementation((checks: unknown) => Promise.resolve(checks)), + findByMonitorId: jest.fn().mockResolvedValue({ checks: [], count: 0 }), + findByTeamId: jest.fn().mockResolvedValue({ checks: [], count: 0 }), + findSummaryByTeamId: jest.fn().mockResolvedValue({}), + deleteByMonitorId: jest.fn().mockResolvedValue(5), + deleteByTeamId: jest.fn().mockResolvedValue(10), + deleteOlderThan: jest.fn().mockResolvedValue(3), + }) as unknown as jest.Mocked; + +const createService = () => { + const logger = createMockLogger(); + const monitorsRepository = createMonitorsRepo(); + const checksRepository = createChecksRepo(); + const service = new CheckService(monitorsRepository, logger as any, checksRepository); + return { service, logger, monitorsRepository, checksRepository }; +}; + +const makeStatusResponse = (overrides?: Partial): MonitorStatusResponse => + ({ + monitorId: "mon-1", + teamId: "team-1", + type: "http", + status: true, + code: 200, + message: "OK", + responseTime: 100, + ...overrides, + }) as MonitorStatusResponse; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("CheckService", () => { + describe("serviceName", () => { + it("returns checkService", () => { + const { service } = createService(); + expect(service.serviceName).toBe("checkService"); + }); + }); + + // ── buildCheck ─────────────────────────────────────────────────────────── + + describe("buildCheck", () => { + it("builds a basic HTTP check with correct fields", () => { + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse()); + + expect(check).toBeDefined(); + expect(check!.metadata).toEqual({ monitorId: "mon-1", teamId: "team-1", type: "http" }); + expect(check!.status).toBe(true); + expect(check!.statusCode).toBe(200); + expect(check!.responseTime).toBe(100); + expect(check!.message).toBe("OK"); + expect(check!.id).toBeDefined(); + expect(check!.createdAt).toBeDefined(); + }); + + it("defaults responseTime to 0 when falsy", () => { + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ responseTime: undefined })); + expect(check!.responseTime).toBe(0); + }); + + it("includes timings when provided", () => { + const timings = { start: 0, socket: 10, lookup: 20 }; + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ timings } as any)); + expect(check!.timings).toBe(timings); + }); + + // ── PageSpeed ──────────────────────────────────────────────────────── + + describe("pagespeed type", () => { + it("extracts category scores and audits from lighthouse result", () => { + const payload: PageSpeedStatusPayload = { + lighthouseResult: { + categories: { + accessibility: { score: 0.9 }, + "best-practices": { score: 0.8 }, + seo: { score: 0.95 }, + performance: { score: 0.7 }, + }, + audits: { + "cumulative-layout-shift": { id: "cls", title: "CLS", score: 0.1, displayValue: "0.1", numericValue: 0.1, numericUnit: "unitless" }, + "speed-index": { id: "si", title: "SI", score: 0.5, displayValue: "3.0s", numericValue: 3000, numericUnit: "millisecond" }, + "first-contentful-paint": { id: "fcp", title: "FCP", score: 0.8 }, + "largest-contentful-paint": { id: "lcp", title: "LCP", score: 0.6 }, + "total-blocking-time": { id: "tbt", title: "TBT", score: 0.9 }, + }, + }, + } as any; + + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); + + expect(check!.accessibility).toBe(90); + expect(check!.bestPractices).toBe(80); + expect(check!.seo).toBe(95); + expect(check!.performance).toBe(70); + expect(check!.audits!.cls).toEqual(expect.objectContaining({ id: "cls", score: 0.1, numericValue: 0.1 })); + expect(check!.audits!.si).toEqual(expect.objectContaining({ id: "si", numericValue: 3000 })); + expect(check!.audits!.fcp).toEqual(expect.objectContaining({ id: "fcp" })); + }); + + it("returns undefined when pagespeed payload is missing", () => { + const { service, logger } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: undefined } as any)); + + expect(check).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build check" })); + }); + + it("defaults categories and audits to empty when lighthouseResult is missing", () => { + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload: {} } as any)); + + expect(check!.accessibility).toBe(0); + expect(check!.bestPractices).toBe(0); + expect(check!.seo).toBe(0); + expect(check!.performance).toBe(0); + expect(check!.audits!.cls).toBeUndefined(); + }); + + it("handles audit with non-number score (string)", () => { + const payload = { + lighthouseResult: { + categories: {}, + audits: { + "cumulative-layout-shift": { id: "cls", title: "CLS", score: "informative" }, + }, + }, + } as any; + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); + + expect(check!.audits!.cls!.score).toBe("informative"); + }); + + it("handles audit with null score", () => { + const payload = { + lighthouseResult: { + categories: {}, + audits: { + "cumulative-layout-shift": { id: "cls", title: "CLS", score: null }, + }, + }, + } as any; + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); + + expect(check!.audits!.cls!.score).toBeNull(); + }); + + it("handles audit with non-number numericValue", () => { + const payload = { + lighthouseResult: { + categories: {}, + audits: { + "speed-index": { id: "si", title: "SI", score: 0.5, numericValue: "n/a" }, + }, + }, + } as any; + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); + + expect(check!.audits!.si!.numericValue).toBeUndefined(); + }); + + it("returns undefined for non-object audit", () => { + const payload = { + lighthouseResult: { + categories: {}, + audits: { + "cumulative-layout-shift": null, + "speed-index": "not an object", + }, + }, + } as any; + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "pagespeed", payload } as any)); + + expect(check!.audits!.cls).toBeUndefined(); + expect(check!.audits!.si).toBeUndefined(); + }); + }); + + // ── Hardware ───────────────────────────────────────────────────────── + + describe("hardware type", () => { + it("extracts cpu, memory, disk, host, net, and capture from payload", () => { + const payload: HardwareStatusPayload = { + data: { + cpu: { usage_percent: 0.5 }, + memory: { usage_percent: 0.6 }, + disk: [{ device: "/dev/sda", usage_percent: 0.7 }], + host: { os: "linux" }, + net: [{ name: "eth0" }], + }, + capture: { screenshot: "base64data" }, + } as any; + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any)); + + expect(check!.cpu).toEqual({ usage_percent: 0.5 }); + expect(check!.memory).toEqual({ usage_percent: 0.6 }); + expect(check!.disk).toHaveLength(1); + expect(check!.host).toEqual({ os: "linux" }); + expect(check!.net).toHaveLength(1); + expect(check!.capture).toEqual({ screenshot: "base64data" }); + }); + + it("extracts errors from array format", () => { + const payload = { + data: {}, + errors: [{ message: "timeout" }], + } as any; + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any)); + + expect(check!.errors).toEqual([{ message: "timeout" }]); + }); + + it("extracts errors from nested object format", () => { + const payload = { + data: {}, + errors: { errors: [{ message: "nested error" }] }, + } as any; + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload } as any)); + + expect(check!.errors).toEqual([{ message: "nested error" }]); + }); + + it("handles undefined payload gracefully", () => { + const { service } = createService(); + const check = service.buildCheck(makeStatusResponse({ type: "hardware", payload: undefined } as any)); + + expect(check).toBeDefined(); + expect(check!.cpu).toBeUndefined(); + expect(check!.memory).toBeUndefined(); + expect(check!.disk).toBeUndefined(); + expect(check!.errors).toBeUndefined(); + }); + }); + }); + + // ── createChecks ───────────────────────────────────────────────────────── + + describe("createChecks", () => { + it("delegates to repository", async () => { + const { service, checksRepository } = createService(); + const checks = [{ id: "c1" }] as any; + await service.createChecks(checks); + expect(checksRepository.createChecks).toHaveBeenCalledWith(checks); + }); + }); + + // ── getChecksByMonitor ─────────────────────────────────────────────────── + + describe("getChecksByMonitor", () => { + it("returns checks from repository", async () => { + const expected = { checks: [{ id: "c1" }], count: 1 }; + const { service, checksRepository } = createService(); + (checksRepository.findByMonitorId as jest.Mock).mockResolvedValue(expected); + + const result = await service.getChecksByMonitor({ + monitorId: "mon-1", + teamId: "team-1", + sortOrder: "desc", + dateRange: "day", + page: 0, + rowsPerPage: 10, + }); + + expect(result).toBe(expected); + }); + + it("defaults page to 0 and rowsPerPage to 5 when nullish", async () => { + const { service, checksRepository } = createService(); + + await service.getChecksByMonitor({ + monitorId: "mon-1", + teamId: "team-1", + sortOrder: "desc", + dateRange: "day", + page: null as any, + rowsPerPage: null as any, + }); + + expect(checksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "desc", "day", undefined, 0, 5, undefined); + }); + + it("passes filter and status parameters", async () => { + const { service, checksRepository } = createService(); + + await service.getChecksByMonitor({ + monitorId: "mon-1", + teamId: "team-1", + sortOrder: "asc", + dateRange: "week", + page: 2, + rowsPerPage: 20, + filter: "error", + status: false, + }); + + expect(checksRepository.findByMonitorId).toHaveBeenCalledWith("mon-1", "asc", "week", "error", 2, 20, false); + }); + + it("throws when monitorId is missing", async () => { + const { service } = createService(); + await expect( + service.getChecksByMonitor({ monitorId: "", teamId: "team-1", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 }) + ).rejects.toThrow("No monitor ID in request"); + }); + + it("throws when teamId is missing", async () => { + const { service } = createService(); + await expect( + service.getChecksByMonitor({ monitorId: "mon-1", teamId: "", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 }) + ).rejects.toThrow("No team ID in request"); + }); + + it("verifies monitor belongs to team via repository", async () => { + const { service, monitorsRepository } = createService(); + + await service.getChecksByMonitor({ + monitorId: "mon-1", + teamId: "team-1", + sortOrder: "desc", + dateRange: "day", + page: 0, + rowsPerPage: 10, + }); + + expect(monitorsRepository.findById).toHaveBeenCalledWith("mon-1", "team-1"); + }); + }); + + // ── getChecksByTeam ────────────────────────────────────────────────────── + + describe("getChecksByTeam", () => { + it("returns checks from repository", async () => { + const expected = { checks: [], count: 0 }; + const { service, checksRepository } = createService(); + (checksRepository.findByTeamId as jest.Mock).mockResolvedValue(expected); + + const result = await service.getChecksByTeam({ teamId: "team-1", sortOrder: "desc", dateRange: "day", page: 0, rowsPerPage: 10 }); + + expect(result).toBe(expected); + }); + + it("defaults page to 0 and rowsPerPage to 5 when nullish", async () => { + const { service, checksRepository } = createService(); + + await service.getChecksByTeam({ teamId: "team-1", sortOrder: "desc", dateRange: "day", page: null as any, rowsPerPage: null as any }); + + expect(checksRepository.findByTeamId).toHaveBeenCalledWith("desc", "day", undefined, 0, 5, "team-1"); + }); + }); + + // ── getChecksSummaryByTeamId ────────────────────────────────────────────── + + describe("getChecksSummaryByTeamId", () => { + it("delegates to repository", async () => { + const summary = { total: 5, up: 4, down: 1 }; + const { service, checksRepository } = createService(); + (checksRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue(summary); + + const result = await service.getChecksSummaryByTeamId({ teamId: "team-1", dateRange: "day" }); + + expect(result).toBe(summary); + expect(checksRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", "day"); + }); + }); + + // ── deleteChecks ───────────────────────────────────────────────────────── + + describe("deleteChecks", () => { + it("verifies monitor ownership and deletes", async () => { + const { service, monitorsRepository, checksRepository } = createService(); + + const result = await service.deleteChecks({ monitorId: "mon-1", teamId: "team-1" }); + + expect(monitorsRepository.findById).toHaveBeenCalledWith("mon-1", "team-1"); + expect(checksRepository.deleteByMonitorId).toHaveBeenCalledWith("mon-1"); + expect(result).toBe(5); + }); + }); + + describe("deleteChecksByTeamId", () => { + it("delegates to repository", async () => { + const { service, checksRepository } = createService(); + + const result = await service.deleteChecksByTeamId({ teamId: "team-1" }); + + expect(checksRepository.deleteByTeamId).toHaveBeenCalledWith("team-1"); + expect(result).toBe(10); + }); + }); + + describe("deleteOlderThan", () => { + it("delegates to repository", async () => { + const date = new Date(); + const { service, checksRepository } = createService(); + + const result = await service.deleteOlderThan(date); + + expect(checksRepository.deleteOlderThan).toHaveBeenCalledWith(date); + expect(result).toBe(3); + }); + }); +}); diff --git a/server/test/unit/incidentService.test.ts b/server/test/unit/services/incidentService.test.ts similarity index 97% rename from server/test/unit/incidentService.test.ts rename to server/test/unit/services/incidentService.test.ts index 64512b46c..0a17cbd79 100644 --- a/server/test/unit/incidentService.test.ts +++ b/server/test/unit/services/incidentService.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; -import { IncidentService } from "../../src/service/business/incidentService.ts"; -import { createMockLogger } from "../helpers/createMockLogger.ts"; -import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "../../src/repositories/index.ts"; -import type { INotificationMessageBuilder } from "../../src/service/infrastructure/notificationMessageBuilder.ts"; -import type { Monitor } from "../../src/types/monitor.ts"; -import type { Incident } from "../../src/types/index.ts"; -import type { MonitorActionDecision } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import { IncidentService } from "../../../src/service/business/incidentService.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "../../../src/repositories/index.ts"; +import type { INotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts"; +import type { Monitor } from "../../../src/types/monitor.ts"; +import type { Incident } from "../../../src/types/index.ts"; +import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts similarity index 98% rename from server/test/unit/monitorService.test.ts rename to server/test/unit/services/monitorService.test.ts index 598797bc5..379bb9605 100644 --- a/server/test/unit/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -1,5 +1,5 @@ import { jest } from "@jest/globals"; -import { MonitorService } from "../../src/service/business/monitorService.ts"; +import { MonitorService } from "../../../src/service/business/monitorService.ts"; import type { IChecksRepository, IGeoChecksRepository, @@ -7,7 +7,7 @@ import type { IMonitorStatsRepository, IMonitorsRepository, IStatusPagesRepository, -} from "../../src/repositories/index.ts"; +} from "../../../src/repositories/index.ts"; const createMonitorsRepositoryMock = () => ({ diff --git a/server/test/unit/services/notificationsService.test.ts b/server/test/unit/services/notificationsService.test.ts new file mode 100644 index 000000000..6c2a40214 --- /dev/null +++ b/server/test/unit/services/notificationsService.test.ts @@ -0,0 +1,347 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { NotificationsService } from "../../../src/service/infrastructure/notificationsService.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { Notification, Monitor, MonitorStatusResponse } from "../../../src/types/index.ts"; +import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.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 }), +}); + +const createMessageBuilder = () => ({ + buildMessage: jest.fn().mockReturnValue({ type: "monitor_down", content: { title: "Down" } }), + extractThresholdBreaches: 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 settingsService = createSettingsService(); + const notificationMessageBuilder = createMessageBuilder(); + + const defaults = { + logger, + notificationsRepository, + monitorsRepository, + webhookProvider, + emailProvider, + slackProvider, + discordProvider, + pagerDutyProvider, + matrixProvider, + teamsProvider, + telegramProvider, + settingsService, + notificationMessageBuilder, + ...overrides, + }; + + const service = new NotificationsService( + defaults.notificationsRepository as any, + defaults.monitorsRepository as any, + defaults.webhookProvider as any, + defaults.emailProvider as any, + defaults.slackProvider as any, + defaults.discordProvider as any, + defaults.pagerDutyProvider as any, + defaults.matrixProvider as any, + defaults.teamsProvider as any, + defaults.telegramProvider as any, + defaults.settingsService as any, + defaults.logger as any, + 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 => ({ + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: true, + incidentReason: null, + notificationReason: "status_change", + ...overrides, +}); + +const makeStatusResponse = () => ({ monitorId: "mon-1", status: false, code: 500 }) as unknown as MonitorStatusResponse; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("NotificationsService", () => { + // ── handleNotifications ─────────────────────────────────────────────────── + + describe("handleNotifications", () => { + it("returns false when shouldSendNotification is false", async () => { + const { service } = createService(); + const result = await service.handleNotifications(makeMonitor(), makeStatusResponse(), 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(), makeStatusResponse(), 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"] as const; + for (const type of types) { + const deps = createService(); + (deps.notificationsRepository.findNotificationsByIds as jest.Mock).mockResolvedValue([makeNotification({ type })]); + + await deps.service.handleNotifications(makeMonitor(), makeStatusResponse(), 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, + }; + 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(), makeStatusResponse(), 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(), makeStatusResponse(), 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 }), makeStatusResponse(), 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(), makeStatusResponse(), 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(), makeStatusResponse(), 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"]] 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, + }; + 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); + }); + }); + + // ── 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"); + }); + }); +}); diff --git a/server/test/unit/statusService.test.ts b/server/test/unit/services/statusService.test.ts similarity index 99% rename from server/test/unit/statusService.test.ts rename to server/test/unit/services/statusService.test.ts index ec42536ea..9a0eeb187 100644 --- a/server/test/unit/statusService.test.ts +++ b/server/test/unit/services/statusService.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; -import { StatusService } from "../../src/service/infrastructure/statusService.ts"; -import { createMockLogger } from "../helpers/createMockLogger.ts"; -import type { Monitor, MonitorStatusResponse, Check, HardwareStatusPayload } from "../../src/types/index.ts"; -import type { IMonitorsRepository, IMonitorStatsRepository, IChecksRepository } from "../../src/repositories/index.ts"; -import type { IBufferService } from "../../src/service/infrastructure/bufferService.ts"; +import { StatusService } from "../../../src/service/infrastructure/statusService.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { Monitor, MonitorStatusResponse, Check, HardwareStatusPayload } from "../../../src/types/index.ts"; +import type { IMonitorsRepository, IMonitorStatsRepository, IChecksRepository } from "../../../src/repositories/index.ts"; +import type { IBufferService } from "../../../src/service/infrastructure/bufferService.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/test/unit/superSimpleQueueHelper.test.ts b/server/test/unit/services/superSimpleQueueHelper.test.ts similarity index 95% rename from server/test/unit/superSimpleQueueHelper.test.ts rename to server/test/unit/services/superSimpleQueueHelper.test.ts index 8a24718b6..4805dd7e7 100644 --- a/server/test/unit/superSimpleQueueHelper.test.ts +++ b/server/test/unit/services/superSimpleQueueHelper.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, jest } from "@jest/globals"; -import { SuperSimpleQueueHelper } from "../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; -import type { Monitor } from "../../src/types/monitor.ts"; -import { createMockLogger } from "../helpers/createMockLogger.ts"; +import { SuperSimpleQueueHelper } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; +import type { Monitor } from "../../../src/types/monitor.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; const createHelper = (overrides?: Record) => { const maintenanceWindowsRepository = { From db378120be387b24ccbc6dc91b2ebf31cb7d0865 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 8 Apr 2026 12:03:26 -0700 Subject: [PATCH 04/23] add queue tests --- .../SuperSimpleQueue/SuperSimpleQueue.ts | 10 +- .../unit/services/superSimpleQueue.test.ts | 564 ++++++++++++++++ .../services/superSimpleQueueHelper.test.ts | 621 +++++++++++++++++- 3 files changed, 1162 insertions(+), 33 deletions(-) create mode 100644 server/test/unit/services/superSimpleQueue.test.ts diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index 542e8e38e..8fe98f4c1 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -306,8 +306,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { if (failCount > 0) { acc.jobsWithFailures.push({ monitorId: job.id, - monitorUrl: job?.data?.url || null, - monitorType: job?.data?.type || null, + monitorUrl: job.data?.url || null, + monitorType: job.data?.type || null, failedAt: job.lastFailedAt ?? null, failCount, failReason: job.lastFailReason ?? null, @@ -339,9 +339,9 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { return jobs.map((job) => { return { monitorId: job.id, - monitorUrl: job?.data?.url || null, - monitorType: job?.data?.type || null, - monitorInterval: job?.data?.interval || null, + monitorUrl: job.data?.url || null, + monitorType: job.data?.type || null, + monitorInterval: job.data?.interval || null, active: job.active, lockedAt: job.lockedAt ?? null, runCount: job.runCount ?? 0, diff --git a/server/test/unit/services/superSimpleQueue.test.ts b/server/test/unit/services/superSimpleQueue.test.ts new file mode 100644 index 000000000..574ef6dd0 --- /dev/null +++ b/server/test/unit/services/superSimpleQueue.test.ts @@ -0,0 +1,564 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { Monitor } from "../../../src/types/monitor.ts"; + +// ── Mock Scheduler ─────────────────────────────────────────────────────────── + +const createScheduler = () => { + const listeners: Record = {}; + return { + on: jest.fn((event: string, cb: Function) => { + listeners[event] = listeners[event] || []; + listeners[event].push(cb); + }), + emit: (event: string, ...args: unknown[]) => { + (listeners[event] || []).forEach((cb) => cb(...args)); + }, + start: jest.fn(), + stop: jest.fn().mockResolvedValue(true), + addTemplate: jest.fn(), + addJob: jest.fn(), + removeJob: jest.fn(), + getJob: jest.fn().mockResolvedValue(null), + getJobs: jest.fn().mockResolvedValue([]), + pauseJob: jest.fn().mockResolvedValue(true), + resumeJob: jest.fn().mockResolvedValue(true), + updateJob: jest.fn(), + flushJobs: jest.fn().mockResolvedValue(true), + }; +}; + +const mockSchedulerInstance = createScheduler(); +const MockScheduler = jest.fn().mockReturnValue(mockSchedulerInstance); + +jest.unstable_mockModule("super-simple-scheduler", () => ({ + default: MockScheduler, +})); + +const { SuperSimpleQueue } = await import("../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts"); + +const createQueueHelper = () => ({ + getHeartbeatJob: jest.fn().mockReturnValue(() => Promise.resolve()), + getHeartbeatGeoJob: jest.fn().mockReturnValue(() => Promise.resolve()), + getCleanupOrphanedJob: jest.fn().mockReturnValue(() => Promise.resolve()), + getCleanupRetentionJob: jest.fn().mockReturnValue(() => Promise.resolve()), +}); + +const createMonitorsRepo = () => ({ + findAll: jest.fn().mockResolvedValue([]), +}); + +const createQueue = (overrides?: Record) => { + const logger = createMockLogger(); + const helper = createQueueHelper(); + const monitorsRepository = createMonitorsRepo(); + const scheduler = createScheduler(); + + const defaults = { logger, helper, monitorsRepository, scheduler, ...overrides }; + + const queue = new SuperSimpleQueue(defaults.logger as any, defaults.helper as any, defaults.monitorsRepository as any, defaults.scheduler as any); + return { queue, ...defaults }; +}; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "http", + interval: 60000, + isActive: true, + geoCheckEnabled: false, + geoCheckInterval: 300000, + geoCheckLocations: [], + ...overrides, + }) as Monitor; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("SuperSimpleQueue", () => { + describe("serviceName", () => { + it("returns JobQueue", () => { + const { queue } = createQueue(); + expect(queue.serviceName).toBe("JobQueue"); + }); + }); + + // ── init ───────────────────────────────────────────────────────────────── + + describe("init", () => { + it("registers listeners, starts scheduler, adds templates and system jobs", async () => { + const { queue, scheduler, helper } = createQueue(); + + const result = await queue.init(); + + expect(result).toBe(true); + expect(scheduler.start).toHaveBeenCalled(); + expect(scheduler.addTemplate).toHaveBeenCalledWith("monitor-job", expect.any(Function)); + expect(scheduler.addTemplate).toHaveBeenCalledWith("geo-check-job", expect.any(Function)); + expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-orphaned", expect.any(Function)); + expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-retention-job", expect.any(Function)); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" })); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-retention" })); + }); + + it("schedules existing monitors with staggered offsets", async () => { + jest.useFakeTimers(); + const monitors = [makeMonitor({ id: "m1" }), makeMonitor({ id: "m2" })]; + const { queue, scheduler, monitorsRepository } = createQueue(); + (monitorsRepository.findAll as jest.Mock).mockResolvedValue(monitors); + + await queue.init(); + jest.runAllTimers(); + + // Each monitor gets addJob called (once for the monitor itself via addJob method) + // The init schedules via setTimeout → addJob, plus the two system jobs + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "m1", template: "monitor-job" })); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "m2", template: "monitor-job" })); + jest.useRealTimers(); + }); + + it("returns true when findAll returns null", async () => { + const { queue, monitorsRepository, scheduler } = createQueue(); + (monitorsRepository.findAll as jest.Mock).mockResolvedValue(null); + + const result = await queue.init(); + + expect(result).toBe(true); + // System jobs should not be added when monitors is null (early return) + expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" })); + }); + + it("returns false and logs error on failure", async () => { + const { queue, logger, monitorsRepository } = createQueue(); + (monitorsRepository.findAll as jest.Mock).mockRejectedValue(new Error("db down")); + + const result = await queue.init(); + + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db down") })); + }); + + it("logs error with String(error) for non-Error exceptions", async () => { + const { queue, logger, monitorsRepository } = createQueue(); + (monitorsRepository.findAll as jest.Mock).mockRejectedValue("string error"); + + const result = await queue.init(); + + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("string error") })); + }); + }); + + // ── registerListeners ──────────────────────────────────────────────────── + + describe("registerListeners (via init)", () => { + it("logs on scheduler:start", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("scheduler:start"); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler started" })); + }); + + it("logs on scheduler:stop", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("scheduler:stop"); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler stopped" })); + }); + + it("logs on scheduler:error with Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("scheduler:error", new Error("boom")); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler error: boom" })); + }); + + it("logs on scheduler:error with non-Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("scheduler:error", "string error"); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler error: string error", stack: undefined })); + }); + + it("logs on job:abort", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:abort", { id: "m1" }, "timeout"); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 aborted: timeout" })); + }); + + it("logs on job:attempt", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:attempt", { id: "m1" }, 2); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 attempt 2" })); + }); + + it("logs on job:complete", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:complete", { id: "m1" }); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 completed successfully" })); + }); + + it("logs on job:exhausted with Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:exhausted", { id: "m1" }, new Error("gave up")); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 exhausted all retries: gave up" })); + }); + + it("logs on job:exhausted with non-Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:exhausted", { id: "m1" }, "gave up"); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 exhausted all retries: gave up", stack: undefined })); + }); + + it("logs on job:fail with Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:fail", { id: "m1" }, new Error("oops"), 1); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 failed on attempt 1: oops" })); + }); + + it("logs on job:fail with non-Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:fail", { id: "m1" }, "oops", 1); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 failed on attempt 1: oops", stack: undefined })); + }); + + it("logs on job:start", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:start", { id: "m1" }); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 started" })); + }); + }); + + // ── addJob ─────────────────────────────────────────────────────────────── + + describe("addJob", () => { + it("adds monitor job to scheduler", async () => { + const { queue, scheduler } = createQueue(); + await queue.addJob("mon-1", makeMonitor()); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1", template: "monitor-job", repeat: 60000, active: true })); + }); + + it("adds geo check job when geoCheckEnabled and type supports it", async () => { + const { queue, scheduler } = createQueue(); + await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 })); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job", repeat: 300000 })); + }); + + it("skips geo job when type does not support geo checks", async () => { + const { queue, scheduler } = createQueue(); + await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "hardware" })); + expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo" })); + }); + + it("skips geo job when geoCheckEnabled is false", async () => { + const { queue, scheduler } = createQueue(); + await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: false, type: "http" })); + expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo" })); + }); + }); + + // ── deleteJob ──────────────────────────────────────────────────────────── + + describe("deleteJob", () => { + it("removes monitor job", async () => { + const { queue, scheduler } = createQueue(); + await queue.deleteJob(makeMonitor()); + expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1"); + }); + + it("removes geo job if it exists", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + await queue.deleteJob(makeMonitor()); + expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); + }); + + it("does not remove geo job if it does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.deleteJob(makeMonitor()); + expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); + }); + }); + + // ── pauseJob ───────────────────────────────────────────────────────────── + + describe("pauseJob", () => { + it("pauses monitor and removes geo job if it exists", async () => { + const { queue, scheduler, logger } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + await queue.pauseJob(makeMonitor()); + + expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1"); + expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Paused monitor mon-1" })); + }); + + it("does not remove geo job if it does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.pauseJob(makeMonitor()); + expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); + }); + + it("throws when scheduler.pauseJob returns false", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.pauseJob as jest.Mock).mockResolvedValue(false); + await expect(queue.pauseJob(makeMonitor())).rejects.toThrow("Failed to pause monitor"); + }); + }); + + // ── resumeJob ──────────────────────────────────────────────────────────── + + describe("resumeJob", () => { + it("resumes monitor and geo job", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.resumeJob(makeMonitor()); + + expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1"); + expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1-geo"); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Resumed monitor mon-1" })); + }); + + it("throws when scheduler.resumeJob returns false", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.resumeJob as jest.Mock).mockResolvedValue(false); + await expect(queue.resumeJob(makeMonitor())).rejects.toThrow("Failed to resume monitor"); + }); + }); + + // ── updateJob ──────────────────────────────────────────────────────────── + + describe("updateJob", () => { + it("updates monitor job and syncs geo job", async () => { + const { queue, scheduler } = createQueue(); + const monitor = makeMonitor({ interval: 120000 }); + await queue.updateJob(monitor); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ repeat: 120000, data: monitor })); + }); + }); + + // ── syncGeoJob (via updateJob) ─────────────────────────────────────────── + + describe("syncGeoJob (via updateJob)", () => { + it("removes existing geo job when geoCheckEnabled is false", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + await queue.updateJob(makeMonitor({ geoCheckEnabled: false, type: "http" })); + expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); + }); + + it("removes existing geo job when type does not support geo checks", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + await queue.updateJob(makeMonitor({ geoCheckEnabled: true, type: "hardware" })); + expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); + }); + + it("does not remove when geo disabled and no existing geo job", async () => { + const { queue, scheduler } = createQueue(); + await queue.updateJob(makeMonitor({ geoCheckEnabled: false })); + expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); + }); + + it("updates existing geo job when geoCheckEnabled", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 }); + await queue.updateJob(monitor); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", expect.objectContaining({ repeat: 300000 })); + }); + + it("creates new geo job when enabled but no existing job", async () => { + const { queue, scheduler } = createQueue(); + const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 }); + await queue.updateJob(monitor); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job" })); + }); + }); + + // ── shutdown ────────────────────────────────────────────────────────────── + + describe("shutdown", () => { + it("stops the scheduler", async () => { + const { queue, scheduler } = createQueue(); + await queue.shutdown(); + expect(scheduler.stop).toHaveBeenCalled(); + }); + }); + + // ── getMetrics ─────────────────────────────────────────────────────────── + + describe("getMetrics", () => { + it("returns empty metrics when no jobs", async () => { + const { queue } = createQueue(); + const metrics = await queue.getMetrics(); + expect(metrics).toEqual({ jobs: 0, activeJobs: 0, failingJobs: 0, jobsWithFailures: [], totalRuns: 0, totalFailures: 0 }); + }); + + it("aggregates metrics from jobs", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", runCount: 10, failCount: 2, lastFailedAt: 200, lastRunAt: 100, lockedAt: null, data: { url: "http://a.com", type: "http" } }, + { id: "m2", runCount: 5, failCount: 0, lastFailedAt: 0, lastRunAt: 50, lockedAt: 999, data: { url: "http://b.com", type: "ping" } }, + ]); + const metrics = await queue.getMetrics(); + + expect(metrics.jobs).toBe(2); + expect(metrics.totalRuns).toBe(15); + expect(metrics.totalFailures).toBe(2); + expect(metrics.activeJobs).toBe(1); + expect(metrics.failingJobs).toBe(1); + expect(metrics.jobsWithFailures).toHaveLength(1); + expect(metrics.jobsWithFailures[0]).toEqual(expect.objectContaining({ monitorId: "m1", failCount: 2, monitorUrl: "http://a.com" })); + }); + + it("handles jobs with undefined fields", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", data: null }]); + const metrics = await queue.getMetrics(); + expect(metrics.jobs).toBe(1); + expect(metrics.totalRuns).toBe(0); + }); + + it("handles failing job with null data", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", runCount: 1, failCount: 1, lastFailedAt: 100, lastRunAt: 50, data: null, lastFailReason: "timeout" }, + ]); + const metrics = await queue.getMetrics(); + expect(metrics.jobsWithFailures).toHaveLength(1); + expect(metrics.jobsWithFailures[0].monitorUrl).toBeNull(); + expect(metrics.jobsWithFailures[0].monitorType).toBeNull(); + expect(metrics.jobsWithFailures[0].failReason).toBe("timeout"); + }); + + it("handles failing job with undefined lastFailedAt and lastFailReason", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", runCount: 1, failCount: 1, data: { url: "http://a.com", type: "http" } }]); + const metrics = await queue.getMetrics(); + expect(metrics.jobsWithFailures[0].failedAt).toBeNull(); + expect(metrics.jobsWithFailures[0].failReason).toBeNull(); + }); + + it("does not count as failing when lastFailedAt < lastRunAt", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", runCount: 10, failCount: 1, lastFailedAt: 50, lastRunAt: 100, data: { url: "a" } }, + ]); + const metrics = await queue.getMetrics(); + expect(metrics.failingJobs).toBe(0); + expect(metrics.jobsWithFailures).toHaveLength(1); + }); + }); + + // ── getJobs ────────────────────────────────────────────────────────────── + + describe("getJobs", () => { + it("maps scheduler jobs to summaries", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { + id: "m1", + active: true, + lockedAt: null, + runCount: 5, + failCount: 1, + lastFailReason: "timeout", + lastRunAt: 1000, + lastFinishedAt: 1100, + lastFailedAt: 900, + data: { url: "http://a.com", type: "http", interval: 60000 }, + }, + ]); + const jobs = await queue.getJobs(); + + expect(jobs).toHaveLength(1); + expect(jobs[0]).toEqual({ + monitorId: "m1", + monitorUrl: "http://a.com", + monitorType: "http", + monitorInterval: 60000, + active: true, + lockedAt: null, + runCount: 5, + failCount: 1, + failReason: "timeout", + lastRunAt: 1000, + lastFinishedAt: 1100, + lastRunTook: 100, + lastFailedAt: 900, + }); + }); + + it("returns null lastRunTook when job is locked", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", active: true, lockedAt: 999, runCount: 1, data: { url: "a", type: "http", interval: 60000 } }, + ]); + const jobs = await queue.getJobs(); + expect(jobs[0].lastRunTook).toBeNull(); + }); + + it("handles jobs with missing data", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", active: true, data: null }]); + const jobs = await queue.getJobs(); + expect(jobs[0].monitorUrl).toBeNull(); + expect(jobs[0].monitorType).toBeNull(); + expect(jobs[0].monitorInterval).toBeNull(); + }); + }); + + // ── flushQueues ────────────────────────────────────────────────────────── + + describe("flushQueues", () => { + it("stops, flushes, and reinitializes", async () => { + const { queue, scheduler } = createQueue(); + const result = await queue.flushQueues(); + expect(scheduler.stop).toHaveBeenCalled(); + expect(scheduler.flushJobs).toHaveBeenCalled(); + expect(result.success).toBe(true); + }); + + it("returns false when any step fails", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.flushJobs as jest.Mock).mockResolvedValue(false); + const result = await queue.flushQueues(); + expect(result.success).toBe(false); + }); + }); + + // ── obliterate ─────────────────────────────────────────────────────────── + + describe("obliterate", () => { + it("logs not-implemented warning", async () => { + const { queue, logger } = createQueue(); + await queue.obliterate(); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "obliterate method not implemented" })); + }); + }); + + // ── static create ──────────────────────────────────────────────────────── + + describe("static create", () => { + it("creates a SuperSimpleQueue instance with a Scheduler and calls init", async () => { + const logger = createMockLogger(); + const helper = createQueueHelper(); + const monitorsRepository = createMonitorsRepo(); + + const instance = await SuperSimpleQueue.create(logger as any, helper as any, monitorsRepository as any); + + expect(MockScheduler).toHaveBeenCalled(); + expect(instance).toBeInstanceOf(SuperSimpleQueue); + // init was called, which calls scheduler.start + expect(mockSchedulerInstance.start).toHaveBeenCalled(); + }); + }); +}); diff --git a/server/test/unit/services/superSimpleQueueHelper.test.ts b/server/test/unit/services/superSimpleQueueHelper.test.ts index 4805dd7e7..4d8ecfbc0 100644 --- a/server/test/unit/services/superSimpleQueueHelper.test.ts +++ b/server/test/unit/services/superSimpleQueueHelper.test.ts @@ -3,6 +3,8 @@ import { SuperSimpleQueueHelper } from "../../../src/service/infrastructure/Supe import type { Monitor } from "../../../src/types/monitor.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; +// ── Helpers ────────────────────────────────────────────────────────────────── + const createHelper = (overrides?: Record) => { const maintenanceWindowsRepository = { findByMonitorId: jest.fn().mockResolvedValue([]), @@ -42,7 +44,7 @@ const createHelper = (overrides?: Record) => { networkService: { requestStatus: jest.fn() }, statusService: statusServiceMock, notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) }, - checkService: { buildCheck: jest.fn().mockReturnValue({}), deleteOlderThan: jest.fn().mockResolvedValue(0) }, + checkService: { buildCheck: jest.fn().mockReturnValue({ id: "check-1" }), deleteOlderThan: jest.fn().mockResolvedValue(0) }, settingsService: settingsServiceMock, buffer: { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn() }, incidentService: { handleIncident: jest.fn().mockResolvedValue(undefined) }, @@ -75,47 +77,415 @@ const createHelper = (overrides?: Record) => { defaults.geoChecksService as any, defaults.geoChecksRepository as any ); - return { helper, maintenanceWindowsRepository, defaults }; + return { helper, defaults, maintenanceWindowsRepository }; }; +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "m1", + teamId: "team", + type: "http", + interval: 60000, + status: "up", + geoCheckEnabled: false, + geoCheckLocations: [], + ...overrides, + }) as Monitor; + +// ── Tests ──────────────────────────────────────────────────────────────────── + describe("SuperSimpleQueueHelper", () => { - describe("getHeartbeatJob", () => { - it("skips execution when monitor is in maintenance window", async () => { + describe("serviceName", () => { + it("returns JobQueueHelper", () => { const { helper } = createHelper(); - const spy = jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); - const job = helper.getHeartbeatJob(); - await job({ id: "m1", teamId: "team", interval: 60000 } as Monitor); - expect(helper["networkService"].requestStatus).not.toHaveBeenCalled(); - expect(helper["logger"].debug).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining("Monitor m1 is in maintenance window") }) - ); - spy.mockRestore(); + expect(helper.serviceName).toBe("JobQueueHelper"); }); + }); - it("processes monitor status and notifications when active", async () => { - const networkResponse = { monitor: { id: "m1" }, status: true, code: 200, message: "OK" }; - const statusServiceMock = { - updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }), - }; - const { helper } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - statusService: statusServiceMock, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - const monitor = { id: "m1", teamId: "team" } as Monitor; - await job(monitor); - expect(helper["networkService"].requestStatus).toHaveBeenCalledWith(monitor); - }); + // ── getHeartbeatJob ────────────────────────────────────────────────────── + describe("getHeartbeatJob", () => { it("throws when monitor id is missing", async () => { const { helper } = createHelper(); const job = helper.getHeartbeatJob(); await expect(job({} as Monitor)).rejects.toThrow("No monitor id"); - expect(helper["logger"].warn).toHaveBeenCalled(); + }); + + it("skips execution and sets maintenance status when in maintenance window", async () => { + const { helper, defaults } = createHelper(); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor({ status: "up" })); + + expect(defaults.networkService.requestStatus).not.toHaveBeenCalled(); + expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance" }); + }); + + it("skips monitor status update when already in maintenance", async () => { + const { helper, defaults } = createHelper(); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor({ status: "maintenance" })); + + expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled(); + }); + + it("throws when network response is null", async () => { + const { helper } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(null) }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await expect(job(makeMonitor())).rejects.toThrow("No network response"); + }); + + it("returns early and warns when buildCheck returns null", async () => { + const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; + const { helper, defaults } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + checkService: { buildCheck: jest.fn().mockReturnValue(null), deleteOlderThan: jest.fn() }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor()); + + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") })); + expect(defaults.statusService.updateMonitorStatus).not.toHaveBeenCalled(); + }); + + it("processes full pipeline: network → check → status → incident", async () => { + const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; + const { helper, defaults } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor()); + + expect(defaults.buffer.addToBuffer).toHaveBeenCalled(); + expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalled(); + expect(defaults.incidentService.handleIncident).toHaveBeenCalled(); + }); + + it("sends notifications when decision says shouldSendNotification", async () => { + const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" }; + const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 }; + const { helper, defaults } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor()); + + expect(defaults.notificationsService.handleNotifications).toHaveBeenCalledWith( + statusResult.monitor, + networkResponse, + expect.objectContaining({ shouldSendNotification: true, shouldCreateIncident: true }) + ); + }); + + it("logs error when notifications fail (fire-and-forget)", async () => { + const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" }; + const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 }; + const notificationsService = { handleNotifications: jest.fn().mockRejectedValue(new Error("smtp down")) }; + const { helper, defaults } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) }, + notificationsService, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor()); + + // Wait for the fire-and-forget catch to execute + await new Promise((r) => setTimeout(r, 10)); + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("smtp down") })); + }); + + it("logs error when notifications fail with non-Error (fire-and-forget)", async () => { + const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" }; + const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 }; + const notificationsService = { handleNotifications: jest.fn().mockRejectedValue("string error") }; + const { helper, defaults } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) }, + notificationsService, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor()); + await new Promise((r) => setTimeout(r, 10)); + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") })); + }); + + it("logs warning when incident handling fails (fire-and-forget)", async () => { + const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; + const incidentService = { handleIncident: jest.fn().mockRejectedValue(new Error("db error")) }; + const { helper, defaults } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + incidentService, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor()); + await new Promise((r) => setTimeout(r, 10)); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db error") })); + }); + + it("logs warning when incident handling fails with non-Error", async () => { + const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; + const incidentService = { handleIncident: jest.fn().mockRejectedValue(42) }; + const { helper, defaults } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + incidentService, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor()); + await new Promise((r) => setTimeout(r, 10)); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") })); + }); + + it("logs and rethrows on unexpected error with non-Error", async () => { + const { helper } = createHelper({ + networkService: { requestStatus: jest.fn().mockRejectedValue("unexpected") }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + + await expect(job(makeMonitor())).rejects.toBe("unexpected"); }); }); + // ── getHeartbeatGeoJob ─────────────────────────────────────────────────── + + describe("getHeartbeatGeoJob", () => { + it("throws nothing when monitor id is missing (logs error)", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getHeartbeatGeoJob(); + + await job({} as Monitor); + + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "No monitor id" })); + }); + + it("returns early when geoCheckEnabled is false", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: false })); + + expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); + }); + + it("returns early when monitor type does not support geo checks", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "hardware", geoCheckLocations: ["us-east"] })); + + expect(defaults.logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("does not support geo checks") }) + ); + expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); + }); + + it("returns early when geoCheckLocations is empty", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: [] })); + + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") })); + }); + + it("returns early when geoCheckLocations is undefined", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: undefined as any })); + + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") })); + }); + + it("skips when in maintenance window", async () => { + const { helper, defaults } = createHelper(); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); + + expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("maintenance window") })); + expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled(); + }); + + it("warns when buildGeoCheck returns null", async () => { + const { helper, defaults } = createHelper(); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); + + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check could be built") })); + }); + + it("adds geo check to buffer on success", async () => { + const geoCheck = { id: "gc-1", monitorId: "m1" }; + const { helper, defaults } = createHelper({ + geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(geoCheck) }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); + + expect(defaults.buffer.addGeoCheckToBuffer).toHaveBeenCalledWith(geoCheck); + expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Geo check job executed") })); + }); + + it("logs error on unexpected failure without rethrowing", async () => { + const { helper, defaults } = createHelper({ + geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue(new Error("api timeout")) }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); + + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "api timeout" })); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { helper, defaults } = createHelper({ + geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue("string error") }, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatGeoJob(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); + + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); + + // ── getCleanupOrphanedJob ──────────────────────────────────────────────── + + describe("getCleanupOrphanedJob", () => { + it("cleans up orphaned data across all repositories", async () => { + const { helper, defaults } = createHelper(); + (defaults.monitorsRepository.deleteByTeamIdsNotIn as jest.Mock).mockResolvedValue(2); + (defaults.monitorStatsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(3); + (defaults.checksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(4); + (defaults.incidentsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(1); + (defaults.geoChecksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(5); + + const job = helper.getCleanupOrphanedJob(); + await job(); + + expect(defaults.teamsRepository.findAllTeamIds).toHaveBeenCalled(); + expect(defaults.monitorsRepository.deleteByTeamIdsNotIn).toHaveBeenCalledWith(["team"]); + expect(defaults.monitorsRepository.findAllMonitorIds).toHaveBeenCalled(); + expect(defaults.monitorStatsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + expect(defaults.checksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + expect(defaults.incidentsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + expect(defaults.geoChecksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + // Logs info for each deleted count > 0 + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("2 orphaned monitors") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("3 orphaned monitor stats") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("4 orphaned checks") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 orphaned incidents") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("5 orphaned geo checks") })); + }); + + it("skips info logs when no orphaned data is found", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getCleanupOrphanedJob(); + await job(); + + // Only start and completion logs, no deletion logs + const infoMessages = (defaults.logger.info as jest.Mock).mock.calls.map((c: any) => c[0].message); + expect(infoMessages).toContain("Starting cleanup of orphaned data"); + expect(infoMessages).toContain("Cleanup of orphaned data completed"); + expect(infoMessages).not.toContain(expect.stringContaining("orphaned monitors")); + }); + + it("logs warning and rethrows on error", async () => { + const { helper, defaults } = createHelper(); + (defaults.teamsRepository.findAllTeamIds as jest.Mock).mockRejectedValue(new Error("db down")); + const job = helper.getCleanupOrphanedJob(); + + await expect(job()).rejects.toThrow("db down"); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "db down" })); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { helper, defaults } = createHelper(); + (defaults.teamsRepository.findAllTeamIds as jest.Mock).mockRejectedValue(null); + const job = helper.getCleanupOrphanedJob(); + + await expect(job()).rejects.toBeNull(); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); + + // ── getCleanupRetentionJob ─────────────────────────────────────────────── + + describe("getCleanupRetentionJob", () => { + it("deletes checks older than TTL cutoff", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getCleanupRetentionJob(); + await job(); + + expect(defaults.checkService.deleteOlderThan).toHaveBeenCalledWith(expect.any(Date)); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Deleted") })); + }); + + it("skips cleanup when checkTTL is sentinel value (366)", async () => { + const { helper, defaults } = createHelper({ + settingsService: { getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 366 }) }, + }); + const job = helper.getCleanupRetentionJob(); + await job(); + + expect(defaults.checkService.deleteOlderThan).not.toHaveBeenCalled(); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("unlimited") })); + }); + + it("logs error on failure without rethrowing", async () => { + const { helper, defaults } = createHelper({ + settingsService: { getDBSettings: jest.fn().mockRejectedValue(new Error("db error")) }, + }); + const job = helper.getCleanupRetentionJob(); + + await job(); // Should not throw + + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "db error" })); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { helper, defaults } = createHelper({ + settingsService: { getDBSettings: jest.fn().mockRejectedValue("boom") }, + }); + const job = helper.getCleanupRetentionJob(); + await job(); + + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); + + // ── isInMaintenanceWindow ──────────────────────────────────────────────── + describe("isInMaintenanceWindow", () => { it("returns true when an active window spans now", async () => { const now = new Date(); @@ -149,5 +519,200 @@ describe("SuperSimpleQueueHelper", () => { const { helper } = createHelper(); await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); }); + + it("returns false when window is inactive", async () => { + const now = new Date(); + const { helper, maintenanceWindowsRepository } = createHelper(); + maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ + { + active: false, + start: new Date(now.getTime() - 1000).toISOString(), + end: new Date(now.getTime() + 1000).toISOString(), + repeat: 0, + }, + ]); + await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); + }); + + it("returns false when active window is in the past without repeat", async () => { + const now = Date.now(); + const { helper, maintenanceWindowsRepository } = createHelper(); + maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ + { + active: true, + start: new Date(now - 5000).toISOString(), + end: new Date(now - 1000).toISOString(), + repeat: 0, + }, + ]); + await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); + }); + + it("returns false when repeat advances past current time without match", async () => { + const now = Date.now(); + const { helper, maintenanceWindowsRepository } = createHelper(); + // Window was 2 hours ago, lasts 10 minutes, repeats every hour + // First repeat: -1h to -50min (past), second repeat: now to +10min... actually this would match + // Use a repeat that skips over now + maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ + { + active: true, + start: new Date(now - 3600000 * 3 - 300000).toISOString(), // 3h5min ago + end: new Date(now - 3600000 * 3).toISOString(), // 3h ago, 5min window + repeat: 3600000, // 1 hour repeat + }, + ]); + // Advances: -2h5m to -2h, -1h5m to -1h, -5m to 0 — that last one might match if now is within 5min + // Let's use a different setup that clearly doesn't match + maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ + { + active: true, + start: new Date(now - 1800000 - 60000).toISOString(), // 31 min ago + end: new Date(now - 1800000).toISOString(), // 30 min ago, 1 min window + repeat: 3600000, // 1 hour repeat — next window would be in 29 min + }, + ]); + await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false); + }); + + it("preserves previous true result via accumulator", async () => { + const now = new Date(); + const { helper, maintenanceWindowsRepository } = createHelper(); + maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([ + { + active: true, + start: new Date(now.getTime() - 1000).toISOString(), + end: new Date(now.getTime() + 1000).toISOString(), + repeat: 0, + }, + { + active: false, + start: new Date(now.getTime() - 1000).toISOString(), + end: new Date(now.getTime() + 1000).toISOString(), + repeat: 0, + }, + ]); + // Second window is inactive but first already matched — accumulator should preserve true + await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true); + }); + }); + + // ── evaluateMonitorAction (tested indirectly via getHeartbeatJob) ───────── + + describe("evaluateMonitorAction", () => { + const runJobAndGetDecision = async (statusResult: Record) => { + const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" }; + const incidentService = { handleIncident: jest.fn().mockResolvedValue(undefined) }; + const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(undefined) }; + const { helper } = createHelper({ + networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, + statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) }, + incidentService, + notificationsService, + }); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); + const job = helper.getHeartbeatJob(); + await job(makeMonitor()); + return { incidentService, notificationsService }; + }; + + it("does nothing when statusChanged is false", async () => { + const { incidentService, notificationsService } = await runJobAndGetDecision({ + monitor: { id: "m1", status: "up" }, + statusChanged: false, + prevStatus: "up", + code: 200, + }); + expect(incidentService.handleIncident).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }), + expect.anything() + ); + }); + + it("creates incident and notifies when monitor goes down", async () => { + const { incidentService } = await runJobAndGetDecision({ + monitor: { id: "m1", status: "down" }, + statusChanged: true, + prevStatus: "up", + code: 500, + }); + expect(incidentService.handleIncident).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + shouldCreateIncident: true, + shouldSendNotification: true, + incidentReason: "status_down", + notificationReason: "status_change", + }), + expect.anything() + ); + }); + + it("creates incident and notifies when monitor is breached", async () => { + const { incidentService } = await runJobAndGetDecision({ + monitor: { id: "m1", status: "breached" }, + statusChanged: true, + prevStatus: "up", + code: 200, + }); + expect(incidentService.handleIncident).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + shouldCreateIncident: true, + incidentReason: "threshold_breach", + notificationReason: "threshold_breach", + }), + expect.anything() + ); + }); + + it("resolves incident when monitor recovers from down", async () => { + const { incidentService } = await runJobAndGetDecision({ + monitor: { id: "m1", status: "up" }, + statusChanged: true, + prevStatus: "down", + code: 200, + }); + expect(incidentService.handleIncident).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ shouldResolveIncident: true, shouldSendNotification: true }), + expect.anything() + ); + }); + + it("resolves incident when monitor recovers from breached", async () => { + const { incidentService } = await runJobAndGetDecision({ + monitor: { id: "m1", status: "up" }, + statusChanged: true, + prevStatus: "breached", + code: 200, + }); + expect(incidentService.handleIncident).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ shouldResolveIncident: true }), + expect.anything() + ); + }); + + it("does not create or resolve for unhandled status transitions", async () => { + const { incidentService } = await runJobAndGetDecision({ + monitor: { id: "m1", status: "paused" }, + statusChanged: true, + prevStatus: "up", + code: 200, + }); + expect(incidentService.handleIncident).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }), + expect.anything() + ); + }); }); }); From a8c172c77f52a61991470f78a729a2a532c93d6f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 9 Apr 2026 09:27:38 -0700 Subject: [PATCH 05/23] add tests for network service --- .../service/infrastructure/networkService.ts | 2 +- .../test/unit/services/networkService.test.ts | 482 ++++++++++++++++++ 2 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 server/test/unit/services/networkService.test.ts diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index 9111ef7b2..ca85d12ac 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -118,7 +118,7 @@ export class NetworkService implements INetworkService { }, }); - if (response?.data?.status !== "success") return false; + if (response.data?.status !== "success") return false; return true; } catch (err: unknown) { const originalMessage = err instanceof Error ? err.message : String(err); diff --git a/server/test/unit/services/networkService.test.ts b/server/test/unit/services/networkService.test.ts new file mode 100644 index 000000000..c9f496166 --- /dev/null +++ b/server/test/unit/services/networkService.test.ts @@ -0,0 +1,482 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { NetworkService } from "../../../src/service/infrastructure/networkService.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import { NETWORK_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; +import type { IStatusProvider } from "../../../src/service/infrastructure/network/IStatusProvider.ts"; +import type { Monitor, MonitorType } from "../../../src/types/index.ts"; +import type { AxiosStatic } from "axios"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createMockAxios = () => + ({ + post: jest.fn(), + }) as unknown as jest.Mocked; + +const createMockProvider = (type: string, result?: unknown) => { + const provider: jest.Mocked> = { + type, + supports: jest.fn((t: MonitorType) => t === type) as any, + handle: jest.fn().mockResolvedValue( + result ?? { + monitorId: "mon-1", + teamId: "team-1", + type, + status: true, + code: 200, + message: "OK", + } + ), + }; + return provider; +}; + +const createService = (providers: IStatusProvider[] = []) => { + const logger = createMockLogger(); + const axios = createMockAxios(); + const service = new NetworkService(axios, logger as any, providers); + return { service, logger, axios }; +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("NetworkService", () => { + // ── Static / instance properties ───────────────────────────────────────── + + describe("serviceName", () => { + it("returns NetworkService from static property", () => { + expect(NetworkService.SERVICE_NAME).toBe("NetworkService"); + }); + + it("returns NetworkService from instance getter", () => { + const { service } = createService(); + expect(service.serviceName).toBe("NetworkService"); + }); + }); + + // ── requestStatus ──────────────────────────────────────────────────────── + + describe("requestStatus", () => { + it("delegates to the matching provider", async () => { + const provider = createMockProvider("http"); + const { service } = createService([provider]); + const monitor = { type: "http", _id: "mon-1" } as unknown as Monitor & { type: "http" }; + + const result = await service.requestStatus(monitor); + + expect(provider.supports).toHaveBeenCalledWith("http"); + expect(provider.handle).toHaveBeenCalledWith(monitor); + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + status: true, + code: 200, + }) + ); + }); + + it("selects the correct provider when multiple are registered", async () => { + const httpProvider = createMockProvider("http"); + const pingProvider = createMockProvider("ping"); + const { service } = createService([httpProvider, pingProvider]); + const monitor = { type: "ping", _id: "mon-2" } as unknown as Monitor & { type: "ping" }; + + await service.requestStatus(monitor); + + expect(httpProvider.handle).not.toHaveBeenCalled(); + expect(pingProvider.handle).toHaveBeenCalledWith(monitor); + }); + + it("returns unsupported-type response when no provider matches", async () => { + const { service } = createService([]); + const monitor = { type: "unknown_type" as any, _id: "mon-1" } as unknown as Monitor & { type: any }; + + const result = await service.requestStatus(monitor); + + expect(result).toEqual({ + monitorId: "unknown", + teamId: "unknown", + type: "unknown", + status: false, + code: NETWORK_ERROR, + message: "Unsupported type: unknown_type", + }); + }); + }); + + // ── requestWebhook ─────────────────────────────────────────────────────── + + describe("requestWebhook", () => { + it("sends a POST and returns success response", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockResolvedValue({ status: 200, data: { ok: true } }); + + const result = await service.requestWebhook("slack", "https://hooks.example.com", { text: "hello" }); + + expect(axios.post).toHaveBeenCalledWith("https://hooks.example.com", { text: "hello" }, { headers: { "Content-Type": "application/json" } }); + expect(result).toEqual({ + type: "webhook", + status: true, + code: 200, + message: "Successfully sent slack notification", + payload: { ok: true }, + }); + }); + + it("returns failure with response data on axios error with response", async () => { + const { service, axios, logger } = createService(); + const axiosError = Object.assign(new Error("Request failed"), { + response: { status: 403, data: { error: "forbidden" } }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestWebhook("discord", "https://hooks.example.com", {}); + + expect(result).toEqual({ + type: "webhook", + status: false, + code: 403, + message: "Failed to send discord notification", + payload: { error: "forbidden" }, + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Request failed", + service: "NetworkService", + method: "requestWebhook", + }) + ); + }); + + it("returns failure with NETWORK_ERROR when axios error has no response status", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("timeout"), { + response: { status: undefined, data: undefined }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestWebhook("slack", "https://hooks.example.com", {}); + + expect(result.code).toBe(NETWORK_ERROR); + }); + + it("returns failure with NETWORK_ERROR on non-axios error (no response property)", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(new Error("DNS failure")); + + const result = await service.requestWebhook("slack", "https://hooks.example.com", {}); + + expect(result).toEqual({ + type: "webhook", + status: false, + code: NETWORK_ERROR, + message: "Failed to send slack notification", + }); + }); + + it("handles non-Error thrown values", async () => { + const { service, axios, logger } = createService(); + (axios.post as jest.Mock).mockRejectedValue("string error"); + + const result = await service.requestWebhook("slack", "https://hooks.example.com", {}); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "string error" })); + expect(result).toEqual({ + type: "webhook", + status: false, + code: NETWORK_ERROR, + message: "Failed to send slack notification", + }); + }); + + it("returns NETWORK_ERROR when error has response key but response is undefined", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("fail"), { response: undefined }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestWebhook("slack", "https://hooks.example.com", {}); + + expect(result.code).toBe(NETWORK_ERROR); + expect(result.payload).toBeUndefined(); + }); + }); + + // ── requestPagerDuty ───────────────────────────────────────────────────── + + describe("requestPagerDuty", () => { + const args = { + message: "Server down", + routingKey: "routing-key-123", + monitorUrl: "https://monitor.example.com", + }; + + it("sends correct payload and returns true on success", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockResolvedValue({ data: { status: "success" } }); + + const result = await service.requestPagerDuty(args); + + expect(result).toBe(true); + expect(axios.post).toHaveBeenCalledWith( + "https://events.pagerduty.com/v2/enqueue", + expect.objectContaining({ + routing_key: "routing-key-123", + event_action: "trigger", + payload: expect.objectContaining({ + summary: "Server down", + severity: "critical", + source: "https://monitor.example.com", + }), + }) + ); + }); + + it("returns false when response status is not success", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockResolvedValue({ data: { status: "invalid event" } }); + + const result = await service.requestPagerDuty(args); + + expect(result).toBe(false); + }); + + it("returns false when response data is undefined", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockResolvedValue({ data: undefined }); + + const result = await service.requestPagerDuty(args); + + expect(result).toBe(false); + }); + + it("throws AppError on axios failure with response data", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("Request failed"), { + response: { data: { message: "invalid routing key" } }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + await expect(service.requestPagerDuty(args)).rejects.toThrow("Request failed"); + }); + + it("throws AppError on network error without response", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(new Error("ECONNREFUSED")); + + await expect(service.requestPagerDuty(args)).rejects.toThrow("ECONNREFUSED"); + }); + + it("throws AppError with default message for non-Error thrown value", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(null); + + await expect(service.requestPagerDuty(args)).rejects.toThrow("null"); + }); + + it("uses fallback message when Error has empty string message", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(new Error("")); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.message).toBe("Error sending PagerDuty notification"); + } + }); + + it("includes responseData when non-Error object has response property", async () => { + const { service, axios } = createService(); + const errorObj = { response: { data: { detail: "bad key" } } }; + (axios.post as jest.Mock).mockRejectedValue(errorObj); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.details).toEqual({ responseData: { detail: "bad key" } }); + } + }); + + it("sets responseData to undefined when error is a primitive (non-object)", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(42); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.details).toEqual({ responseData: undefined }); + } + }); + + it("sets responseData to undefined when object has response key set to undefined", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue({ response: undefined }); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.details).toEqual({ responseData: undefined }); + } + }); + + it("includes responseData in AppError details when available", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("Bad request"), { + response: { data: { errors: ["invalid key"] } }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.details).toEqual({ + responseData: { errors: ["invalid key"] }, + }); + } + }); + + it("sets responseData to undefined in AppError when error has no response", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(new Error("timeout")); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.details).toEqual({ responseData: undefined }); + } + }); + }); + + // ── requestMatrix ──────────────────────────────────────────────────────── + + describe("requestMatrix", () => { + const args = { + homeserverUrl: "https://matrix.example.com", + accessToken: "token-abc", + roomId: "!room:example.com", + message: "Alert", + }; + + it("sends correct payload and returns success response", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockResolvedValue({ status: 200, data: { event_id: "$abc" } }); + + const result = await service.requestMatrix(args); + + expect(result).toEqual({ + status: true, + code: 200, + message: "Successfully sent Matrix notification", + }); + expect(axios.post).toHaveBeenCalledWith( + "https://matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message?access_token=token-abc", + { + msgtype: "m.text", + body: "Alert", + format: "org.matrix.custom.html", + formatted_body: "Alert", + }, + { headers: { "Content-Type": "application/json" } } + ); + }); + + it("returns failure with response data on axios error with response (Error instance)", async () => { + const { service, axios, logger } = createService(); + const axiosError = Object.assign(new Error("Forbidden"), { + response: { status: 403, data: { errcode: "M_FORBIDDEN" } }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestMatrix(args); + + expect(result).toEqual({ + status: false, + code: 403, + message: "Failed to send Matrix notification", + payload: { errcode: "M_FORBIDDEN" }, + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Forbidden", + service: "NetworkService", + method: "requestMatrix", + }) + ); + }); + + it("returns NETWORK_ERROR when axios error response has no status", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("timeout"), { + response: { status: 0, data: undefined }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestMatrix(args); + + expect(result.code).toBe(NETWORK_ERROR); + }); + + it("returns failure with NETWORK_ERROR on Error without response property", async () => { + const { service, axios, logger } = createService(); + (axios.post as jest.Mock).mockRejectedValue(new Error("DNS failure")); + + const result = await service.requestMatrix(args); + + expect(result).toEqual({ + status: false, + code: NETWORK_ERROR, + message: "Failed to send Matrix notification", + }); + // First warn call is from the Error instanceof branch + expect(logger.warn).toHaveBeenCalledTimes(2); + }); + + it("returns failure with NETWORK_ERROR on non-Error thrown value", async () => { + const { service, axios, logger } = createService(); + (axios.post as jest.Mock).mockRejectedValue("string error"); + + const result = await service.requestMatrix(args); + + expect(result).toEqual({ + status: false, + code: NETWORK_ERROR, + message: "Failed to send Matrix notification", + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "string error", + service: "NetworkService", + method: "requestMatrix", + }) + ); + }); + + it("returns NETWORK_ERROR when Error has response key but response is undefined", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("fail"), { response: undefined }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestMatrix(args); + + expect(result.code).toBe(NETWORK_ERROR); + }); + + it("returns NETWORK_ERROR when Error response has undefined status", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("fail"), { + response: { status: undefined, data: { error: "unknown" } }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestMatrix(args); + + expect(result.code).toBe(NETWORK_ERROR); + expect(result.payload).toEqual({ error: "unknown" }); + }); + }); +}); From 88e0660762e9d746c63789990da0741c07eb2772 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 9 Apr 2026 09:36:51 -0700 Subject: [PATCH 06/23] add tests for buffer service --- server/package.json | 2 +- .../service/infrastructure/bufferService.ts | 4 +- .../test/unit/services/bufferService.test.ts | 573 ++++++++++++++++++ 3 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 server/test/unit/services/bufferService.test.ts diff --git a/server/package.json b/server/package.json index 42b1783e1..44643d40e 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/business/*.ts' --collectCoverageFrom='src/service/infrastructure/statusService.ts' --collectCoverageFrom='src/service/infrastructure/notificationsService.ts' --collectCoverageFrom='src/service/infrastructure/SuperSimpleQueue/*.ts' test/unit/services", + "test:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services", "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/bufferService.ts b/server/src/service/infrastructure/bufferService.ts index 229c5e7d4..31ff5b7aa 100755 --- a/server/src/service/infrastructure/bufferService.ts +++ b/server/src/service/infrastructure/bufferService.ts @@ -83,8 +83,8 @@ export class BufferService implements IBufferService { return check.id.toString() === checkToRemove.id.toString(); } return ( - check.metadata.monitorId?.toString() === checkToRemove.metadata.monitorId && - check.metadata.teamId?.toString() === checkToRemove.metadata.teamId && + check.metadata.monitorId.toString() === checkToRemove.metadata.monitorId && + check.metadata.teamId.toString() === checkToRemove.metadata.teamId && check.metadata.type === checkToRemove.metadata.type && check.status === checkToRemove.status && check.statusCode === checkToRemove.statusCode && diff --git a/server/test/unit/services/bufferService.test.ts b/server/test/unit/services/bufferService.test.ts new file mode 100644 index 000000000..6bc3dfc23 --- /dev/null +++ b/server/test/unit/services/bufferService.test.ts @@ -0,0 +1,573 @@ +import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; +import { BufferService } from "../../../src/service/infrastructure/bufferService.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { ICheckService } from "../../../src/service/business/checkService.ts"; +import type { IGeoChecksService } from "../../../src/service/business/geoChecksService.ts"; +import type { ISettingsService } from "../../../src/service/system/settingsService.ts"; +import type { Check, GeoCheck } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createMockCheckService = () => + ({ + createChecks: jest.fn().mockResolvedValue([]), + }) as unknown as jest.Mocked; + +const createMockGeoChecksService = () => + ({ + createGeoChecks: jest.fn().mockResolvedValue([]), + }) as unknown as jest.Mocked; + +const createMockSettingsService = (nodeEnv: string = "development") => + ({ + getSettings: jest.fn().mockReturnValue({ nodeEnv }), + }) as unknown as jest.Mocked; + +const makeCheck = (overrides?: Partial): Check => + ({ + id: "check-1", + metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" }, + status: true, + statusCode: 200, + responseTime: 100, + message: "OK", + ...overrides, + }) as Check; + +const makeGeoCheck = (overrides?: Partial): GeoCheck => + ({ + id: "geo-1", + monitorId: "mon-1", + ...overrides, + }) as GeoCheck; + +const createService = (nodeEnv: string = "development") => { + const logger = createMockLogger(); + const checkService = createMockCheckService(); + const geoChecksService = createMockGeoChecksService(); + const settingsService = createMockSettingsService(nodeEnv); + const service = new BufferService(logger as any, checkService, geoChecksService, settingsService); + return { service, logger, checkService, geoChecksService }; +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("BufferService", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // ── Static / instance properties ───────────────────────────────────── + + describe("serviceName", () => { + it("returns BufferService from static property", () => { + expect(BufferService.SERVICE_NAME).toBe("BufferService"); + }); + + it("returns BufferService from instance getter", () => { + const { service } = createService(); + expect(service.serviceName).toBe("BufferService"); + }); + }); + + // ── constructor ────────────────────────────────────────────────────── + + describe("constructor", () => { + it("logs initialization with development timeout", () => { + const { logger } = createService("development"); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("0.01s"), + service: "BufferService", + method: "constructor", + }) + ); + }); + + it("uses 60s timeout in non-development environment", () => { + const { logger } = createService("production"); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("60s"), + }) + ); + }); + + it("schedules a flush on construction", () => { + createService(); + expect(jest.getTimerCount()).toBeGreaterThanOrEqual(1); + }); + }); + + // ── addToBuffer ────────────────────────────────────────────────────── + + describe("addToBuffer", () => { + it("adds a check to the buffer", async () => { + const { service, checkService } = createService(); + const check = makeCheck(); + + service.addToBuffer(check); + await service.flushBuffer(); + + expect(checkService.createChecks).toHaveBeenCalledWith([check]); + }); + + it("adds multiple checks to the buffer", async () => { + const { service, checkService } = createService(); + const check1 = makeCheck({ id: "c1" }); + const check2 = makeCheck({ id: "c2" }); + + service.addToBuffer(check1); + service.addToBuffer(check2); + await service.flushBuffer(); + + expect(checkService.createChecks).toHaveBeenCalledWith([check1, check2]); + }); + + it("logs error if push throws", () => { + const { service, logger } = createService(); + // Force buffer.push to throw by making buffer non-extensible + Object.defineProperty(service, "buffer", { value: Object.freeze([]) }); + + service.addToBuffer(makeCheck()); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + service: "BufferService", + method: "addToBuffer", + }) + ); + }); + + it("logs 'Unknown error' for non-Error thrown values", () => { + const { service, logger } = createService(); + Object.defineProperty(service, "buffer", { + value: { + push: () => { + throw "string error"; + }, + }, + }); + + service.addToBuffer(makeCheck()); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + stack: undefined, + }) + ); + }); + }); + + // ── addGeoCheckToBuffer ────────────────────────────────────────────── + + describe("addGeoCheckToBuffer", () => { + it("adds a geo check to the buffer", async () => { + const { service, geoChecksService } = createService(); + const geoCheck = makeGeoCheck(); + + service.addGeoCheckToBuffer(geoCheck); + await service.flushGeoBuffer(); + + expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]); + }); + + it("logs error if push throws", () => { + const { service, logger } = createService(); + Object.defineProperty(service, "geoBuffer", { value: Object.freeze([]) }); + + service.addGeoCheckToBuffer(makeGeoCheck()); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + service: "BufferService", + method: "addGeoCheckToBuffer", + }) + ); + }); + + it("logs 'Unknown error' for non-Error thrown values", () => { + const { service, logger } = createService(); + Object.defineProperty(service, "geoBuffer", { + value: { + push: () => { + throw 42; + }, + }, + }); + + service.addGeoCheckToBuffer(makeGeoCheck()); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + stack: undefined, + }) + ); + }); + }); + + // ── removeCheckFromBuffer ──────────────────────────────────────────── + + describe("removeCheckFromBuffer", () => { + it("removes a check by id and returns true", () => { + const { service } = createService(); + const check = makeCheck({ id: "c1" }); + service.addToBuffer(check); + + const result = service.removeCheckFromBuffer(check); + + expect(result).toBe(true); + }); + + it("returns false when check is not found", () => { + const { service } = createService(); + service.addToBuffer(makeCheck({ id: "c1" })); + + const result = service.removeCheckFromBuffer(makeCheck({ id: "c999" })); + + expect(result).toBe(false); + }); + + it("returns false when passed a falsy value", () => { + const { service } = createService(); + + const result = service.removeCheckFromBuffer(null as any); + + expect(result).toBe(false); + }); + + it("matches by metadata fields when ids are missing", async () => { + const { service, checkService } = createService(); + const check = makeCheck({ + id: undefined as any, + metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" } as any, + status: true, + statusCode: 200, + responseTime: 50, + message: "OK", + }); + service.addToBuffer(check); + + const result = service.removeCheckFromBuffer(check); + + expect(result).toBe(true); + await service.flushBuffer(); + expect(checkService.createChecks).not.toHaveBeenCalled(); + }); + + it("does not match when metadata fields differ", () => { + const { service } = createService(); + const check = makeCheck({ + id: undefined as any, + metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" } as any, + status: true, + statusCode: 200, + responseTime: 50, + message: "OK", + }); + service.addToBuffer(check); + + const different = makeCheck({ + id: undefined as any, + metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" } as any, + status: false, + statusCode: 500, + responseTime: 50, + message: "Error", + }); + const result = service.removeCheckFromBuffer(different); + + expect(result).toBe(false); + }); + + it("logs error and returns false on exception", () => { + const { service, logger } = createService(); + // Force findIndex to throw + Object.defineProperty(service, "buffer", { + value: { + findIndex: () => { + throw new Error("findIndex failed"); + }, + }, + }); + + const result = service.removeCheckFromBuffer(makeCheck()); + + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "findIndex failed", + service: "BufferService", + method: "removeCheckFromBuffer", + }) + ); + }); + + it("logs 'Unknown error' for non-Error thrown values in removeCheckFromBuffer", () => { + const { service, logger } = createService(); + Object.defineProperty(service, "buffer", { + value: { + findIndex: () => { + throw null; + }, + }, + }); + + const result = service.removeCheckFromBuffer(makeCheck()); + + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + stack: undefined, + }) + ); + }); + }); + + // ── scheduleNextFlush ──────────────────────────────────────────────── + + describe("scheduleNextFlush", () => { + it("clears existing timer and sets a new one", () => { + const { service } = createService(); + // Constructor already scheduled one flush + const initialTimerCount = jest.getTimerCount(); + + service.scheduleNextFlush(); + + // Should still have timers (cleared old, set new) + expect(jest.getTimerCount()).toBe(initialTimerCount); + }); + + it("flushes buffer and geo buffer when timer fires", async () => { + const { service, checkService, geoChecksService } = createService(); + service.addToBuffer(makeCheck()); + service.addGeoCheckToBuffer(makeGeoCheck()); + + await jest.advanceTimersByTimeAsync(10); + + expect(checkService.createChecks).toHaveBeenCalled(); + expect(geoChecksService.createGeoChecks).toHaveBeenCalled(); + }); + + it("reschedules after flush completes", async () => { + const { service, checkService } = createService(); + (checkService.createChecks as jest.Mock).mockResolvedValue([]); + + service.addToBuffer(makeCheck()); + await jest.advanceTimersByTimeAsync(10); + + // Add another check and advance again to confirm rescheduling + service.addToBuffer(makeCheck({ id: "c2" })); + await jest.advanceTimersByTimeAsync(10); + + expect(checkService.createChecks).toHaveBeenCalledTimes(2); + }); + + it("reschedules even when flush throws", async () => { + const { service, checkService, logger } = createService(); + (checkService.createChecks as jest.Mock).mockRejectedValueOnce(new Error("DB down")); + service.addToBuffer(makeCheck()); + + await jest.advanceTimersByTimeAsync(10); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "DB down", + method: "flushBuffer", + }) + ); + + // Should still reschedule — add another check and flush + (checkService.createChecks as jest.Mock).mockResolvedValue([]); + service.addToBuffer(makeCheck({ id: "c2" })); + await jest.advanceTimersByTimeAsync(10); + + expect(checkService.createChecks).toHaveBeenCalledTimes(2); + }); + + it("logs error and reschedules when flush throws past its own catch", async () => { + const { service, logger } = createService(); + // Override flushBuffer to throw past its own try/catch + service.flushBuffer = jest.fn<() => Promise>().mockRejectedValueOnce(new Error("unexpected")); + + await jest.advanceTimersByTimeAsync(10); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "unexpected", + method: "scheduleNextFlush", + }) + ); + }); + + it("logs 'Unknown error' when flush throws non-Error past its own catch", async () => { + const { service, logger } = createService(); + service.flushBuffer = jest.fn<() => Promise>().mockRejectedValueOnce("string error"); + + await jest.advanceTimersByTimeAsync(10); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + method: "scheduleNextFlush", + stack: undefined, + }) + ); + }); + }); + + // ── flushBuffer ────────────────────────────────────────────────────── + + describe("flushBuffer", () => { + it("does nothing when buffer is empty", async () => { + const { service, checkService } = createService(); + + await service.flushBuffer(); + + expect(checkService.createChecks).not.toHaveBeenCalled(); + }); + + it("flushes checks to checksService and clears buffer", async () => { + const { service, checkService } = createService(); + const check = makeCheck(); + service.addToBuffer(check); + + await service.flushBuffer(); + + expect(checkService.createChecks).toHaveBeenCalledWith([check]); + // Buffer should be empty now + await service.flushBuffer(); + expect(checkService.createChecks).toHaveBeenCalledTimes(1); + }); + + it("logs debug message before flushing", async () => { + const { service, logger } = createService(); + service.addToBuffer(makeCheck()); + + await service.flushBuffer(); + + expect(logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Flushing 1 checks to database", + service: "BufferService", + method: "flushBuffer", + }) + ); + }); + + it("clears buffer even on error to prevent infinite retries", async () => { + const { service, checkService, logger } = createService(); + (checkService.createChecks as jest.Mock).mockRejectedValue(new Error("DB write failed")); + service.addToBuffer(makeCheck()); + + await service.flushBuffer(); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "DB write failed", + method: "flushBuffer", + }) + ); + // Buffer should be cleared + await service.flushBuffer(); + expect(checkService.createChecks).toHaveBeenCalledTimes(1); + }); + + it("logs 'Unknown error' for non-Error thrown values", async () => { + const { service, checkService, logger } = createService(); + (checkService.createChecks as jest.Mock).mockRejectedValue(null); + service.addToBuffer(makeCheck()); + + await service.flushBuffer(); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + method: "flushBuffer", + stack: undefined, + }) + ); + }); + }); + + // ── flushGeoBuffer ─────────────────────────────────────────────────── + + describe("flushGeoBuffer", () => { + it("does nothing when geo buffer is empty", async () => { + const { service, geoChecksService } = createService(); + + await service.flushGeoBuffer(); + + expect(geoChecksService.createGeoChecks).not.toHaveBeenCalled(); + }); + + it("flushes geo checks and clears buffer", async () => { + const { service, geoChecksService } = createService(); + const geoCheck = makeGeoCheck(); + service.addGeoCheckToBuffer(geoCheck); + + await service.flushGeoBuffer(); + + expect(geoChecksService.createGeoChecks).toHaveBeenCalledWith([geoCheck]); + // Buffer should be empty now + await service.flushGeoBuffer(); + expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1); + }); + + it("logs debug message before flushing", async () => { + const { service, logger } = createService(); + service.addGeoCheckToBuffer(makeGeoCheck()); + + await service.flushGeoBuffer(); + + expect(logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Flushing 1 geo checks to database", + service: "BufferService", + method: "flushGeoBuffer", + }) + ); + }); + + it("clears geo buffer even on error", async () => { + const { service, geoChecksService, logger } = createService(); + (geoChecksService.createGeoChecks as jest.Mock).mockRejectedValue(new Error("DB error")); + service.addGeoCheckToBuffer(makeGeoCheck()); + + await service.flushGeoBuffer(); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "DB error", + method: "flushGeoBuffer", + }) + ); + // Buffer should be cleared + await service.flushGeoBuffer(); + expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1); + }); + + it("logs 'Unknown error' for non-Error thrown values", async () => { + const { service, geoChecksService, logger } = createService(); + (geoChecksService.createGeoChecks as jest.Mock).mockRejectedValue(undefined); + service.addGeoCheckToBuffer(makeGeoCheck()); + + await service.flushGeoBuffer(); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + method: "flushGeoBuffer", + stack: undefined, + }) + ); + }); + }); +}); From bb55f3b3ebef48abf2fb86cc1769d965f3dcf135 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 9 Apr 2026 09:41:17 -0700 Subject: [PATCH 07/23] add tests for notificaitonMessageBuilder --- .../notificationMessageBuilder.test.ts | 761 ++++++++++++++++++ 1 file changed, 761 insertions(+) create mode 100644 server/test/unit/services/notificationMessageBuilder.test.ts diff --git a/server/test/unit/services/notificationMessageBuilder.test.ts b/server/test/unit/services/notificationMessageBuilder.test.ts new file mode 100644 index 000000000..d10bd1604 --- /dev/null +++ b/server/test/unit/services/notificationMessageBuilder.test.ts @@ -0,0 +1,761 @@ +import { describe, expect, it, beforeEach } from "@jest/globals"; +import { NotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts"; +import type { Monitor, MonitorStatusResponse, HardwareStatusPayload } from "../../../src/types/index.ts"; +import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + name: "Test Monitor", + url: "https://example.com", + type: "http", + status: "down", + teamId: "team-1", + cpuAlertThreshold: undefined, + memoryAlertThreshold: undefined, + diskAlertThreshold: undefined, + tempAlertThreshold: undefined, + ...overrides, + }) as Monitor; + +const makeDecision = (overrides?: Partial): MonitorActionDecision => ({ + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: true, + incidentReason: null, + notificationReason: "status_change", + ...overrides, +}); + +const makeStatusResponse = (overrides?: Partial): MonitorStatusResponse => + ({ + monitorId: "mon-1", + teamId: "team-1", + type: "http", + status: false, + code: 500, + message: "Internal Server Error", + ...overrides, + }) as MonitorStatusResponse; + +const makeHardwarePayload = (overrides?: Partial): HardwareStatusPayload => + ({ + data: { + cpu: { usage_percent: 0.5, temperature: [45] }, + memory: { usage_percent: 0.6 }, + disk: [{ usage_percent: 0.7 }], + ...overrides, + }, + }) as HardwareStatusPayload; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("NotificationMessageBuilder", () => { + let builder: NotificationMessageBuilder; + + beforeEach(() => { + builder = new NotificationMessageBuilder(); + }); + + describe("SERVICE_NAME", () => { + it("returns NotificationMessageBuilder", () => { + expect(NotificationMessageBuilder.SERVICE_NAME).toBe("NotificationMessageBuilder"); + }); + }); + + // ── buildMessage ───────────────────────────────────────────────────── + + describe("buildMessage", () => { + it("builds a monitor_down message", () => { + const monitor = makeMonitor({ status: "down" }); + const decision = makeDecision(); + const response = makeStatusResponse(); + + const msg = builder.buildMessage(monitor, response, decision, "https://app.example.com"); + + expect(msg.type).toBe("monitor_down"); + expect(msg.severity).toBe("critical"); + expect(msg.monitor).toEqual({ + id: "mon-1", + name: "Test Monitor", + url: "https://example.com", + type: "http", + status: "down", + }); + expect(msg.content.title).toBe("Monitor Down: Test Monitor"); + expect(msg.clientHost).toBe("https://app.example.com"); + expect(msg.metadata).toEqual({ + teamId: "team-1", + notificationReason: "status_change", + }); + }); + + it("builds a monitor_up message", () => { + const monitor = makeMonitor({ status: "up", type: "http" }); + const decision = makeDecision({ notificationReason: "status_change" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "https://app.example.com"); + + expect(msg.type).toBe("monitor_up"); + expect(msg.severity).toBe("success"); + expect(msg.content.title).toBe("Monitor Recovered: Test Monitor"); + }); + + it("builds a threshold_breach message", () => { + const monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 }); + const decision = makeDecision({ notificationReason: "threshold_breach" }); + const response = makeStatusResponse({ + type: "hardware", + payload: makeHardwarePayload({ cpu: { usage_percent: 0.9, temperature: [50] } as any }), + } as any); + + const msg = builder.buildMessage(monitor, response, decision, "https://app.example.com"); + + expect(msg.type).toBe("threshold_breach"); + expect(msg.severity).toBe("warning"); + expect(msg.content.title).toBe("Threshold Exceeded: Test Monitor"); + expect(msg.content.thresholds).toBeDefined(); + expect(msg.content.thresholds!.length).toBeGreaterThan(0); + }); + + it("builds a threshold_resolved message for hardware monitor recovering", () => { + const monitor = makeMonitor({ status: "up", type: "hardware" }); + const decision = makeDecision({ notificationReason: "status_change" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "https://app.example.com"); + + expect(msg.type).toBe("threshold_resolved"); + expect(msg.severity).toBe("success"); + expect(msg.content.title).toBe("Thresholds Resolved: Test Monitor"); + }); + + it("uses notificationReason from decision, falling back to status_change", () => { + const monitor = makeMonitor({ status: "down" }); + const decision = makeDecision({ notificationReason: null }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "https://app.example.com"); + + expect(msg.metadata.notificationReason).toBe("status_change"); + }); + }); + + // ── determineNotificationType (via buildMessage) ───────────────────── + + describe("determineNotificationType", () => { + it("returns monitor_down when status is down, even if notificationReason is threshold_breach", () => { + const monitor = makeMonitor({ status: "down" }); + const decision = makeDecision({ notificationReason: "threshold_breach" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, ""); + + expect(msg.type).toBe("monitor_down"); + }); + + it("returns threshold_breach when reason is threshold_breach and status is not down", () => { + const monitor = makeMonitor({ status: "up", type: "hardware" }); + const decision = makeDecision({ notificationReason: "threshold_breach" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, ""); + + expect(msg.type).toBe("threshold_breach"); + }); + + it("returns threshold_resolved for hardware monitor with status_change and up", () => { + const monitor = makeMonitor({ status: "up", type: "hardware" }); + const decision = makeDecision({ notificationReason: "status_change" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, ""); + + expect(msg.type).toBe("threshold_resolved"); + }); + + it("returns monitor_up for non-hardware monitor with status up", () => { + const monitor = makeMonitor({ status: "up", type: "http" }); + const decision = makeDecision({ notificationReason: "status_change" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, ""); + + expect(msg.type).toBe("monitor_up"); + }); + + it("returns monitor_up as default for unrecognized status", () => { + const monitor = makeMonitor({ status: "unknown" as any }); + const decision = makeDecision({ notificationReason: null }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, ""); + + expect(msg.type).toBe("monitor_up"); + }); + }); + + // ── determineSeverity (via buildMessage) ───────────────────────────── + + describe("determineSeverity", () => { + it("returns critical for monitor_down", () => { + const msg = builder.buildMessage(makeMonitor({ status: "down" }), makeStatusResponse(), makeDecision(), ""); + expect(msg.severity).toBe("critical"); + }); + + it("returns warning for threshold_breach", () => { + const msg = builder.buildMessage( + makeMonitor({ status: "up", type: "hardware" }), + makeStatusResponse(), + makeDecision({ notificationReason: "threshold_breach" }), + "" + ); + expect(msg.severity).toBe("warning"); + }); + + it("returns success for monitor_up", () => { + const msg = builder.buildMessage( + makeMonitor({ status: "up", type: "http" }), + makeStatusResponse(), + makeDecision({ notificationReason: "status_change" }), + "" + ); + expect(msg.severity).toBe("success"); + }); + + it("returns success for threshold_resolved", () => { + const msg = builder.buildMessage( + makeMonitor({ status: "up", type: "hardware" }), + makeStatusResponse(), + makeDecision({ notificationReason: "status_change" }), + "" + ); + expect(msg.severity).toBe("success"); + }); + }); + + // ── buildContent variants ──────────────────────────────────────────── + + describe("buildContent", () => { + describe("monitor_down", () => { + it("includes response code and error message when present", () => { + const monitor = makeMonitor({ status: "down" }); + const response = makeStatusResponse({ code: 503, message: "Service Unavailable" }); + + const msg = builder.buildMessage(monitor, response, makeDecision(), ""); + + expect(msg.content.details).toContain("Response Code: 503"); + expect(msg.content.details).toContain("Error: Service Unavailable"); + }); + + it("omits response code when falsy", () => { + const monitor = makeMonitor({ status: "down" }); + const response = makeStatusResponse({ code: 0, message: "" }); + + const msg = builder.buildMessage(monitor, response, makeDecision(), ""); + + expect(msg.content.details).not.toContainEqual(expect.stringContaining("Response Code:")); + expect(msg.content.details).not.toContainEqual(expect.stringContaining("Error:")); + }); + + it("includes URL, Status, and Type in details", () => { + const monitor = makeMonitor({ status: "down" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + + expect(msg.content.details).toContain("URL: https://example.com"); + expect(msg.content.details).toContain("Status: Down"); + expect(msg.content.details).toContain("Type: http"); + }); + + it("sets summary text", () => { + const monitor = makeMonitor({ status: "down" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + expect(msg.content.summary).toBe('Monitor "Test Monitor" is currently down and unreachable.'); + }); + + it("sets timestamp", () => { + const monitor = makeMonitor({ status: "down" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + expect(msg.content.timestamp).toBeInstanceOf(Date); + }); + }); + + describe("monitor_up", () => { + it("includes recovery details", () => { + const monitor = makeMonitor({ status: "up", type: "http" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + + expect(msg.content.title).toBe("Monitor Recovered: Test Monitor"); + expect(msg.content.summary).toBe('Monitor "Test Monitor" is back up and operational.'); + expect(msg.content.details).toContain("Status: Up"); + }); + }); + + describe("threshold_breach", () => { + it("includes threshold breaches in content", () => { + const monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.9, temperature: [50] } as any }), + } as any); + + const msg = builder.buildMessage(monitor, response, makeDecision({ notificationReason: "threshold_breach" }), ""); + + expect(msg.content.thresholds).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + metric: "cpu", + currentValue: 90, + threshold: 80, + }), + ]) + ); + }); + }); + + describe("threshold_resolved", () => { + it("includes resolved details", () => { + const monitor = makeMonitor({ status: "up", type: "hardware" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + + expect(msg.content.title).toBe("Thresholds Resolved: Test Monitor"); + expect(msg.content.summary).toBe('Monitor "Test Monitor" thresholds have returned to normal.'); + expect(msg.content.details).toContain("Status: Up"); + }); + }); + + describe("default content", () => { + it("builds default content for unhandled notification type", () => { + // Force default by creating a scenario where type falls through + // We can test buildContent default by subclassing or testing indirectly + // The default case in buildContent handles "test" type among others + // Since determineNotificationType never returns "test", we test via + // a monitor with unrecognized status that maps to monitor_up + // Instead, let's verify the default case exists by checking that + // non-standard types would get default content + const monitor = makeMonitor({ status: "up", type: "http" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + + // monitor_up is handled, so content should be monitor_up specific + expect(msg.content.title).toBe("Monitor Recovered: Test Monitor"); + }); + }); + }); + + // ── determineSeverity edge cases (via private method) ──────────────── + + describe("determineSeverity edge cases", () => { + it("returns info for test type", () => { + const result = (builder as any).determineSeverity("test"); + expect(result).toBe("info"); + }); + + it("returns info for unknown type (default)", () => { + const result = (builder as any).determineSeverity("unknown_type"); + expect(result).toBe("info"); + }); + }); + + // ── buildContent edge cases (via private method) ───────────────────── + + describe("buildContent edge cases", () => { + it("returns default content for unhandled notification type", () => { + const monitor = makeMonitor({ status: "up", type: "http" }); + const result = (builder as any).buildContent("test", monitor, makeStatusResponse()); + + expect(result.title).toBe("Monitor: Test Monitor"); + expect(result.summary).toBe('Status update for monitor "Test Monitor".'); + expect(result.details).toContain("URL: https://example.com"); + expect(result.details).toContain("Status: up"); + expect(result.details).toContain("Type: http"); + }); + }); + + // ── extractThresholdBreaches ───────────────────────────────────────── + + describe("extractThresholdBreaches", () => { + it("returns empty array for non-hardware monitor", () => { + const monitor = makeMonitor({ type: "http" }); + const response = makeStatusResponse(); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toEqual([]); + }); + + it("returns empty array when payload is missing", () => { + const monitor = makeMonitor({ type: "hardware" }); + const response = makeStatusResponse({ payload: undefined } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toEqual([]); + }); + + it("returns empty array when hardware data is missing", () => { + const monitor = makeMonitor({ type: "hardware" }); + const response = makeStatusResponse({ payload: { data: undefined } } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toEqual([]); + }); + + // ── CPU ────────────────────────────────────────────────────────── + + describe("cpu threshold", () => { + it("detects CPU breach when usage exceeds threshold", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.9, temperature: [50] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toContainEqual( + expect.objectContaining({ + metric: "cpu", + currentValue: 90, + threshold: 80, + unit: "%", + formattedValue: "90.0%", + }) + ); + }); + + it("does not report CPU breach when usage is below threshold", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [50] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined(); + }); + + it("skips CPU check when cpuAlertThreshold is undefined", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: undefined }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.99 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined(); + }); + + it("skips CPU check when cpuAlertThreshold is null", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: null as any }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.99 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined(); + }); + + it("skips CPU check when cpu usage_percent is undefined", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: undefined } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined(); + }); + + it("skips CPU check when cpu object is undefined", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: undefined as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined(); + }); + }); + + // ── Memory ─────────────────────────────────────────────────────── + + describe("memory threshold", () => { + it("detects memory breach when usage exceeds threshold", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: { usage_percent: 0.85 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toContainEqual( + expect.objectContaining({ + metric: "memory", + currentValue: 85, + threshold: 70, + unit: "%", + formattedValue: "85.0%", + }) + ); + }); + + it("does not report memory breach when usage is below threshold", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 90 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: { usage_percent: 0.5 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "memory")).toBeUndefined(); + }); + + it("skips memory check when memoryAlertThreshold is undefined", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: undefined }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: { usage_percent: 0.99 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "memory")).toBeUndefined(); + }); + + it("skips memory check when memoryAlertThreshold is null", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: null as any }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: { usage_percent: 0.99 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "memory")).toBeUndefined(); + }); + + it("skips memory check when memory usage_percent is undefined", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: { usage_percent: undefined } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "memory")).toBeUndefined(); + }); + + it("skips memory check when memory object is undefined", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: undefined as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "memory")).toBeUndefined(); + }); + }); + + // ── Disk ───────────────────────────────────────────────────────── + + describe("disk threshold", () => { + it("detects disk breach using highest usage across multiple disks", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ + disk: [{ usage_percent: 0.5 }, { usage_percent: 0.95 }, { usage_percent: 0.7 }] as any, + }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toContainEqual( + expect.objectContaining({ + metric: "disk", + currentValue: 95, + threshold: 80, + formattedValue: "95.0%", + }) + ); + }); + + it("does not report disk breach when all disks are below threshold", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 90 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ + disk: [{ usage_percent: 0.5 }, { usage_percent: 0.6 }] as any, + }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "disk")).toBeUndefined(); + }); + + it("skips disk check when diskAlertThreshold is undefined", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: undefined }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ disk: [{ usage_percent: 0.99 }] as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "disk")).toBeUndefined(); + }); + + it("skips disk check when diskAlertThreshold is null", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: null as any }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ disk: [{ usage_percent: 0.99 }] as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "disk")).toBeUndefined(); + }); + + it("skips disk check when disk is not an array", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ disk: "not-an-array" as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "disk")).toBeUndefined(); + }); + + it("skips disks with undefined usage_percent", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 50 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ + disk: [{ usage_percent: undefined }, { usage_percent: 0.3 }] as any, + }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "disk")).toBeUndefined(); + }); + }); + + // ── Temperature ────────────────────────────────────────────────── + + describe("temperature threshold", () => { + it("detects temperature breach from array of temperatures", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [65, 75, 68] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toContainEqual( + expect.objectContaining({ + metric: "temp", + currentValue: 75, + threshold: 70, + unit: "°C", + formattedValue: "75.0°C", + }) + ); + }); + + it("handles single temperature value (non-array)", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: 80 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toContainEqual( + expect.objectContaining({ + metric: "temp", + currentValue: 80, + threshold: 70, + }) + ); + }); + + it("does not report temperature breach when below threshold", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [60, 65] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "temp")).toBeUndefined(); + }); + + it("skips temperature check when tempAlertThreshold is undefined", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: undefined }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [99] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "temp")).toBeUndefined(); + }); + + it("skips temperature check when tempAlertThreshold is null", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: null as any }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [99] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "temp")).toBeUndefined(); + }); + + it("skips temperature check when cpu.temperature is falsy", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: null } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "temp")).toBeUndefined(); + }); + + it("skips temperature check when cpu object is undefined", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: undefined as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "temp")).toBeUndefined(); + }); + }); + + // ── Combined ───────────────────────────────────────────────────── + + describe("combined thresholds", () => { + it("detects multiple breaches simultaneously", () => { + const monitor = makeMonitor({ + type: "hardware", + cpuAlertThreshold: 80, + memoryAlertThreshold: 70, + diskAlertThreshold: 85, + tempAlertThreshold: 65, + }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ + cpu: { usage_percent: 0.9, temperature: [70] } as any, + memory: { usage_percent: 0.85 } as any, + disk: [{ usage_percent: 0.95 }] as any, + }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + const metrics = breaches.map((b) => b.metric); + expect(metrics).toContain("cpu"); + expect(metrics).toContain("memory"); + expect(metrics).toContain("disk"); + expect(metrics).toContain("temp"); + }); + }); + }); +}); From db833b441af3df1c2b992553014f05d954b0943f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 9 Apr 2026 09:49:12 -0700 Subject: [PATCH 08/23] add tests for emailService --- .../service/infrastructure/emailService.ts | 18 +- .../test/unit/services/emailService.test.ts | 400 ++++++++++++++++++ 2 files changed, 408 insertions(+), 10 deletions(-) create mode 100644 server/test/unit/services/emailService.test.ts diff --git a/server/src/service/infrastructure/emailService.ts b/server/src/service/infrastructure/emailService.ts index f536f3f18..6cf8caf4f 100755 --- a/server/src/service/infrastructure/emailService.ts +++ b/server/src/service/infrastructure/emailService.ts @@ -35,7 +35,6 @@ export class EmailService implements IEmailService { private logger: ILogger; private transporter: ReturnType | null = null; private templateLookup: Record) => string) | undefined>; - private loadTemplate: (templateName: string) => ((context: Record) => string) | undefined; constructor( settingsService: ISettingsService, @@ -54,7 +53,6 @@ export class EmailService implements IEmailService { this.nodemailer = nodemailer; this.logger = logger; this.templateLookup = {}; - this.loadTemplate = () => undefined; this.init(); } @@ -63,7 +61,7 @@ export class EmailService implements IEmailService { } init = () => { - this.loadTemplate = (templateName) => { + const loadTemplate = (templateName: string) => { try { const templatePath = this.path.join(__dirname, `../../templates/${templateName}.mjml`); const templateContent = this.fs.readFileSync(templatePath, "utf8"); @@ -79,12 +77,12 @@ export class EmailService implements IEmailService { }; this.templateLookup = { - welcomeEmailTemplate: this.loadTemplate("welcomeEmail"), - employeeActivationTemplate: this.loadTemplate("employeeActivation"), - noIncidentsThisWeekTemplate: this.loadTemplate("noIncidentsThisWeek"), - passwordResetTemplate: this.loadTemplate("passwordReset"), - testEmailTemplate: this.loadTemplate("testEmailTemplate"), - unifiedNotificationTemplate: this.loadTemplate("unifiedNotification"), + welcomeEmailTemplate: loadTemplate("welcomeEmail"), + employeeActivationTemplate: loadTemplate("employeeActivation"), + noIncidentsThisWeekTemplate: loadTemplate("noIncidentsThisWeek"), + passwordResetTemplate: loadTemplate("passwordReset"), + testEmailTemplate: loadTemplate("testEmailTemplate"), + unifiedNotificationTemplate: loadTemplate("unifiedNotification"), }; }; @@ -167,7 +165,7 @@ export class EmailService implements IEmailService { subject: subject, html: html, }); - return info?.messageId; + return info.messageId; } catch (error: unknown) { this.logger.error({ message: error instanceof Error ? error.message : "Unknown error", diff --git a/server/test/unit/services/emailService.test.ts b/server/test/unit/services/emailService.test.ts new file mode 100644 index 000000000..13b234186 --- /dev/null +++ b/server/test/unit/services/emailService.test.ts @@ -0,0 +1,400 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { EmailService } from "../../../src/service/infrastructure/emailService.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { ISettingsService } from "../../../src/service/system/settingsService.ts"; +import type { EmailTransportConfig } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeTransportConfig = (overrides?: Partial): EmailTransportConfig => + ({ + systemEmailHost: "smtp.example.com", + systemEmailPort: 587, + systemEmailSecure: false, + systemEmailPool: false, + systemEmailUser: "user@example.com", + systemEmailAddress: "noreply@example.com", + systemEmailPassword: "password123", + systemEmailConnectionHost: "mail.example.com", + systemEmailTLSServername: "smtp.example.com", + systemEmailIgnoreTLS: false, + systemEmailRequireTLS: true, + systemEmailRejectUnauthorized: true, + ...overrides, + }) as EmailTransportConfig; + +const createMockSettingsService = () => + ({ + getDBSettings: jest.fn().mockResolvedValue(makeTransportConfig()), + }) as unknown as jest.Mocked; + +const createMockFs = (templateContent: string = "") => + ({ + readFileSync: jest.fn().mockReturnValue(templateContent), + }) as any; + +const createMockPath = () => + ({ + join: jest.fn((...args: string[]) => args.join("/")), + dirname: jest.fn().mockReturnValue("/mock/dir"), + }) as any; + +const createMockCompile = (result: string = "compiled") => { + const compiledFn = jest.fn().mockReturnValue(result); + const compile = jest.fn().mockReturnValue(compiledFn) as any; + compile.__compiledFn = compiledFn; + return compile; +}; + +const createMockMjml = (html: string = "rendered") => jest.fn().mockReturnValue({ html }) as any; + +const createMockTransporter = () => ({ + verify: jest.fn().mockResolvedValue(true), + sendMail: jest.fn().mockResolvedValue({ messageId: "msg-123" }), +}); + +const createMockNodemailer = (transporter?: ReturnType) => { + const t = transporter ?? createMockTransporter(); + return { + createTransport: jest.fn().mockReturnValue(t), + __transporter: t, + } as any; +}; + +const createService = (overrides?: { settingsService?: any; fs?: any; path?: any; compile?: any; mjml?: any; nodemailer?: any }) => { + const logger = createMockLogger(); + const settingsService = overrides?.settingsService ?? createMockSettingsService(); + const mockFs = overrides?.fs ?? createMockFs(); + const mockPath = overrides?.path ?? createMockPath(); + const compile = overrides?.compile ?? createMockCompile(); + const mjml = overrides?.mjml ?? createMockMjml(); + const mockNodemailer = overrides?.nodemailer ?? createMockNodemailer(); + + const service = new EmailService(settingsService, mockFs, mockPath, compile, mjml, mockNodemailer, logger as any); + + return { service, logger, settingsService, mockFs, mockPath, compile, mjml, mockNodemailer }; +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("EmailService", () => { + describe("SERVICE_NAME", () => { + it("returns EmailService from static property", () => { + expect(EmailService.SERVICE_NAME).toBe("EmailService"); + }); + + it("returns EmailService from instance getter", () => { + const { service } = createService(); + expect(service.serviceName).toBe("EmailService"); + }); + }); + + // ── init / loadTemplate ────────────────────────────────────────────── + + describe("init", () => { + it("loads all expected templates on construction", () => { + const { mockFs } = createService(); + + // 6 templates loaded + expect(mockFs.readFileSync).toHaveBeenCalledTimes(6); + }); + + it("compiles each loaded template", () => { + const { compile } = createService(); + + expect(compile).toHaveBeenCalledTimes(6); + }); + + it("logs error when a template file is not found", () => { + const failFs = { + readFileSync: jest.fn().mockImplementation(() => { + throw new Error("ENOENT: no such file"); + }), + }; + + const { logger } = createService({ fs: failFs }); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "ENOENT: no such file", + service: "EmailService", + method: "loadTemplate", + }) + ); + }); + + it("logs 'Unknown error' for non-Error thrown values in loadTemplate", () => { + const failFs = { + readFileSync: jest.fn().mockImplementation(() => { + throw "string error"; + }), + }; + + const { logger } = createService({ fs: failFs }); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + stack: undefined, + }) + ); + }); + + it("can be called again to reinitialize templates", () => { + const { service, mockFs } = createService(); + const initialCalls = mockFs.readFileSync.mock.calls.length; + + service.init(); + + expect(mockFs.readFileSync).toHaveBeenCalledTimes(initialCalls + 6); + }); + }); + + // ── buildEmail ─────────────────────────────────────────────────────── + + describe("buildEmail", () => { + it("compiles template with context and returns rendered HTML", async () => { + const { service } = createService(); + + const result = await service.buildEmail("welcomeEmailTemplate", { name: "Test" }); + + expect(result).toBe("rendered"); + }); + + it("passes context to the compiled template function", async () => { + const compile = createMockCompile(); + const { service } = createService({ compile }); + const context = { name: "Alex", url: "https://example.com" }; + + await service.buildEmail("welcomeEmailTemplate", context); + + expect(compile.__compiledFn).toHaveBeenCalledWith(context); + }); + + it("returns undefined and logs error when template is not found", async () => { + const { service, logger } = createService(); + + const result = await service.buildEmail("nonExistentTemplate", {}); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Template nonExistentTemplate not found", + service: "EmailService", + method: "buildEmail", + }) + ); + }); + + it("returns undefined and logs error when template function returns falsy", async () => { + const compile = jest.fn().mockReturnValue(jest.fn().mockReturnValue(undefined)) as any; + const { service, logger } = createService({ compile }); + + const result = await service.buildEmail("welcomeEmailTemplate", {}); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Template welcomeEmailTemplate not found", + }) + ); + }); + + it("returns undefined and logs error when mjml throws", async () => { + const mjml = jest.fn().mockImplementation(() => { + throw new Error("MJML parse error"); + }) as any; + const { service, logger } = createService({ mjml }); + + const result = await service.buildEmail("welcomeEmailTemplate", {}); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "MJML parse error", + method: "buildEmail", + }) + ); + }); + + it("logs 'Unknown error' for non-Error thrown values in buildEmail", async () => { + const mjml = jest.fn().mockImplementation(() => { + throw null; + }) as any; + const { service, logger } = createService({ mjml }); + + const result = await service.buildEmail("welcomeEmailTemplate", {}); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + method: "buildEmail", + stack: undefined, + }) + ); + }); + }); + + // ── sendEmail ──────────────────────────────────────────────────────── + + describe("sendEmail", () => { + it("sends email with provided transport config and returns messageId", async () => { + const transporter = createMockTransporter(); + const nodemailer = createMockNodemailer(transporter); + const { service } = createService({ nodemailer }); + const config = makeTransportConfig(); + + const result = await service.sendEmail("to@example.com", "Subject", "

body

", config); + + expect(result).toBe("msg-123"); + expect(transporter.verify).toHaveBeenCalled(); + expect(transporter.sendMail).toHaveBeenCalledWith({ + to: "to@example.com", + from: "noreply@example.com", + subject: "Subject", + html: "

body

", + }); + }); + + it("falls back to DB settings when no transport config is provided", async () => { + const settingsService = createMockSettingsService(); + const transporter = createMockTransporter(); + const nodemailer = createMockNodemailer(transporter); + const { service } = createService({ settingsService, nodemailer }); + + await service.sendEmail("to@example.com", "Subject", "

body

"); + + expect(settingsService.getDBSettings).toHaveBeenCalled(); + }); + + it("creates transporter with correct email config", async () => { + const transporter = createMockTransporter(); + const nodemailer = createMockNodemailer(transporter); + const { service } = createService({ nodemailer }); + const config = makeTransportConfig({ + systemEmailHost: "smtp.test.com", + systemEmailPort: 465, + systemEmailSecure: true, + systemEmailPool: true, + systemEmailUser: "testuser", + systemEmailPassword: "testpass", + systemEmailConnectionHost: "conn.test.com", + systemEmailRejectUnauthorized: false, + systemEmailIgnoreTLS: true, + systemEmailRequireTLS: false, + systemEmailTLSServername: "tls.test.com", + }); + + await service.sendEmail("to@example.com", "Subject", "

body

", config); + + expect(nodemailer.createTransport).toHaveBeenCalledWith({ + host: "smtp.test.com", + port: 465, + secure: true, + auth: { user: "testuser", pass: "testpass" }, + name: "conn.test.com", + connectionTimeout: 5000, + pool: true, + tls: { + rejectUnauthorized: false, + ignoreTLS: true, + requireTLS: false, + servername: "tls.test.com", + }, + }); + }); + + it("uses systemEmailAddress as auth user when systemEmailUser is falsy", async () => { + const transporter = createMockTransporter(); + const nodemailer = createMockNodemailer(transporter); + const { service } = createService({ nodemailer }); + const config = makeTransportConfig({ systemEmailUser: "", systemEmailAddress: "fallback@example.com" }); + + await service.sendEmail("to@example.com", "Subject", "

body

", config); + + expect(nodemailer.createTransport).toHaveBeenCalledWith( + expect.objectContaining({ + auth: expect.objectContaining({ user: "fallback@example.com" }), + }) + ); + }); + + it("uses 'localhost' as name when systemEmailConnectionHost is falsy", async () => { + const transporter = createMockTransporter(); + const nodemailer = createMockNodemailer(transporter); + const { service } = createService({ nodemailer }); + const config = makeTransportConfig({ systemEmailConnectionHost: "" }); + + await service.sendEmail("to@example.com", "Subject", "

body

", config); + + expect(nodemailer.createTransport).toHaveBeenCalledWith(expect.objectContaining({ name: "localhost" })); + }); + + it("returns false when transporter verification fails", async () => { + const transporter = createMockTransporter(); + (transporter.verify as jest.Mock).mockRejectedValue(new Error("SMTP auth failed")); + const nodemailer = createMockNodemailer(transporter); + const { service, logger } = createService({ nodemailer }); + + const result = await service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig()); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Email transporter verification failed", + service: "EmailService", + method: "verifyTransporter", + }) + ); + }); + + it("logs stack as undefined when verification fails with non-Error", async () => { + const transporter = createMockTransporter(); + (transporter.verify as jest.Mock).mockRejectedValue("connection refused"); + const nodemailer = createMockNodemailer(transporter); + const { service, logger } = createService({ nodemailer }); + + const result = await service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig()); + + expect(result).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); + }); + + it("returns undefined and logs error when sendMail throws", async () => { + const transporter = createMockTransporter(); + (transporter.sendMail as jest.Mock).mockRejectedValue(new Error("Recipient rejected")); + const nodemailer = createMockNodemailer(transporter); + const { service, logger } = createService({ nodemailer }); + + const result = await service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig()); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Recipient rejected", + service: "EmailService", + method: "sendEmail", + }) + ); + }); + + it("logs 'Unknown error' for non-Error thrown values in sendMail", async () => { + const transporter = createMockTransporter(); + (transporter.sendMail as jest.Mock).mockRejectedValue(42); + const nodemailer = createMockNodemailer(transporter); + const { service, logger } = createService({ nodemailer }); + + const result = await service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig()); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Unknown error", + method: "sendEmail", + stack: undefined, + }) + ); + }); + }); +}); From dca09daf358b36b86fdc872701426d18e465cada Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 9 Apr 2026 10:03:23 -0700 Subject: [PATCH 09/23] add tests for globalPingService --- .../unit/services/globalPingService.test.ts | 499 ++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 server/test/unit/services/globalPingService.test.ts diff --git a/server/test/unit/services/globalPingService.test.ts b/server/test/unit/services/globalPingService.test.ts new file mode 100644 index 000000000..78d195b0f --- /dev/null +++ b/server/test/unit/services/globalPingService.test.ts @@ -0,0 +1,499 @@ +import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { GeoContinent } from "../../../src/types/geoCheck.ts"; + +// ── got mock ───────────────────────────────────────────────────────────────── + +const mockGotPost = jest.fn(); +const mockGotGet = jest.fn(); + +jest.unstable_mockModule("got", () => ({ + default: { + post: mockGotPost, + get: mockGotGet, + }, +})); + +// Dynamic import AFTER mock registration +const { GlobalPingService } = await import("../../../src/service/infrastructure/globalPingService.ts"); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createService = () => { + const logger = createMockLogger(); + const service = new GlobalPingService(logger as any); + return { service, logger }; +}; + +const makeProbeResult = (overrides?: Record) => ({ + probe: { + continent: "NA" as GeoContinent, + region: "Northern America", + country: "US", + state: "CA", + city: "San Francisco", + longitude: -122.4, + latitude: 37.77, + }, + result: { + status: "finished", + statusCode: 200, + statusCodeName: "OK", + timings: { + total: 150, + dns: 10, + tcp: 20, + tls: 30, + firstByte: 50, + download: 40, + }, + }, + ...overrides, +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("GlobalPingService", () => { + beforeEach(() => { + jest.useFakeTimers(); + mockGotPost.mockReset(); + mockGotGet.mockReset(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + // ── Static / instance properties ───────────────────────────────────── + + describe("serviceName", () => { + it("returns GlobalPingService from static property", () => { + expect(GlobalPingService.SERVICE_NAME).toBe("GlobalPingService"); + }); + + it("returns GlobalPingService from instance getter", () => { + const { service } = createService(); + expect(service.serviceName).toBe("GlobalPingService"); + }); + }); + + // ── createMeasurement ──────────────────────────────────────────────── + + describe("createMeasurement", () => { + it("creates a measurement and returns the id", async () => { + const { service, logger } = createService(); + mockGotPost.mockResolvedValue({ body: { id: "meas-123" } }); + + const result = await service.createMeasurement("http", "https://example.com", ["NA", "EU"] as GeoContinent[]); + + expect(result).toBe("meas-123"); + expect(mockGotPost).toHaveBeenCalledWith( + "https://api.globalping.io/v1/measurements", + expect.objectContaining({ + json: { + type: "http", + target: "example.com", + locations: [{ continent: "NA" }, { continent: "EU" }], + limit: 2, + }, + responseType: "json", + timeout: { request: 10000 }, + }) + ); + expect(logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("meas-123"), + method: "createMeasurement", + }) + ); + }); + + it("strips http:// protocol from target URL", async () => { + const { service } = createService(); + mockGotPost.mockResolvedValue({ body: { id: "meas-1" } }); + + await service.createMeasurement("http", "http://example.com", ["NA"] as GeoContinent[]); + + expect(mockGotPost).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + json: expect.objectContaining({ target: "example.com" }), + }) + ); + }); + + it("strips https:// protocol from target URL", async () => { + const { service } = createService(); + mockGotPost.mockResolvedValue({ body: { id: "meas-1" } }); + + await service.createMeasurement("http", "https://example.com/path", ["EU"] as GeoContinent[]); + + expect(mockGotPost).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + json: expect.objectContaining({ target: "example.com/path" }), + }) + ); + }); + + it("returns null and logs error for unsupported monitor type", async () => { + const { service, logger } = createService(); + + const result = await service.createMeasurement("hardware" as any, "https://example.com", ["NA"] as GeoContinent[]); + + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "GlobalPing API unavailable, skipping geo check", + method: "createMeasurement", + }) + ); + }); + + it("returns null and logs error when API call fails", async () => { + const { service, logger } = createService(); + mockGotPost.mockRejectedValue(new Error("Network error")); + + const result = await service.createMeasurement("http", "https://example.com", ["NA"] as GeoContinent[]); + + expect(result).toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "GlobalPing API unavailable, skipping geo check", + method: "createMeasurement", + }) + ); + }); + + it("logs stack as undefined for non-Error thrown values", async () => { + const { service, logger } = createService(); + mockGotPost.mockRejectedValue("string error"); + + await service.createMeasurement("http", "https://example.com", ["NA"] as GeoContinent[]); + + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); + }); + + it("supports ping monitor type", async () => { + const { service } = createService(); + mockGotPost.mockResolvedValue({ body: { id: "meas-ping" } }); + + const result = await service.createMeasurement("ping", "example.com", ["NA"] as GeoContinent[]); + + expect(result).toBe("meas-ping"); + }); + }); + + // ── pollForResults ─────────────────────────────────────────────────── + + describe("pollForResults", () => { + it("returns transformed results when measurement is finished", async () => { + const { service, logger } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [makeProbeResult()], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results).toHaveLength(1); + expect(results[0]).toEqual( + expect.objectContaining({ + location: expect.objectContaining({ + continent: "NA", + city: "San Francisco", + }), + status: true, + statusCode: 200, + timings: expect.objectContaining({ + total: 150, + dns: 10, + }), + }) + ); + expect(logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("meas-123"), + method: "pollForResults", + }) + ); + }); + + it("defaults results to empty array when finished with no results", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { status: "finished", results: undefined }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results).toEqual([]); + }); + + it("returns empty array when measurement has failed", async () => { + const { service, logger } = createService(); + mockGotGet.mockResolvedValue({ + body: { status: "failed" }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("failed"), + method: "pollForResults", + }) + ); + }); + + it("polls again when status is in-progress, then returns on finished", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValueOnce({ body: { status: "in-progress" } }).mockResolvedValueOnce({ + body: { + status: "finished", + results: [makeProbeResult()], + }, + }); + + const promise = service.pollForResults("meas-123"); + + // Advance past the sleep(2000) + await jest.advanceTimersByTimeAsync(2000); + + const results = await promise; + + expect(mockGotGet).toHaveBeenCalledTimes(2); + expect(results).toHaveLength(1); + }); + + it("returns empty array and logs error when API call throws", async () => { + const { service, logger } = createService(); + mockGotGet.mockRejectedValue(new Error("Connection refused")); + + const results = await service.pollForResults("meas-123"); + + expect(results).toEqual([]); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Error polling GlobalPing API", + method: "pollForResults", + }) + ); + }); + + it("logs stack as undefined for non-Error thrown values in poll", async () => { + const { service, logger } = createService(); + mockGotGet.mockRejectedValue(42); + + await service.pollForResults("meas-123"); + + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); + }); + + it("returns empty array and logs warning on timeout", async () => { + const { service, logger } = createService(); + // Always return in-progress so we hit the timeout + mockGotGet.mockImplementation(async () => ({ body: { status: "in-progress" } })); + + const promise = service.pollForResults("meas-123", 100); + + // Advance time well past the timeout + await jest.advanceTimersByTimeAsync(35000); + + const results = await promise; + + expect(results).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("timeout"), + method: "pollForResults", + details: expect.objectContaining({ measurementId: "meas-123", timeoutMs: 100 }), + }) + ); + }); + }); + + // ── transformResults (via pollForResults) ──────────────────────────── + + describe("transformResults", () => { + it("skips probes with non-finished status", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [makeProbeResult({ result: { status: "failed" } }), makeProbeResult({ result: { status: "timeout" } })], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results).toEqual([]); + }); + + it("transforms HTTP results with statusCode and timings", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [makeProbeResult()], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results[0].status).toBe(true); + expect(results[0].statusCode).toBe(200); + expect(results[0].timings.total).toBe(150); + }); + + it("marks HTTP result as failed for non-2xx status codes", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [ + makeProbeResult({ + result: { + status: "finished", + statusCode: 500, + timings: { total: 100, dns: 5, tcp: 10, tls: 15, firstByte: 30, download: 40 }, + }, + }), + ], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results[0].status).toBe(false); + expect(results[0].statusCode).toBe(500); + }); + + it("transforms ping results with stats (no loss)", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [ + makeProbeResult({ + result: { + status: "finished", + stats: { min: 10, max: 30, avg: 20, total: 3, loss: 0, rcv: 3, drop: 0 }, + }, + }), + ], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results[0].status).toBe(true); + expect(results[0].statusCode).toBe(200); + expect(results[0].timings.total).toBe(20); + expect(results[0].timings.dns).toBe(0); + }); + + it("transforms ping results with loss as failed", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [ + makeProbeResult({ + result: { + status: "finished", + stats: { min: 10, max: 30, avg: 20, total: 3, loss: 2, rcv: 1, drop: 2 }, + }, + }), + ], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results[0].status).toBe(false); + expect(results[0].statusCode).toBe(5000); + }); + + it("uses empty string for null state in location", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [ + makeProbeResult({ + probe: { + continent: "EU", + region: "Western Europe", + country: "DE", + state: null, + city: "Berlin", + longitude: 13.4, + latitude: 52.5, + }, + }), + ], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results[0].location.state).toBe(""); + }); + + it("skips probes with no statusCode/timings and no stats", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [ + makeProbeResult({ + result: { status: "finished" }, + }), + ], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results).toEqual([]); + }); + + it("transforms multiple probes from different locations", async () => { + const { service } = createService(); + mockGotGet.mockResolvedValue({ + body: { + status: "finished", + results: [ + makeProbeResult(), + makeProbeResult({ + probe: { + continent: "EU", + region: "Western Europe", + country: "GB", + state: null, + city: "London", + longitude: -0.12, + latitude: 51.5, + }, + result: { + status: "finished", + statusCode: 200, + timings: { total: 80, dns: 5, tcp: 10, tls: 15, firstByte: 30, download: 20 }, + }, + }), + ], + }, + }); + + const results = await service.pollForResults("meas-123"); + + expect(results).toHaveLength(2); + expect(results[0].location.continent).toBe("NA"); + expect(results[1].location.continent).toBe("EU"); + }); + }); +}); From 63efef65c7c7871cf6c585807b4b79e8a6227951 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 9 Apr 2026 10:36:59 -0700 Subject: [PATCH 10/23] add provider tests --- server/package.json | 2 +- .../infrastructure/network/DockerProvider.ts | 4 +- .../infrastructure/network/GrpcProvider.ts | 2 +- server/test/helpers/statusProviderContract.ts | 54 +++ .../unit/providers/advancedMatcher.test.ts | 228 +++++++++++ .../unit/providers/dockerProvider.test.ts | 328 +++++++++++++++ .../test/unit/providers/gameProvider.test.ts | 191 +++++++++ .../test/unit/providers/grpcProvider.test.ts | 378 ++++++++++++++++++ .../unit/providers/hardwareProvider.test.ts | 91 +++++ .../test/unit/providers/httpProvider.test.ts | 348 ++++++++++++++++ .../unit/providers/pageSpeedProvider.test.ts | 165 ++++++++ .../test/unit/providers/pingProvider.test.ts | 139 +++++++ .../test/unit/providers/portProvider.test.ts | 270 +++++++++++++ server/test/unit/providers/utils.test.ts | 44 ++ .../unit/providers/webSocketProvider.test.ts | 194 +++++++++ 15 files changed, 2434 insertions(+), 4 deletions(-) create mode 100644 server/test/helpers/statusProviderContract.ts create mode 100644 server/test/unit/providers/advancedMatcher.test.ts create mode 100644 server/test/unit/providers/dockerProvider.test.ts create mode 100644 server/test/unit/providers/gameProvider.test.ts create mode 100644 server/test/unit/providers/grpcProvider.test.ts create mode 100644 server/test/unit/providers/hardwareProvider.test.ts create mode 100644 server/test/unit/providers/httpProvider.test.ts create mode 100644 server/test/unit/providers/pageSpeedProvider.test.ts create mode 100644 server/test/unit/providers/pingProvider.test.ts create mode 100644 server/test/unit/providers/portProvider.test.ts create mode 100644 server/test/unit/providers/utils.test.ts create mode 100644 server/test/unit/providers/webSocketProvider.test.ts diff --git a/server/package.json b/server/package.json index 44643d40e..57ee33308 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:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers", "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/network/DockerProvider.ts b/server/src/service/infrastructure/network/DockerProvider.ts index 8e84b5dc6..813baedbe 100644 --- a/server/src/service/infrastructure/network/DockerProvider.ts +++ b/server/src/service/infrastructure/network/DockerProvider.ts @@ -90,7 +90,7 @@ export class DockerProvider implements IStatusProvider { if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID"); if (matchTypes.length > 1) { - const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`; + const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : "exact name"} match.`; this.logger.warn({ message, @@ -142,7 +142,7 @@ export class DockerProvider implements IStatusProvider { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, - status: response?.State?.Status === "running", + status: response.State?.Status === "running", code: 200, message: "Docker container status fetched successfully", responseTime, diff --git a/server/src/service/infrastructure/network/GrpcProvider.ts b/server/src/service/infrastructure/network/GrpcProvider.ts index 66684ba0c..14a063bca 100644 --- a/server/src/service/infrastructure/network/GrpcProvider.ts +++ b/server/src/service/infrastructure/network/GrpcProvider.ts @@ -60,7 +60,7 @@ export class GrpcProvider implements IStatusProvider { throw new AppError({ message: "Monitor port is required", service: SERVICE_NAME, method: "handle" }); } - const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0]; + const host = url.replace(/^https?:\/\//, "").split(/[/?#:]/)[0]; const target = `${host}:${port}`; const currentFilePath = fileURLToPath(import.meta.url); diff --git a/server/test/helpers/statusProviderContract.ts b/server/test/helpers/statusProviderContract.ts new file mode 100644 index 000000000..6f7c89466 --- /dev/null +++ b/server/test/helpers/statusProviderContract.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "@jest/globals"; +import type { IStatusProvider } from "../../src/service/infrastructure/network/IStatusProvider.ts"; +import type { Monitor } from "../../src/types/index.ts"; + +/** + * Shared contract tests for all IStatusProvider implementations. + * Tests the interface guarantees that every provider must satisfy. + */ +export const testStatusProviderContract = ( + name: string, + opts: { + create: () => IStatusProvider; + supportedType: string; + unsupportedType: string; + makeMonitor: () => Monitor; + } +) => { + describe(`IStatusProvider contract: ${name}`, () => { + it("has a readonly type property matching the supported type", () => { + const provider = opts.create(); + expect(provider.type).toBe(opts.supportedType); + }); + + it("supports() returns true for its own type", () => { + const provider = opts.create(); + expect(provider.supports(opts.supportedType as any)).toBe(true); + }); + + it("supports() returns false for a different type", () => { + const provider = opts.create(); + expect(provider.supports(opts.unsupportedType as any)).toBe(false); + }); + + it("handle() returns an object with required MonitorStatusResponse fields", async () => { + const provider = opts.create(); + try { + const result = await provider.handle(opts.makeMonitor()); + expect(result).toEqual( + expect.objectContaining({ + monitorId: expect.anything(), + teamId: expect.anything(), + type: expect.anything(), + status: expect.any(Boolean), + code: expect.any(Number), + message: expect.any(String), + }) + ); + } catch (err: any) { + // Some providers throw AppError on failure — that's also valid behavior + expect(err).toBeDefined(); + } + }); + }); +}; diff --git a/server/test/unit/providers/advancedMatcher.test.ts b/server/test/unit/providers/advancedMatcher.test.ts new file mode 100644 index 000000000..e0087b201 --- /dev/null +++ b/server/test/unit/providers/advancedMatcher.test.ts @@ -0,0 +1,228 @@ +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"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createMockJmespath = () => ({ + search: jest.fn((data: any, expr: string) => { + // Simple dot-path evaluation for tests + return expr.split(".").reduce((acc: any, key: string) => acc?.[key], data); + }), +}); + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + useAdvancedMatching: false, + jsonPath: undefined, + matchMethod: undefined, + expectedValue: undefined, + ...overrides, + }) as Monitor; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("AdvancedMatcher", () => { + describe("validate", () => { + it("returns ok when advanced matching is disabled", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate({ data: "test" }, makeMonitor({ useAdvancedMatching: false })); + + expect(result).toEqual({ ok: true, message: "Success" }); + }); + + // ── jsonPath extraction ────────────────────────────────────────── + + describe("jsonPath extraction", () => { + it("extracts value using jsonPath", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate({ status: "healthy" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "status" })); + + expect(result).toEqual({ ok: true, message: "Success", extracted: "healthy" }); + }); + + it("returns error when jsonPath evaluation throws", () => { + const jmespath = { + search: jest.fn().mockImplementation(() => { + throw new Error("bad expression"); + }), + }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({ data: "test" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "invalid[" })); + + expect(result).toEqual({ ok: false, message: "Error evaluating JSON path" }); + }); + }); + + // ── expectedValue matching ─────────────────────────────────────── + + describe("expectedValue with matchMethod", () => { + it("matches with 'equal' method", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { status: "ok" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "status", matchMethod: "equal", expectedValue: "ok" }) + ); + + expect(result.ok).toBe(true); + }); + + it("fails with 'equal' method on mismatch", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { status: "error" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "status", matchMethod: "equal", expectedValue: "ok" }) + ); + + expect(result.ok).toBe(false); + expect(result.message).toBe("Expected value did not match"); + }); + + it("matches with 'include' method", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { msg: "server is healthy" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "msg", matchMethod: "include", expectedValue: "healthy" }) + ); + + expect(result.ok).toBe(true); + }); + + it("fails with 'include' method when substring not found", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { msg: "server is down" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "msg", matchMethod: "include", expectedValue: "healthy" }) + ); + + expect(result.ok).toBe(false); + }); + + it("matches with 'regex' method", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { code: "200" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "code", matchMethod: "regex", expectedValue: "^2\\d{2}$" }) + ); + + expect(result.ok).toBe(true); + }); + + it("fails with 'regex' method on mismatch", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { code: "500" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "code", matchMethod: "regex", expectedValue: "^2\\d{2}$" }) + ); + + expect(result.ok).toBe(false); + }); + + it("defaults to 'equal' comparison when matchMethod is undefined", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate({ val: "exact" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "val", expectedValue: "exact" })); + + expect(result.ok).toBe(true); + }); + + it("defaults to 'equal' comparison for unknown matchMethod", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { val: "exact" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "val", matchMethod: "unknown" as any, expectedValue: "exact" }) + ); + + expect(result.ok).toBe(true); + }); + + it("compares entire payload when jsonPath is not set", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + "hello", + makeMonitor({ useAdvancedMatching: true, jsonPath: undefined, matchMethod: "equal", expectedValue: "hello" }) + ); + + expect(result.ok).toBe(true); + expect(result.extracted).toBe("hello"); + }); + + it("includes extracted value in result", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { nested: { value: "42" } }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "nested.value", matchMethod: "equal", expectedValue: "42" }) + ); + + expect(result.extracted).toBe("42"); + }); + }); + + // ── falsy value check (no expectedValue) ──────────────────────── + + describe("falsy value check (no expectedValue)", () => { + it("returns ok for truthy extracted value", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate({ status: "up" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "status" })); + + expect(result.ok).toBe(true); + expect(result.extracted).toBe("up"); + }); + + it("returns not ok for false", () => { + const jmespath = { search: jest.fn().mockReturnValue(false) }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "missing" })); + + expect(result.ok).toBe(false); + expect(result.message).toBe("Extracted value is falsy"); + }); + + it("returns not ok for string 'false'", () => { + const jmespath = { search: jest.fn().mockReturnValue("false") }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" })); + + expect(result.ok).toBe(false); + }); + + it("returns not ok for undefined", () => { + const jmespath = { search: jest.fn().mockReturnValue(undefined) }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" })); + + expect(result.ok).toBe(false); + }); + + it("returns not ok for null", () => { + const jmespath = { search: jest.fn().mockReturnValue(null) }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" })); + + expect(result.ok).toBe(false); + }); + + it("returns ok for zero (not in falsy list)", () => { + const jmespath = { search: jest.fn().mockReturnValue(0) }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" })); + + expect(result.ok).toBe(true); + }); + + it("returns ok for empty string (not in falsy list)", () => { + const jmespath = { search: jest.fn().mockReturnValue("") }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" })); + + expect(result.ok).toBe(true); + }); + }); + }); +}); diff --git a/server/test/unit/providers/dockerProvider.test.ts b/server/test/unit/providers/dockerProvider.test.ts new file mode 100644 index 000000000..4306d3ff7 --- /dev/null +++ b/server/test/unit/providers/dockerProvider.test.ts @@ -0,0 +1,328 @@ +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"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "docker", + url: "my-container", + ...overrides, + }) as Monitor; + +const makeContainer = (overrides?: Record) => ({ + Id: "abc123def456abc123def456abc123def456abc123def456abc123def456abcd", + Names: ["/my-container"], + State: "running", + ...overrides, +}); + +const createMockDocker = (containers: any[] = [makeContainer()], inspectResult?: any) => { + const inspect = jest.fn().mockResolvedValue(inspectResult ?? { State: { Status: "running" } }); + const getContainer = jest.fn().mockReturnValue({ inspect }); + + const instance = { + listContainers: jest.fn().mockResolvedValue(containers), + getContainer, + }; + + const DockerLib = jest.fn().mockReturnValue(instance) as any; + + return { DockerLib, instance, inspect, getContainer }; +}; + +const createProvider = (opts?: { containers?: any[]; inspectResult?: any }) => { + const logger = createMockLogger(); + const { DockerLib, instance, inspect, getContainer } = createMockDocker(opts?.containers, opts?.inspectResult); + const provider = new DockerProvider(logger as any, DockerLib); + return { provider, logger, docker: instance, inspect, getContainer }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("DockerProvider", { + create: () => createProvider().provider, + supportedType: "docker", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("DockerProvider", () => { + // ── Container matching ─────────────────────────────────────────────── + + describe("container matching", () => { + it("matches by exact container name", async () => { + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: "my-container" })); + + expect(result.status).toBe(true); + expect(result.code).toBe(200); + }); + + it("matches by exact full ID (64 chars)", async () => { + const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd"; + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: fullId })); + + expect(result.status).toBe(true); + }); + + it("matches by partial ID prefix", async () => { + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: "abc123" })); + + expect(result.status).toBe(true); + }); + + it("strips leading slashes from input", async () => { + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: "///my-container" })); + + expect(result.status).toBe(true); + }); + + it("matches case-insensitively", async () => { + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: "MY-CONTAINER" })); + + expect(result.status).toBe(true); + }); + + it("returns 404 when no container matches", async () => { + const { provider, logger } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: "nonexistent" })); + + expect(result.status).toBe(false); + expect(result.code).toBe(404); + expect(result.message).toBe("Docker container not found"); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("No container found"), + }) + ); + }); + }); + + // ── Ambiguity detection ────────────────────────────────────────────── + + describe("ambiguity detection", () => { + it("returns error when input matches both exact name and partial ID", async () => { + // Container whose name matches the input AND whose ID starts with the input + const container = makeContainer({ + Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd", + Names: ["/my-container"], + }); + const { provider, logger } = createProvider({ containers: [container] }); + + const result = await provider.handle(makeMonitor({ url: "my-container" })); + + expect(result.status).toBe(false); + expect(result.code).toBe(NETWORK_ERROR); + expect(result.message).toContain("Ambiguous"); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Ambiguous"), + }) + ); + }); + + it("reports 'exact ID' in ambiguity message when exact ID and name both match", async () => { + const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd"; + const container = makeContainer({ + Id: fullId, + Names: ["/" + fullId], + }); + const { provider } = createProvider({ containers: [container] }); + + const result = await provider.handle(makeMonitor({ url: fullId })); + + expect(result.message).toContain("Using exact ID"); + }); + + it("reports 'exact name' in ambiguity message when name and partial ID match", async () => { + const container = makeContainer({ + Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd", + Names: ["/my-container"], + }); + const { provider } = createProvider({ containers: [container] }); + + const result = await provider.handle(makeMonitor({ url: "my-container" })); + + expect(result.message).toContain("Using exact name"); + }); + }); + + // ── Container inspection ───────────────────────────────────────────── + + describe("container inspection", () => { + it("returns running status for running container", async () => { + const { provider } = createProvider({ inspectResult: { State: { Status: "running" } } }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(true); + expect(result.message).toBe("Docker container status fetched successfully"); + }); + + it("returns not-running status for stopped container", async () => { + const { provider } = createProvider({ inspectResult: { State: { Status: "exited" } } }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + }); + + it("returns false status when inspect response has no State", async () => { + const { provider } = createProvider({ inspectResult: {} }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + }); + + it("returns false status when inspect response has State but no Status", async () => { + const { provider } = createProvider({ inspectResult: { State: {} } }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + }); + + it("handles inspect error with Docker-specific error", async () => { + const { provider } = createProvider(); + // Make inspect throw a Docker error + const dockerErr = Object.assign(new Error("container not found"), { + statusCode: 404, + reason: "no such container", + json: { message: "Container not found" }, + }); + provider["docker"] = { + listContainers: jest.fn().mockResolvedValue([makeContainer()]), + getContainer: jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(dockerErr), + }), + } as any; + + // Need to re-assign; simpler to use the createProvider mock + const { provider: p2 } = createProvider(); + // Override getContainer to return failing inspect + p2["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(dockerErr), + }); + + const result = await p2.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.code).toBe(404); + expect(result.message).toBe("Container not found"); + }); + + it("handles inspect error with Docker error using reason fallback", async () => { + const { provider } = createProvider(); + const dockerErr = Object.assign(new Error("error"), { + statusCode: 500, + reason: "internal error", + json: {}, + }); + provider["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(dockerErr), + }); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("internal error"); + }); + + it("handles inspect error with Docker error using Error message fallback", async () => { + const { provider } = createProvider(); + const dockerErr = Object.assign(new Error("base error"), { + statusCode: 503, + reason: undefined, + json: undefined, + }); + provider["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(dockerErr), + }); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("base error"); + }); + + it("handles inspect error with NETWORK_ERROR when no statusCode", async () => { + const { provider } = createProvider(); + const dockerErr = Object.assign(new Error("unknown"), { + statusCode: undefined, + }); + provider["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(dockerErr), + }); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(NETWORK_ERROR); + }); + + it("handles inspect error with standard Error (not Docker error)", async () => { + const { provider } = createProvider(); + provider["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(new Error("ECONNREFUSED")), + }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.message).toBe("ECONNREFUSED"); + }); + + it("handles inspect error with non-Error non-Docker thrown value", async () => { + const { provider } = createProvider(); + provider["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue("string error"), + }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.message).toBe("Failed to fetch Docker container information"); + }); + }); + + // ── Outer catch ────────────────────────────────────────────────────── + + describe("outer error handling", () => { + it("throws AppError when containerInput is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Container name or ID is required for Docker monitor"); + }); + + it("throws AppError when listContainers throws", async () => { + const { provider } = createProvider(); + provider["docker"].listContainers = jest.fn().mockRejectedValue(new Error("Docker daemon unavailable")); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Docker daemon unavailable"); + }); + + it("throws AppError with default message for non-Error thrown values", async () => { + const { provider } = createProvider(); + provider["docker"].listContainers = jest.fn().mockRejectedValue(null); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Docker request"); + }); + }); +}); diff --git a/server/test/unit/providers/gameProvider.test.ts b/server/test/unit/providers/gameProvider.test.ts new file mode 100644 index 000000000..c2f671c3c --- /dev/null +++ b/server/test/unit/providers/gameProvider.test.ts @@ -0,0 +1,191 @@ +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"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "game", + url: "play.example.com", + port: 25565, + gameId: "minecraft", + ...overrides, + }) as Monitor; + +const createMockGameDig = (state?: Record | null) => ({ + query: jest.fn().mockResolvedValue( + state ?? { + name: "My Server", + map: "world", + players: [], + ping: 45, + } + ), +}); + +const createProvider = (gameDig?: any) => { + const logger = createMockLogger(); + const dig = gameDig ?? createMockGameDig(); + const provider = new GameProvider(logger as any, dig as any); + return { provider, logger, gameDig: dig }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("GameProvider", { + create: () => createProvider().provider, + supportedType: "game", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("GameProvider", () => { + it("returns success with server state", async () => { + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "game", + status: true, + code: 200, + message: "Success", + responseTime: 45, + }) + ); + }); + + it("queries with correct host, port, and gameId", async () => { + const { provider, gameDig } = createProvider(); + + await provider.handle(makeMonitor({ url: "https://play.example.com/path", port: 27015, gameId: "csgo" })); + + expect(gameDig.query).toHaveBeenCalledWith({ + type: "csgo", + host: "play.example.com", + port: 27015, + }); + }); + + it("strips protocol and path from url for host", async () => { + const { provider, gameDig } = createProvider(); + + await provider.handle(makeMonitor({ url: "http://game.server.com:8080/info" })); + + expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ host: "game.server.com" })); + }); + + it("defaults gameId to 'unknown' when not set", async () => { + const { provider, gameDig } = createProvider(); + + await provider.handle(makeMonitor({ gameId: undefined })); + + expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ type: "unknown" })); + }); + + it("defaults port to 0 when not set", async () => { + const { provider, gameDig } = createProvider(); + + await provider.handle(makeMonitor({ port: undefined })); + + expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ port: 0 })); + }); + + it("defaults responseTime to 0 when ping is undefined", async () => { + const { provider } = createProvider(createMockGameDig({ ping: undefined })); + + const result = await provider.handle(makeMonitor()); + + expect(result.responseTime).toBe(0); + }); + + it("returns failure when query resolves to undefined (caught error)", async () => { + const gameDig = { query: jest.fn().mockRejectedValue(new Error("Server offline")) }; + // GameProvider catches the error internally via .catch() + const { provider, logger } = createProvider(gameDig); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "No response from game server", + responseTime: 0, + }) + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Server offline", + method: "handle", + }) + ); + }); + + it("logs non-Error thrown values from query", async () => { + const gameDig = { query: jest.fn().mockRejectedValue("string error") }; + const { provider, logger } = createProvider(gameDig); + + await provider.handle(makeMonitor()); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "string error" })); + }); + + it("uses empty string as host when url is undefined", async () => { + const { provider, gameDig } = createProvider(); + + await provider.handle(makeMonitor({ url: undefined })); + + expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ host: "" })); + }); + + it("throws AppError with default message when error stringifies to empty", async () => { + const gameDig = { + query: jest.fn().mockReturnValue({ + catch: () => { + throw new Error(""); + }, + }), + }; + const { provider } = createProvider(gameDig); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing game server check"); + }); + + it("throws AppError with default message for non-Error that stringifies to empty", async () => { + const gameDig = { + query: jest.fn().mockReturnValue({ + catch: () => { + throw ""; + }, + }), + }; + const { provider } = createProvider(gameDig); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing game server check"); + }); + + it("throws AppError with Error message in outer catch", async () => { + const gameDig = { + query: jest.fn().mockReturnValue({ + catch: () => { + throw new Error("unexpected"); + }, + }), + }; + const { provider } = createProvider(gameDig); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("unexpected"); + }); +}); diff --git a/server/test/unit/providers/grpcProvider.test.ts b/server/test/unit/providers/grpcProvider.test.ts new file mode 100644 index 000000000..2b5de6634 --- /dev/null +++ b/server/test/unit/providers/grpcProvider.test.ts @@ -0,0 +1,378 @@ +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"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "grpc", + url: "grpc.example.com", + port: 50051, + grpcServiceName: "my.Service", + ignoreTlsErrors: false, + ...overrides, + }) as Monitor; + +const createMockClient = (behavior: "serving" | "not-serving" | "error" | "grpc-error" = "serving") => { + const close = jest.fn(); + const Check = jest.fn((_req: any, _opts: any, cb: Function) => { + if (behavior === "serving") { + cb(null, { status: "SERVING" }); + } else if (behavior === "not-serving") { + cb(null, { status: "NOT_SERVING" }); + } else if (behavior === "error") { + cb({ code: 14, details: "Connection refused", message: "unavailable" }); + } else if (behavior === "grpc-error") { + cb({ code: 2, message: "Unknown error" }); + } + }); + return { Check, close }; +}; + +const createMockGrpc = (client?: ReturnType) => { + const c = client ?? createMockClient(); + const Health = jest.fn().mockReturnValue(c); + + return { + grpc: { + loadPackageDefinition: jest.fn().mockReturnValue({ + grpc: { health: { v1: { Health } } }, + }), + credentials: { + createInsecure: jest.fn().mockReturnValue("insecure-creds"), + createSsl: jest.fn().mockReturnValue("ssl-creds"), + }, + }, + protoLoader: { + loadSync: jest.fn().mockReturnValue({}), + }, + Health, + client: c, + }; +}; + +const createProvider = (opts?: { behavior?: "serving" | "not-serving" | "error" | "grpc-error" }) => { + const client = createMockClient(opts?.behavior ?? "serving"); + const mocks = createMockGrpc(client); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + return { provider, ...mocks, client }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("GrpcProvider", { + create: () => createProvider().provider, + supportedType: "grpc", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("GrpcProvider", () => { + // ── Success paths ──────────────────────────────────────────────────── + + describe("success responses", () => { + it("returns healthy status for SERVING response", async () => { + const { provider } = createProvider({ behavior: "serving" }); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "grpc", + status: true, + code: 200, + message: expect.stringContaining("SERVING"), + }) + ); + expect(result.payload).toEqual( + expect.objectContaining({ + grpcStatusCode: 0, + grpcStatusName: "OK", + serviceName: "my.Service", + servingStatus: "SERVING", + }) + ); + }); + + it("returns unhealthy status for NOT_SERVING response", async () => { + const { provider } = createProvider({ behavior: "not-serving" }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.code).toBe(5000); + expect(result.message).toContain("NOT_SERVING"); + }); + + it("defaults to UNKNOWN when response has no status", async () => { + const client = { Check: jest.fn((_r: any, _o: any, cb: Function) => cb(null, {})), close: jest.fn() }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toEqual(expect.objectContaining({ servingStatus: "UNKNOWN" })); + }); + + it("defaults to UNKNOWN when response is undefined", async () => { + const client = { Check: jest.fn((_r: any, _o: any, cb: Function) => cb(null, undefined)), close: jest.fn() }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toEqual(expect.objectContaining({ servingStatus: "UNKNOWN" })); + }); + }); + + // ── gRPC errors ────────────────────────────────────────────────────── + + describe("gRPC errors", () => { + it("returns failure with gRPC error details", async () => { + const { provider } = createProvider({ behavior: "error" }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.code).toBe(14); + expect(result.message).toBe("Connection refused"); + expect(result.payload).toEqual( + expect.objectContaining({ + grpcStatusCode: 14, + grpcStatusName: "UNAVAILABLE", + servingStatus: "UNKNOWN", + }) + ); + }); + + it("falls back to message when details is missing", async () => { + const client = { + Check: jest.fn((_r: any, _o: any, cb: Function) => cb({ code: 2, message: "Unknown error" })), + close: jest.fn(), + }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("Unknown error"); + }); + + it("uses default message and code when error has no code/details/message", async () => { + const client = { + Check: jest.fn((_r: any, _o: any, cb: Function) => cb({})), + close: jest.fn(), + }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(5000); + expect(result.message).toBe("gRPC error"); + expect(result.payload).toEqual(expect.objectContaining({ grpcStatusCode: -1, grpcStatusName: "UNKNOWN" })); + }); + + it("defaults grpcCode to 5000 when error has no grpcCode", async () => { + const client = { + Check: jest.fn((_r: any, _o: any, cb: Function) => { + const err = new Error("test") as any; + // no grpcPayload or grpcCode + cb(err); + }), + close: jest.fn(), + }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(5000); + }); + + it("includes grpcPayload in error result", async () => { + const { provider } = createProvider({ behavior: "error" }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toEqual( + expect.objectContaining({ + grpcStatusCode: 14, + grpcStatusName: "UNAVAILABLE", + }) + ); + }); + + it("defaults code, message, and payload when error lacks gRPC properties", async () => { + // client.Check throws synchronously — timeRequest catches a plain object with no gRPC props + const client = { + Check: jest.fn(() => { + throw { unexpected: true }; + }), + close: jest.fn(), + }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(5000); + expect(result.message).toBe("gRPC health check failed"); + expect(result.payload).toBeNull(); + }); + }); + + // ── Credentials ────────────────────────────────────────────────────── + + describe("credentials", () => { + it("uses insecure credentials by default", async () => { + const { provider, grpc } = createProvider(); + + await provider.handle(makeMonitor({ ignoreTlsErrors: false })); + + expect(grpc.credentials.createInsecure).toHaveBeenCalled(); + }); + + it("uses SSL credentials with checkServerIdentity override when ignoreTlsErrors is true", async () => { + const { provider, grpc } = createProvider(); + + await provider.handle(makeMonitor({ ignoreTlsErrors: true })); + + expect(grpc.credentials.createSsl).toHaveBeenCalledWith(null, null, null, { + checkServerIdentity: expect.any(Function), + }); + + // Verify checkServerIdentity returns undefined + const opts = (grpc.credentials.createSsl as jest.Mock).mock.calls[0][3]; + expect(opts.checkServerIdentity()).toBeUndefined(); + }); + }); + + // ── URL parsing ────────────────────────────────────────────────────── + + describe("URL and target construction", () => { + it("strips protocol from URL", async () => { + const { provider, Health } = createProvider(); + + await provider.handle(makeMonitor({ url: "https://grpc.example.com", port: 443 })); + + expect(Health).toHaveBeenCalledWith("grpc.example.com:443", expect.anything()); + }); + + it("uses grpcServiceName in Check request", async () => { + const { provider, client } = createProvider(); + + await provider.handle(makeMonitor({ grpcServiceName: "health.v1" })); + + expect(client.Check).toHaveBeenCalledWith( + { service: "health.v1" }, + expect.objectContaining({ deadline: expect.any(Date) }), + expect.any(Function) + ); + }); + + it("defaults grpcServiceName to empty string when not set", async () => { + const { provider, client } = createProvider(); + + await provider.handle(makeMonitor({ grpcServiceName: undefined })); + + expect(client.Check).toHaveBeenCalledWith({ service: "" }, expect.anything(), expect.any(Function)); + }); + }); + + // ── Validation ─────────────────────────────────────────────────────── + + describe("validation", () => { + it("throws AppError when url is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Monitor host is required"); + }); + + it("throws AppError when port is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ port: undefined }))).rejects.toThrow("Monitor port is required"); + }); + }); + + // ── Outer catch ────────────────────────────────────────────────────── + + describe("outer error handling", () => { + it("throws AppError when protoLoader.loadSync throws", async () => { + const mocks = createMockGrpc(); + mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => { + throw new Error("Proto file not found"); + }); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Proto file not found"); + }); + + it("throws AppError with default message when Error has empty message", async () => { + const mocks = createMockGrpc(); + mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => { + throw new Error(""); + }); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing gRPC health check"); + }); + + it("throws AppError with stringified message for non-Error thrown values", async () => { + const mocks = createMockGrpc(); + mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => { + throw "proto load failed"; + }); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("proto load failed"); + }); + }); + + // ── getGrpcStatusName ──────────────────────────────────────────────── + + describe("gRPC status name mapping", () => { + const statusCodes: [number, string][] = [ + [0, "OK"], + [1, "CANCELLED"], + [2, "UNKNOWN"], + [3, "INVALID_ARGUMENT"], + [4, "DEADLINE_EXCEEDED"], + [5, "NOT_FOUND"], + [6, "ALREADY_EXISTS"], + [7, "PERMISSION_DENIED"], + [8, "RESOURCE_EXHAUSTED"], + [9, "FAILED_PRECONDITION"], + [10, "ABORTED"], + [11, "OUT_OF_RANGE"], + [12, "UNIMPLEMENTED"], + [13, "INTERNAL"], + [14, "UNAVAILABLE"], + [15, "DATA_LOSS"], + [16, "UNAUTHENTICATED"], + [99, "UNKNOWN"], + ]; + + it.each(statusCodes)("maps gRPC code %i to %s", async (code, expectedName) => { + const client = { + Check: jest.fn((_r: any, _o: any, cb: Function) => cb({ code, details: "test" })), + close: jest.fn(), + }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toEqual(expect.objectContaining({ grpcStatusName: expectedName })); + }); + }); +}); diff --git a/server/test/unit/providers/hardwareProvider.test.ts b/server/test/unit/providers/hardwareProvider.test.ts new file mode 100644 index 000000000..22b2f5ae9 --- /dev/null +++ b/server/test/unit/providers/hardwareProvider.test.ts @@ -0,0 +1,91 @@ +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"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "hardware", + url: "https://capture.example.com", + ...overrides, + }) as Monitor; + +const createMockHttpProvider = () => + ({ + handle: jest.fn().mockResolvedValue({ + monitorId: "mon-1", + teamId: "team-1", + type: "hardware", + status: true, + code: 200, + message: "OK", + responseTime: 50, + payload: { data: { cpu: { usage_percent: 0.5 } } }, + }), + }) as unknown as jest.Mocked; + +const createProvider = () => { + const httpProvider = createMockHttpProvider(); + const provider = new HardwareProvider(httpProvider); + return { provider, httpProvider }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("HardwareProvider", { + create: () => createProvider().provider, + supportedType: "hardware", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("HardwareProvider", () => { + it("delegates to httpProvider.handle", async () => { + const { provider, httpProvider } = createProvider(); + const monitor = makeMonitor(); + + await provider.handle(monitor); + + expect(httpProvider.handle).toHaveBeenCalledWith(monitor); + }); + + it("returns the response from httpProvider", async () => { + const { provider } = createProvider(); + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + status: true, + code: 200, + }) + ); + }); + + it("throws AppError when url is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for Hardware monitor"); + }); + + it("throws AppError when httpProvider.handle throws", async () => { + const { provider, httpProvider } = createProvider(); + (httpProvider.handle as jest.Mock).mockRejectedValue(new Error("connection failed")); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("connection failed"); + }); + + it("throws AppError with default message for non-Error thrown values", async () => { + const { provider, httpProvider } = createProvider(); + (httpProvider.handle as jest.Mock).mockRejectedValue("string error"); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Hardware request"); + }); +}); diff --git a/server/test/unit/providers/httpProvider.test.ts b/server/test/unit/providers/httpProvider.test.ts new file mode 100644 index 000000000..7c9a2f10d --- /dev/null +++ b/server/test/unit/providers/httpProvider.test.ts @@ -0,0 +1,348 @@ +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"; + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +jest.unstable_mockModule("cacheable-lookup", () => ({ + default: jest.fn().mockImplementation(() => ({})), +})); + +const mockGot = jest.fn(); +// got instance returned by got.extend() +mockGot.mockImplementation(() => Promise.resolve()); +(mockGot as any).extend = jest.fn().mockReturnValue(mockGot); + +jest.unstable_mockModule("got", () => ({ + type: { Got: {} }, + HTTPError: class HTTPError extends Error { + response: any; + timings: any; + constructor(msg: string, response?: any, timings?: any) { + super(msg); + this.name = "HTTPError"; + this.response = response; + this.timings = timings; + } + }, + RequestError: class RequestError extends Error { + response: any; + timings: any; + constructor(msg: string, response?: any, timings?: any) { + super(msg); + this.name = "RequestError"; + this.response = response; + this.timings = timings; + } + }, +})); + +const { HttpProvider } = await import("../../../src/service/infrastructure/network/HttpProvider.ts"); +const gotModule = await import("got"); +const { HTTPError, RequestError } = gotModule; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "http", + url: "https://example.com", + secret: undefined, + jsonPath: undefined, + ignoreTlsErrors: false, + useAdvancedMatching: false, + matchMethod: undefined, + expectedValue: undefined, + ...overrides, + }) as Monitor; + +const createMockMatcher = (result?: { ok: boolean; message: string; extracted?: unknown }) => ({ + validate: jest.fn().mockReturnValue(result ?? { ok: true, message: "Success" }), +}); + +const makeGotResponse = (overrides?: Record) => ({ + ok: true, + statusCode: 200, + statusMessage: "OK", + headers: { "content-type": "text/html" }, + body: "", + timings: { phases: { total: 100 } }, + ...overrides, +}); + +const createProvider = (matcher?: any) => { + const advancedMatcher = matcher ?? createMockMatcher(); + const provider = new HttpProvider(mockGot as any, advancedMatcher); + return { provider, advancedMatcher }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("HttpProvider", { + create: () => { + mockGot.mockResolvedValue(makeGotResponse()); + return createProvider().provider; + }, + supportedType: "http", + unsupportedType: "ping", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("HttpProvider", () => { + // ── Success paths ──────────────────────────────────────────────────── + + describe("success responses", () => { + it("returns success for a standard HTML response", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "http", + status: true, + code: 200, + message: "OK", + responseTime: 100, + }) + ); + }); + + it("parses JSON body when content-type is application/json", async () => { + mockGot.mockResolvedValue( + makeGotResponse({ + headers: { "content-type": "application/json" }, + body: '{"status":"ok"}', + }) + ); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toEqual({ status: "ok" }); + }); + + it("returns raw body when JSON parsing fails", async () => { + mockGot.mockResolvedValue( + makeGotResponse({ + headers: { "content-type": "application/json" }, + body: "not-json", + }) + ); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toBe("not-json"); + }); + + it("returns raw body for non-JSON content", async () => { + mockGot.mockResolvedValue(makeGotResponse({ body: "test" })); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toBe("test"); + }); + + it("passes Authorization header when secret is set", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor({ secret: "my-token" })); + + expect(mockGot).toHaveBeenCalledWith( + "https://example.com", + expect.objectContaining({ + headers: { Authorization: "Bearer my-token" }, + }) + ); + }); + + it("passes undefined headers when no secret", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const { provider } = createProvider(); + + await provider.handle(makeMonitor({ secret: undefined })); + + expect(mockGot).toHaveBeenCalledWith( + "https://example.com", + expect.objectContaining({ + headers: undefined, + }) + ); + }); + + it("defaults responseTime to 0 when timings.phases.total is undefined", async () => { + mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { total: undefined } } })); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.responseTime).toBe(0); + }); + + it("defaults statusMessage to 'OK' when undefined", async () => { + mockGot.mockResolvedValue(makeGotResponse({ statusMessage: undefined })); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("OK"); + }); + + it("uses empty string for content-type when header is missing", async () => { + mockGot.mockResolvedValue(makeGotResponse({ headers: {} })); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + // Should treat as non-JSON + expect(result.payload).toBe(""); + }); + }); + + // ── jsonPath + non-JSON response ───────────────────────────────────── + + describe("jsonPath validation", () => { + it("returns failure when jsonPath is set but response is not JSON", async () => { + mockGot.mockResolvedValue(makeGotResponse({ headers: { "content-type": "text/html" } })); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ jsonPath: "status" })); + + expect(result.status).toBe(false); + expect(result.message).toBe("Response is not JSON"); + }); + + it("defaults responseTime to 0 in non-JSON jsonPath response when total is undefined", async () => { + mockGot.mockResolvedValue( + makeGotResponse({ + headers: { "content-type": "text/html" }, + timings: { phases: { total: undefined } }, + }) + ); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ jsonPath: "status" })); + + expect(result.responseTime).toBe(0); + }); + }); + + // ── AdvancedMatcher integration ────────────────────────────────────── + + describe("advanced matching", () => { + it("uses matcher result for status and message", async () => { + mockGot.mockResolvedValue(makeGotResponse()); + const matcher = createMockMatcher({ ok: false, message: "Mismatch", extracted: "value" }); + const { provider } = createProvider(matcher); + + const result = await provider.handle(makeMonitor({ useAdvancedMatching: true })); + + expect(result.status).toBe(false); + expect(result.message).toBe("Mismatch"); + expect(result.extracted).toBe("value"); + }); + + it("sets status to false when response.ok is false even if matcher passes", async () => { + mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301 })); + const matcher = createMockMatcher({ ok: true, message: "Success" }); + const { provider } = createProvider(matcher); + + const result = await provider.handle(makeMonitor()); + + // status = response.ok && matchResult.ok + expect(result.status).toBe(false); + }); + }); + + // ── Error handling ─────────────────────────────────────────────────── + + describe("error handling", () => { + it("handles HTTPError with response and timings", async () => { + const err = new HTTPError("Not Found"); + (err as any).response = { statusCode: 404 }; + (err as any).timings = { phases: { total: 50 } }; + mockGot.mockRejectedValue(err); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: 404, + message: "Not Found", + responseTime: 50, + }) + ); + }); + + it("handles RequestError with response and timings", async () => { + const err = new RequestError("ECONNREFUSED"); + (err as any).response = { statusCode: undefined }; + (err as any).timings = { phases: { total: undefined } }; + mockGot.mockRejectedValue(err); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(NETWORK_ERROR); + expect(result.responseTime).toBe(0); + }); + + it("handles HTTPError without response (defaults to NETWORK_ERROR)", async () => { + const err = new HTTPError("Timeout"); + (err as any).response = undefined; + (err as any).timings = undefined; + mockGot.mockRejectedValue(err); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(NETWORK_ERROR); + expect(result.responseTime).toBe(0); + }); + + it("handles generic Error (non-HTTPError/RequestError)", async () => { + mockGot.mockRejectedValue(new Error("DNS lookup failed")); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "DNS lookup failed", + responseTime: 0, + }) + ); + }); + + it("handles non-Error thrown values", async () => { + mockGot.mockRejectedValue("string error"); + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("string error"); + expect(result.code).toBe(NETWORK_ERROR); + }); + + it("throws when url is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for HTTP monitor"); + }); + }); +}); diff --git a/server/test/unit/providers/pageSpeedProvider.test.ts b/server/test/unit/providers/pageSpeedProvider.test.ts new file mode 100644 index 000000000..08c19bbd7 --- /dev/null +++ b/server/test/unit/providers/pageSpeedProvider.test.ts @@ -0,0 +1,165 @@ +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"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "pagespeed", + url: "https://example.com", + ...overrides, + }) as Monitor; + +const createMockHttpProvider = () => + ({ + handle: jest.fn().mockResolvedValue({ + monitorId: "mon-1", + teamId: "team-1", + type: "pagespeed", + status: true, + code: 200, + message: "OK", + responseTime: 2000, + payload: { lighthouseResult: {} }, + }), + }) as unknown as jest.Mocked; + +const createMockSettingsService = (apiKey?: string) => + ({ + getDBSettings: jest.fn().mockResolvedValue({ pagespeedApiKey: apiKey }), + }) as unknown as jest.Mocked; + +const createProvider = (opts?: { apiKey?: string }) => { + const logger = createMockLogger(); + const httpProvider = createMockHttpProvider(); + const settingsService = createMockSettingsService(opts?.apiKey); + const provider = new PageSpeedProvider(httpProvider, settingsService, logger as any); + return { provider, httpProvider, settingsService, logger }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("PageSpeedProvider", { + create: () => createProvider({ apiKey: "test-key" }).provider, + supportedType: "pagespeed", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("PageSpeedProvider", () => { + it("delegates to httpProvider with PageSpeed API URL", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "my-key" }); + + await provider.handle(makeMonitor({ url: "https://example.com" })); + + expect(httpProvider.handle).toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining("pagespeedonline.googleapis.com"), + }) + ); + }); + + it("includes API key in URL when available", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "my-key" }); + + await provider.handle(makeMonitor()); + + const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url; + expect(calledUrl).toContain("key=my-key"); + }); + + it("omits API key and logs warning when not configured", async () => { + const { provider, httpProvider, logger } = createProvider({ apiKey: undefined }); + + await provider.handle(makeMonitor()); + + const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url; + expect(calledUrl).not.toContain("key="); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("API key not found"), + service: "PageSpeedProvider", + }) + ); + }); + + it("encodes the monitor URL in the PageSpeed API URL", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "key" }); + + await provider.handle(makeMonitor({ url: "https://example.com/path?q=1" })); + + const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url; + expect(calledUrl).toContain(encodeURIComponent("https://example.com/path?q=1")); + }); + + it("includes all PageSpeed categories in the URL", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "key" }); + + await provider.handle(makeMonitor()); + + const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url; + expect(calledUrl).toContain("category=seo"); + expect(calledUrl).toContain("category=accessibility"); + expect(calledUrl).toContain("category=best-practices"); + expect(calledUrl).toContain("category=performance"); + }); + + it("passes all monitor properties through to httpProvider", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "key" }); + const monitor = makeMonitor({ id: "mon-5", teamId: "team-9" }); + + await provider.handle(monitor); + + expect(httpProvider.handle).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-5", teamId: "team-9" })); + }); + + it("throws AppError when url is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for PageSpeed monitor"); + }); + + it("throws AppError when httpProvider.handle throws", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "key" }); + (httpProvider.handle as jest.Mock).mockRejectedValue(new Error("timeout")); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("timeout"); + }); + + it("throws AppError with default message for non-Error thrown values", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "key" }); + (httpProvider.handle as jest.Mock).mockRejectedValue("string error"); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing PageSpeed request"); + }); + + it("handles null apiKey from DB settings", async () => { + const { provider, logger } = createProvider({ apiKey: null as any }); + + await provider.handle(makeMonitor()); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("API key not found") })); + }); + + it("handles null dbSettings response", async () => { + const logger = createMockLogger(); + const httpProvider = createMockHttpProvider(); + const settingsService = { + getDBSettings: jest.fn().mockResolvedValue(null), + } as unknown as jest.Mocked; + const provider = new PageSpeedProvider(httpProvider, settingsService, logger as any); + + await provider.handle(makeMonitor()); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("API key not found") })); + }); +}); diff --git a/server/test/unit/providers/pingProvider.test.ts b/server/test/unit/providers/pingProvider.test.ts new file mode 100644 index 000000000..9c8dbd538 --- /dev/null +++ b/server/test/unit/providers/pingProvider.test.ts @@ -0,0 +1,139 @@ +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"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "ping", + url: "example.com", + ...overrides, + }) as Monitor; + +const createMockPing = (result?: Partial<{ alive: boolean; time: number | string }>) => ({ + promise: { + probe: jest.fn().mockResolvedValue({ + alive: true, + time: 25, + ...result, + }), + }, +}); + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("PingProvider", { + create: () => new PingProvider(createMockPing() as any), + supportedType: "ping", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("PingProvider", () => { + it("returns success when host is alive", async () => { + const provider = new PingProvider(createMockPing({ alive: true, time: 30 }) as any); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "ping", + status: true, + code: 200, + message: "Success", + responseTime: 30, + }) + ); + }); + + it("returns failure when host is not alive", async () => { + const provider = new PingProvider(createMockPing({ alive: false, time: 0 }) as any); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: 5000, + message: "Ping failed", + }) + ); + }); + + it("parses string time values", async () => { + const provider = new PingProvider(createMockPing({ alive: true, time: "42.5" }) as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.responseTime).toBe(42.5); + }); + + it("defaults responseTime to 0 for non-numeric time", async () => { + const provider = new PingProvider(createMockPing({ alive: true, time: "unknown" }) as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.responseTime).toBe(0); + }); + + it("defaults status to false when alive is undefined", async () => { + const provider = new PingProvider(createMockPing({ alive: undefined as any }) as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + }); + + it("sanitizes URL by stripping protocol and path", async () => { + const mockPing = createMockPing(); + const provider = new PingProvider(mockPing as any); + + await provider.handle(makeMonitor({ url: "https://example.com/path?query=1" })); + + expect(mockPing.promise.probe).toHaveBeenCalledWith("example.com"); + }); + + it("sanitizes URL by stripping port", async () => { + const mockPing = createMockPing(); + const provider = new PingProvider(mockPing as any); + + await provider.handle(makeMonitor({ url: "example.com:8080" })); + + expect(mockPing.promise.probe).toHaveBeenCalledWith("example.com"); + }); + + it("throws AppError when url is missing", async () => { + const provider = new PingProvider(createMockPing() as any); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for ping monitor"); + }); + + it("throws AppError when ping.probe rejects", async () => { + const mockPing = { promise: { probe: jest.fn().mockRejectedValue(new Error("Host unreachable")) } }; + const provider = new PingProvider(mockPing as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Host unreachable"); + }); + + it("throws AppError when probe returns no response", async () => { + const mockPing = { promise: { probe: jest.fn().mockResolvedValue(null) } }; + const provider = new PingProvider(mockPing as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("No response from ping"); + }); + + it("throws AppError with stringified message for non-Error throws", async () => { + const mockPing = { promise: { probe: jest.fn().mockRejectedValue(42) } }; + const provider = new PingProvider(mockPing as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("42"); + }); +}); diff --git a/server/test/unit/providers/portProvider.test.ts b/server/test/unit/providers/portProvider.test.ts new file mode 100644 index 000000000..a972f7c9c --- /dev/null +++ b/server/test/unit/providers/portProvider.test.ts @@ -0,0 +1,270 @@ +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"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "port", + url: "example.com", + port: 443, + ...overrides, + }) as Monitor; + +const createMockNet = (behavior: "connect" | "timeout" | "error" = "connect") => { + const socket = { + setTimeout: jest.fn(), + on: jest.fn(), + end: jest.fn(), + destroy: jest.fn(), + }; + + const createConnection = jest.fn((opts: any, onConnect: () => void) => { + if (behavior === "connect") { + // Simulate immediate connection + process.nextTick(onConnect); + } else if (behavior === "timeout") { + process.nextTick(() => { + const timeoutCb = socket.on.mock.calls.find((c: any) => c[0] === "timeout"); + // Simulate via setTimeout handler + const setTimeoutCb = socket.setTimeout.mock.calls[0]; + if (setTimeoutCb) { + // The timeout event handler is registered via socket.on("timeout", ...) + } + }); + } else if (behavior === "error") { + process.nextTick(() => { + const errorCb = socket.on.mock.calls.find((c: any) => c[0] === "error"); + if (errorCb) { + errorCb[1](new Error("ECONNREFUSED")); + } + }); + } + return socket; + }); + + return { createConnection, __socket: socket } as any; +}; + +const createSuccessNet = () => { + const socket = { + setTimeout: jest.fn(), + on: jest.fn(), + end: jest.fn(), + destroy: jest.fn(), + }; + return { + createConnection: jest.fn((_opts: any, onConnect: () => void) => { + process.nextTick(onConnect); + return socket; + }), + } as any; +}; + +const createErrorNet = (error: Error = new Error("ECONNREFUSED")) => { + const socket = { + setTimeout: jest.fn(), + on: jest.fn((event: string, cb: Function) => { + if (event === "error") { + process.nextTick(() => cb(error)); + } + }), + end: jest.fn(), + destroy: jest.fn(), + }; + return { + createConnection: jest.fn(() => socket), + } as any; +}; + +const createTimeoutNet = () => { + const socket = { + setTimeout: jest.fn(), + on: jest.fn((event: string, cb: Function) => { + if (event === "timeout") { + process.nextTick(() => cb()); + } + }), + end: jest.fn(), + destroy: jest.fn(), + }; + return { + createConnection: jest.fn(() => socket), + } as any; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("PortProvider", { + create: () => new PortProvider(createSuccessNet()), + supportedType: "port", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("PortProvider", () => { + it("returns success when port connection succeeds", async () => { + const provider = new PortProvider(createSuccessNet()); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "port", + status: true, + code: 200, + message: "Port check successful", + payload: { success: true }, + }) + ); + expect(result.responseTime).toBeGreaterThanOrEqual(0); + }); + + it("returns failure when connection errors", async () => { + const provider = new PortProvider(createErrorNet()); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "ECONNREFUSED", + payload: { success: false }, + }) + ); + }); + + it("returns failure with generic message for non-Error connection error", async () => { + const net = createErrorNet(); + // Override the on handler to throw a non-Error + const socket = { + setTimeout: jest.fn(), + on: jest.fn((event: string, cb: Function) => { + if (event === "error") { + process.nextTick(() => cb("string error")); + } + }), + end: jest.fn(), + destroy: jest.fn(), + }; + net.createConnection = jest.fn(() => socket); + + const provider = new PortProvider(net); + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("Port check failed"); + }); + + it("returns failure when connection times out", async () => { + const provider = new PortProvider(createTimeoutNet()); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "Connection timeout", + }) + ); + }); + + it("throws AppError when url is missing", async () => { + const provider = new PortProvider(createSuccessNet()); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL and port are required for port monitoring"); + }); + + it("throws AppError when port is missing", async () => { + const provider = new PortProvider(createSuccessNet()); + + await expect(provider.handle(makeMonitor({ port: undefined }))).rejects.toThrow("URL and port are required for port monitoring"); + }); + + it("returns failure with generic message when timeRequest catches non-Error", async () => { + const socket = { + setTimeout: jest.fn(), + on: jest.fn((event: string, cb: Function) => { + if (event === "error") { + process.nextTick(() => cb({ notAnError: true })); + } + }), + end: jest.fn(), + destroy: jest.fn(), + }; + const net = { createConnection: jest.fn(() => socket) } as any; + const provider = new PortProvider(net); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.message).toBe("Port check failed"); + }); + + it("throws AppError wrapping the original Error message", async () => { + const provider = new PortProvider(createSuccessNet()); + + try { + await provider.handle(makeMonitor({ url: "" })); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.service).toBe("PortProvider"); + expect(err.method).toBe("handle"); + } + }); + + it("throws AppError when an unexpected error occurs in outer try", async () => { + const provider = new PortProvider(createSuccessNet()); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw new Error("getter exploded"); + return "example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("getter exploded"); + }); + + it("throws AppError with fallback message when Error has empty message", async () => { + const provider = new PortProvider(createSuccessNet()); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw new Error(""); + return "example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("Error performing port check"); + }); + + it("throws AppError with stringified message for non-Error thrown values", async () => { + const provider = new PortProvider(createSuccessNet()); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw 42; + return "example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("42"); + }); +}); diff --git a/server/test/unit/providers/utils.test.ts b/server/test/unit/providers/utils.test.ts new file mode 100644 index 000000000..64772f432 --- /dev/null +++ b/server/test/unit/providers/utils.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "@jest/globals"; +import { timeRequest, NETWORK_ERROR, PING_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; + +describe("network utils", () => { + describe("timeRequest", () => { + it("returns response and responseTime on success", async () => { + const result = await timeRequest(async () => "hello"); + + expect(result.response).toBe("hello"); + expect(result.responseTime).toBeGreaterThanOrEqual(0); + expect(result.error).toBeNull(); + }); + + it("returns error and responseTime on failure", async () => { + const err = new Error("boom"); + const result = await timeRequest(async () => { + throw err; + }); + + expect(result.response).toBeNull(); + expect(result.responseTime).toBeGreaterThanOrEqual(0); + expect(result.error).toBe(err); + }); + + it("measures elapsed time", async () => { + const result = await timeRequest(async () => { + await new Promise((r) => setTimeout(r, 50)); + return "done"; + }); + + expect(result.responseTime).toBeGreaterThanOrEqual(40); + }); + }); + + describe("constants", () => { + it("NETWORK_ERROR is 5000", () => { + expect(NETWORK_ERROR).toBe(5000); + }); + + it("PING_ERROR is 5001", () => { + expect(PING_ERROR).toBe(5001); + }); + }); +}); diff --git a/server/test/unit/providers/webSocketProvider.test.ts b/server/test/unit/providers/webSocketProvider.test.ts new file mode 100644 index 000000000..d712bb198 --- /dev/null +++ b/server/test/unit/providers/webSocketProvider.test.ts @@ -0,0 +1,194 @@ +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"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "websocket", + url: "wss://example.com/ws", + ignoreTlsErrors: false, + ...overrides, + }) as Monitor; + +const createMockWS = (behavior: "open" | "error" = "open") => { + return jest.fn().mockImplementation((_url: string, _opts: any) => { + const handlers: Record = {}; + const ws = { + on: jest.fn((event: string, cb: Function) => { + handlers[event] = cb; + if (behavior === "open" && event === "open") { + process.nextTick(() => cb()); + } + if (behavior === "error" && event === "error") { + process.nextTick(() => cb(new Error("Connection failed"))); + } + }), + close: jest.fn(), + }; + return ws; + }) as any; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("WebSocketProvider", { + create: () => new WebSocketProvider(createMockWS("open")), + supportedType: "websocket", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("WebSocketProvider", () => { + it("returns success when connection opens", async () => { + const provider = new WebSocketProvider(createMockWS("open")); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "websocket", + status: true, + code: 200, + message: "WebSocket check successful", + payload: { connected: true }, + }) + ); + }); + + it("returns failure when connection errors", async () => { + const provider = new WebSocketProvider(createMockWS("error")); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "Connection failed", + payload: { connected: false }, + }) + ); + }); + + it("returns failure with generic message for non-Error connection error", async () => { + const WS = jest.fn().mockImplementation(() => ({ + on: jest.fn((event: string, cb: Function) => { + if (event === "error") { + process.nextTick(() => cb("string error")); + } + }), + close: jest.fn(), + })) as any; + + const provider = new WebSocketProvider(WS); + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("WebSocket check failed"); + }); + + it("passes rejectUnauthorized: false when ignoreTlsErrors is true", async () => { + const WS = jest.fn().mockImplementation((_url: string, opts: any) => { + expect(opts.rejectUnauthorized).toBe(false); + return { + on: jest.fn((event: string, cb: Function) => { + if (event === "open") process.nextTick(() => cb()); + }), + close: jest.fn(), + }; + }) as any; + + const provider = new WebSocketProvider(WS); + await provider.handle(makeMonitor({ ignoreTlsErrors: true })); + + expect(WS).toHaveBeenCalled(); + }); + + it("throws AppError when url is missing", async () => { + const provider = new WebSocketProvider(createMockWS("open")); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for WebSocket monitoring"); + }); + + it("throws AppError when an unexpected error occurs in outer try", async () => { + const provider = new WebSocketProvider(createMockWS("open")); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw new Error("getter failed"); + return "wss://example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("getter failed"); + }); + + it("throws AppError with fallback message when Error has empty message", async () => { + const provider = new WebSocketProvider(createMockWS("open")); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw new Error(""); + return "wss://example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("Error performing WebSocket check"); + }); + + it("throws AppError with stringified message for non-Error thrown values", async () => { + const provider = new WebSocketProvider(createMockWS("open")); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw 99; + return "wss://example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("99"); + }); + + describe("connection timeout", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it("returns failure on connection timeout", async () => { + // WS that never fires open or error + const WS = jest.fn().mockImplementation(() => ({ + on: jest.fn(), + close: jest.fn(), + })) as any; + + const provider = new WebSocketProvider(WS); + const promise = provider.handle(makeMonitor()); + + await jest.advanceTimersByTimeAsync(10000); + + const result = await promise; + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "WebSocket connection timeout", + }) + ); + }); + }); +}); From a0f73e4e8da5ce61963de0a372f6625c33733b0e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 9 Apr 2026 11:05:21 -0700 Subject: [PATCH 11/23] 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("