diff --git a/server/jest.config.ts b/server/jest.config.ts index fd0c87ad7..be7d359b1 100644 --- a/server/jest.config.ts +++ b/server/jest.config.ts @@ -9,8 +9,7 @@ const config: Config = { }, moduleNameMapper: { "^@/validation/(.*)\\.js$": "/src/validation/$1.js", - "^@/utils/(AppError)\\.js$": "/src/utils/$1.ts", - "^@/utils/(.*)\\.js$": "/src/utils/$1.js", + "^@/utils/(.*)\\.js$": "/src/utils/$1.ts", "^@/(.*)\\.ts$": "/src/$1.ts", "^@/(.*)\\.js$": "/src/$1.ts", "^@/(.*)$": "/src/$1", diff --git a/server/test/helpers/createMockLogger.ts b/server/test/helpers/createMockLogger.ts new file mode 100644 index 000000000..d657a20fa --- /dev/null +++ b/server/test/helpers/createMockLogger.ts @@ -0,0 +1,8 @@ +import { jest } from "@jest/globals"; + +export const createMockLogger = () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +}); diff --git a/server/test/monitorService.test.ts b/server/test/monitorService.test.ts index 32708a42e..fd0097ed9 100644 --- a/server/test/monitorService.test.ts +++ b/server/test/monitorService.test.ts @@ -1,6 +1,13 @@ import { jest } from "@jest/globals"; import { MonitorService } from "../src/service/business/monitorService.ts"; -import type { IChecksRepository, IMonitorStatsRepository, IMonitorsRepository, IStatusPagesRepository } from "../src/repositories/index.ts"; +import type { + IChecksRepository, + IGeoChecksRepository, + IIncidentsRepository, + IMonitorStatsRepository, + IMonitorsRepository, + IStatusPagesRepository, +} from "../src/repositories/index.ts"; const createMonitorsRepositoryMock = () => ({ @@ -8,16 +15,11 @@ const createMonitorsRepositoryMock = () => findByTeamId: jest.fn(), findById: jest.fn(), findMonitorsSummaryByTeamId: jest.fn(), - findGroupsByTeamId: jest.fn(), - create: jest.fn(), - createBulkMonitors: jest.fn(), - deleteByTeamId: jest.fn(), }) as unknown as IMonitorsRepository; const createChecksRepositoryMock = () => ({ - findLatestChecksByMonitorIds: jest.fn(), - findDateRangeChecksByMonitor: jest.fn(), + findByDateRangeAndMonitorId: jest.fn(), }) as unknown as IChecksRepository; const createMonitorStatsRepositoryMock = () => @@ -31,82 +33,82 @@ const createStatusPagesRepositoryMock = () => removeMonitorFromStatusPages: jest.fn(), }) as unknown as IStatusPagesRepository; +const createGeoChecksRepositoryMock = () => ({}) as unknown as IGeoChecksRepository; + +const createIncidentsRepositoryMock = () => ({}) as unknown as IIncidentsRepository; + const createService = ({ monitorsRepository = createMonitorsRepositoryMock(), checksRepository = createChecksRepositoryMock(), monitorStatsRepository = createMonitorStatsRepositoryMock(), statusPagesRepository = createStatusPagesRepositoryMock(), + geoChecksRepository = createGeoChecksRepositoryMock(), + incidentsRepository = createIncidentsRepositoryMock(), }: { monitorsRepository?: IMonitorsRepository; checksRepository?: IChecksRepository; monitorStatsRepository?: IMonitorStatsRepository; statusPagesRepository?: IStatusPagesRepository; + geoChecksRepository?: IGeoChecksRepository; + incidentsRepository?: IIncidentsRepository; } = {}) => { return new MonitorService({ - db: { - checkModule: { deleteChecks: jest.fn() }, - statusPageModule: { deleteStatusPagesByMonitorId: jest.fn() }, - pageSpeedCheckModule: { deletePageSpeedChecksByMonitorId: jest.fn() }, - notificationModule: { deleteNotificationsByMonitorId: jest.fn() }, - }, jobQueue: { addJob: jest.fn(), updateJob: jest.fn(), resumeJob: jest.fn(), pauseJob: jest.fn(), deleteJob: jest.fn(), - }, - stringService: {}, - emailService: { buildEmail: jest.fn(), sendEmail: jest.fn() }, - papaparse: { parse: jest.fn(), unparse: jest.fn() }, - logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, - errorService: { - createAuthorizationError: jest.fn(() => new Error("unauthorized")), - createServerError: jest.fn(() => new Error("server")), - createBadRequestError: jest.fn(() => new Error("bad request")), - createNotFoundError: jest.fn(() => new Error("not found")), - }, + } as any, + emailService: { buildEmail: jest.fn(), sendEmail: jest.fn() } as any, + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn() } as any, games: [], monitorsRepository, checksRepository, + geoChecksRepository, monitorStatsRepository, statusPagesRepository, + incidentsRepository, }); }; describe("MonitorService", () => { describe("getMonitorsWithChecksByTeamId", () => { - it("returns monitors enriched with normalized checks", async () => { + it("returns monitors enriched with normalized recentChecks", async () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(2); + (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ + totalMonitors: 2, + upMonitors: 2, + downMonitors: 0, + pausedMonitors: 0, + }); (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([ - { id: "m1", name: "Monitor 1", interval: 60000 }, - { id: "m2", name: "Monitor 2", interval: 60000 }, + { + id: "m1", + name: "Monitor 1", + type: "http", + interval: 60000, + recentChecks: [ + { responseTime: 10, status: true, message: "OK" }, + { responseTime: 20, status: true, message: "OK" }, + ], + }, + { + id: "m2", + name: "Monitor 2", + type: "http", + interval: 60000, + recentChecks: [{ responseTime: 50, status: true, message: "OK" }], + }, ]); - const checksRepository = createChecksRepositoryMock(); - (checksRepository.findLatestChecksByMonitorIds as jest.Mock).mockResolvedValue({ - m1: [ - { responseTime: 10, status: true, message: "OK" }, - { responseTime: 20, status: true, message: "OK" }, - ], - m2: [{ responseTime: 50, status: true, message: "OK" }], - }); - - const service = createService({ monitorsRepository, checksRepository }); + const service = createService({ monitorsRepository }); const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" }); expect(result).toMatchObject({ count: 2 }); expect(result.monitors).toHaveLength(2); - expect(result.monitors[0]).toHaveProperty("checks"); - expect(result.monitors[0].checks.length).toBeGreaterThan(0); - expect(result.monitors[0].checks[0]).toEqual( - expect.objectContaining({ - responseTime: expect.any(Number), - status: expect.any(Boolean), - message: expect.any(String), - }) - ); + expect(result.monitors[0]).toHaveProperty("recentChecks"); }); }); @@ -120,15 +122,15 @@ describe("MonitorService", () => { const service = createService({ monitorsRepository }); const result = await service.getMonitorsByTeamId({ teamId: "team" } as any); expect(result).toHaveLength(2); - expect(result[0]).toHaveProperty("id", "m1"); + expect(result![0]).toHaveProperty("id", "m1"); }); }); describe("getMonitorsWithChecksByTeamId summary", () => { - it("includes summary and monitors with checks", async () => { + it("includes summary and monitors with recentChecks", async () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.findMonitorCountByTeamIdAndType as jest.Mock).mockResolvedValue(1); - (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([{ id: "m1", type: "http" }]); + (monitorsRepository.findByTeamId as jest.Mock).mockResolvedValue([{ id: "m1", type: "http", recentChecks: [] }]); (monitorsRepository.findMonitorsSummaryByTeamId as jest.Mock).mockResolvedValue({ totalMonitors: 1, upMonitors: 1, @@ -136,15 +138,12 @@ describe("MonitorService", () => { pausedMonitors: 0, }); - const checksRepository = createChecksRepositoryMock(); - (checksRepository.findLatestChecksByMonitorIds as jest.Mock).mockResolvedValue({ m1: [] }); - - const service = createService({ monitorsRepository, checksRepository }); + const service = createService({ monitorsRepository }); const result = await service.getMonitorsWithChecksByTeamId({ teamId: "team" }); expect(result).toEqual({ summary: { totalMonitors: 1, upMonitors: 1, downMonitors: 0, pausedMonitors: 0 }, count: 1, - monitors: [{ id: "m1", type: "http", checks: [] }], + monitors: [{ id: "m1", type: "http", recentChecks: [] }], }); }); }); @@ -155,23 +154,11 @@ describe("MonitorService", () => { const monitor = { id: "monitor-1", teamId: TEAM_ID, - name: "Hardware monitor", + name: "HTTP monitor", interval: 60000, - statusWindow: [], - statusWindowSize: 5, - statusWindowThreshold: 60, type: "http", - ignoreTlsErrors: false, url: "https://example.com", isActive: true, - alertThreshold: 5, - cpuAlertThreshold: 5, - memoryAlertThreshold: 5, - diskAlertThreshold: 5, - tempAlertThreshold: 5, - selectedDisks: [], - notifications: [], - group: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; @@ -179,7 +166,7 @@ describe("MonitorService", () => { const monitorsRepository = createMonitorsRepositoryMock(); (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); const checksRepository = createChecksRepositoryMock(); - (checksRepository.findDateRangeChecksByMonitor as jest.Mock).mockResolvedValue({ + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ monitorType: "http", groupedChecks: [{ _id: "2024-01-01", avgResponseTime: 100, totalChecks: 2 }], groupedUpChecks: [{ _id: "2024-01-01", totalChecks: 2, avgResponseTime: 90 }], diff --git a/server/test/superSimpleQueueHelper.test.ts b/server/test/superSimpleQueueHelper.test.ts index 3b179bd91..25db19dbc 100644 --- a/server/test/superSimpleQueueHelper.test.ts +++ b/server/test/superSimpleQueueHelper.test.ts @@ -1,36 +1,89 @@ import { describe, expect, it, jest } from "@jest/globals"; -import SuperSimpleQueueHelper from "../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.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 createLogger = () => ({ info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() }); - -const createHelper = (overrides?: Partial[0]>) => { +const createHelper = (overrides?: Record) => { const maintenanceWindowsRepository = { findByMonitorId: jest.fn().mockResolvedValue([]), }; - const statusServiceMock = { - updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1" }, statusChanged: true, prevStatus: false }), + const monitorsRepository = { + updateById: jest.fn().mockResolvedValue({}), + findAllMonitorIds: jest.fn().mockResolvedValue(["m1"]), + deleteByTeamIdsNotIn: jest.fn().mockResolvedValue(0), }; - const helper = new SuperSimpleQueueHelper({ - logger: createLogger(), + const teamsRepository = { + findAllTeamIds: jest.fn().mockResolvedValue(["team"]), + }; + const monitorStatsRepository = { + deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), + }; + const checksRepository = { + deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), + }; + const incidentsRepository = { + deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), + }; + const geoChecksRepository = { + deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), + }; + const statusServiceMock = { + updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }), + }; + const settingsServiceMock = { + getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 30 }), + }; + const geoChecksServiceMock = { + buildGeoCheck: jest.fn().mockResolvedValue(null), + }; + + const defaults = { + logger: createMockLogger(), networkService: { requestStatus: jest.fn() }, statusService: statusServiceMock, notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) }, - checkService: { buildCheck: jest.fn().mockResolvedValue({}) }, - buffer: { addToBuffer: jest.fn() }, + checkService: { buildCheck: jest.fn().mockReturnValue({}), deleteOlderThan: jest.fn().mockResolvedValue(0) }, + settingsService: settingsServiceMock, + buffer: { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn() }, incidentService: { handleIncident: jest.fn().mockResolvedValue(undefined) }, maintenanceWindowsRepository, + monitorsRepository, + teamsRepository, + monitorStatsRepository, + checksRepository, + incidentsRepository, + geoChecksService: geoChecksServiceMock, + geoChecksRepository, ...overrides, - }); - return { helper, maintenanceWindowsRepository }; + }; + + const helper = new SuperSimpleQueueHelper( + defaults.logger as any, + defaults.networkService as any, + defaults.statusService as any, + defaults.notificationsService as any, + defaults.checkService as any, + defaults.settingsService as any, + defaults.buffer as any, + defaults.incidentService as any, + defaults.maintenanceWindowsRepository as any, + defaults.monitorsRepository as any, + defaults.teamsRepository as any, + defaults.monitorStatsRepository as any, + defaults.checksRepository as any, + defaults.incidentsRepository as any, + defaults.geoChecksService as any, + defaults.geoChecksRepository as any + ); + return { helper, maintenanceWindowsRepository, defaults }; }; describe("SuperSimpleQueueHelper", () => { - describe("getMonitorJob", () => { + describe("getHeartbeatJob", () => { it("skips execution when monitor is in maintenance window", async () => { const { helper } = createHelper(); const spy = jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); - const job = helper.getMonitorJob(); + 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( @@ -40,17 +93,16 @@ describe("SuperSimpleQueueHelper", () => { }); it("processes monitor status and notifications when active", async () => { - const networkResponse = { monitor: { id: "m1" }, status: true }; - const updatedMonitor = { id: "m1", status: true }; + 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: { - updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: updatedMonitor, statusChanged: true, prevStatus: false, code: 200 }), - }, - notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) }, + statusService: statusServiceMock, }); jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getMonitorJob(); + const job = helper.getHeartbeatJob(); const monitor = { id: "m1", teamId: "team" } as Monitor; await job(monitor); expect(helper["networkService"].requestStatus).toHaveBeenCalledWith(monitor); @@ -58,7 +110,7 @@ describe("SuperSimpleQueueHelper", () => { it("throws when monitor id is missing", async () => { const { helper } = createHelper(); - const job = helper.getMonitorJob(); + const job = helper.getHeartbeatJob(); await expect(job({} as Monitor)).rejects.toThrow("No monitor id"); expect(helper["logger"].warn).toHaveBeenCalled(); }); diff --git a/server/test/teamsProvider.test.ts b/server/test/teamsProvider.test.ts index e6104f568..d63324f9b 100644 --- a/server/test/teamsProvider.test.ts +++ b/server/test/teamsProvider.test.ts @@ -1,19 +1,15 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; -import { TeamsProvider } from "../src/service/infrastructure/notificationProviders/teams.ts"; import type { Notification } from "../src/types/notification.ts"; import type { NotificationMessage } from "../src/types/notificationMessage.ts"; +import { createMockLogger } from "./helpers/createMockLogger.ts"; -// Mock got +const mockPost = jest.fn(); jest.unstable_mockModule("got", () => ({ - default: { post: jest.fn() }, + default: { post: mockPost }, + HTTPError: class HTTPError extends Error {}, })); -const createLogger = () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -}); +const { TeamsProvider } = await import("../src/service/infrastructure/notificationProviders/teams.ts"); const createNotification = (overrides?: Partial): Notification => ({ id: "notif-1", @@ -58,17 +54,14 @@ const createMessage = (overrides?: Partial): NotificationMe }); describe("TeamsProvider", () => { - let provider: TeamsProvider; - let logger: ReturnType; - let gotPost: jest.Mock; + let provider: InstanceType; + let logger: ReturnType; - beforeEach(async () => { - logger = createLogger(); + beforeEach(() => { + logger = createMockLogger(); provider = new TeamsProvider(logger); - const got = await import("got"); - gotPost = got.default.post as jest.Mock; - gotPost.mockReset(); - gotPost.mockResolvedValue({}); + mockPost.mockReset(); + mockPost.mockResolvedValue({}); }); describe("sendTestAlert", () => { @@ -76,35 +69,33 @@ describe("TeamsProvider", () => { const notification = createNotification({ address: undefined }); const result = await provider.sendTestAlert(notification); expect(result).toBe(false); - expect(gotPost).not.toHaveBeenCalled(); + expect(mockPost).not.toHaveBeenCalled(); }); it("sends an Adaptive Card test message and returns true on success", async () => { const notification = createNotification(); const result = await provider.sendTestAlert(notification); expect(result).toBe(true); - expect(gotPost).toHaveBeenCalledTimes(1); + expect(mockPost).toHaveBeenCalledTimes(1); - const [url, options] = gotPost.mock.calls[0] as [string, { json: any; headers: any }]; + const [url, options] = mockPost.mock.calls[0] as [string, { json: any; headers: any }]; expect(url).toBe(notification.address); expect(options.headers["Content-Type"]).toBe("application/json"); - // Verify Teams webhook envelope const payload = options.json; expect(payload.type).toBe("message"); expect(payload.attachments).toHaveLength(1); expect(payload.attachments[0].contentType).toBe("application/vnd.microsoft.card.adaptive"); - // Verify Adaptive Card structure const card = payload.attachments[0].content; expect(card.type).toBe("AdaptiveCard"); expect(card.version).toBe("1.4"); expect(card.body[0].type).toBe("TextBlock"); - expect(card.body[0].text).toContain("test notification"); + expect(card.body[0].text).toBe("This is a test notification from Checkmate"); }); it("returns false and logs warning on HTTP error", async () => { - gotPost.mockRejectedValue(new Error("Network error")); + mockPost.mockRejectedValue(new Error("Network error")); const notification = createNotification(); const result = await provider.sendTestAlert(notification); expect(result).toBe(false); @@ -122,7 +113,7 @@ describe("TeamsProvider", () => { const notification = createNotification({ address: undefined }); const result = await provider.sendMessage(notification, createMessage()); expect(result).toBe(false); - expect(gotPost).not.toHaveBeenCalled(); + expect(mockPost).not.toHaveBeenCalled(); }); it("sends a well-formed Adaptive Card and returns true", async () => { @@ -131,9 +122,9 @@ describe("TeamsProvider", () => { const result = await provider.sendMessage(notification, message); expect(result).toBe(true); - expect(gotPost).toHaveBeenCalledTimes(1); + expect(mockPost).toHaveBeenCalledTimes(1); - const [url, options] = gotPost.mock.calls[0] as [string, { json: any }]; + const [url, options] = mockPost.mock.calls[0] as [string, { json: any }]; expect(url).toBe(notification.address); const payload = options.json; @@ -143,18 +134,13 @@ describe("TeamsProvider", () => { expect(card.type).toBe("AdaptiveCard"); expect(card.version).toBe("1.4"); - // Verify body contains expected elements const textBlocks = card.body.filter((b: any) => b.type === "TextBlock"); const factSets = card.body.filter((b: any) => b.type === "FactSet"); - // Title block expect(textBlocks[0].text).toBe(message.content.title); - expect(textBlocks[0].color).toBe("attention"); // critical -> attention - - // Summary block + expect(textBlocks[0].color).toBe("attention"); expect(textBlocks[1].text).toBe(message.content.summary); - // FactSet with monitor details expect(factSets).toHaveLength(1); const facts = factSets[0].facts; expect(facts).toEqual( @@ -165,11 +151,10 @@ describe("TeamsProvider", () => { ]) ); - // Incident action button expect(card.actions).toHaveLength(1); expect(card.actions[0].type).toBe("Action.OpenUrl"); expect(card.actions[0].title).toBe("View Incident"); - expect(card.actions[0].url).toContain("/infrastructure/mon-1"); + expect(card.actions[0].url).toContain("/incidents/inc-1"); }); it("omits actions when no incident is present", async () => { @@ -184,7 +169,7 @@ describe("TeamsProvider", () => { const result = await provider.sendMessage(notification, message); expect(result).toBe(true); - const [, options] = gotPost.mock.calls[0] as [string, { json: any }]; + const [, options] = mockPost.mock.calls[0] as [string, { json: any }]; const card = options.json.attachments[0].content; expect(card.actions).toBeUndefined(); }); @@ -212,7 +197,7 @@ describe("TeamsProvider", () => { await provider.sendMessage(notification, message); - const [, options] = gotPost.mock.calls[0] as [string, { json: any }]; + const [, options] = mockPost.mock.calls[0] as [string, { json: any }]; const card = options.json.attachments[0].content; // Title should use "warning" color @@ -222,7 +207,7 @@ describe("TeamsProvider", () => { const thresholdHeader = card.body.find((b: any) => b.type === "TextBlock" && b.text === "**Threshold Breaches**"); expect(thresholdHeader).toBeDefined(); - const cpuBlock = card.body.find((b: any) => b.type === "TextBlock" && b.text?.includes("CPU")); + const cpuBlock = card.body.find((b: any) => b.type === "TextBlock" && b.text?.includes("**CPU**")); expect(cpuBlock).toBeDefined(); expect(cpuBlock.text).toContain("95%"); expect(cpuBlock.text).toContain("threshold: 80%"); @@ -239,20 +224,20 @@ describe("TeamsProvider", () => { ] as const; for (const { severity, expected } of severityMap) { - gotPost.mockReset(); - gotPost.mockResolvedValue({}); + mockPost.mockReset(); + mockPost.mockResolvedValue({}); const message = createMessage({ severity }); await provider.sendMessage(notification, message); - const [, options] = gotPost.mock.calls[0] as [string, { json: any }]; + const [, options] = mockPost.mock.calls[0] as [string, { json: any }]; const card = options.json.attachments[0].content; expect(card.body[0].color).toBe(expected); } }); it("returns false and logs warning on HTTP error", async () => { - gotPost.mockRejectedValue(new Error("502 Bad Gateway")); + mockPost.mockRejectedValue(new Error("502 Bad Gateway")); const notification = createNotification(); const result = await provider.sendMessage(notification, createMessage()); expect(result).toBe(false);