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 = {