diff --git a/server/src/services/business/UserService.ts b/server/src/services/business/UserService.ts index 89458e654..26913e4e2 100644 --- a/server/src/services/business/UserService.ts +++ b/server/src/services/business/UserService.ts @@ -1,7 +1,6 @@ import { IUser, IUserProfile, User } from "@/db/models/index.js"; import ApiError from "@/utils/ApiError.js"; import { hashPassword } from "@/utils/JWTUtils.js"; -import { object } from "joi"; const SERVICE_NAME = "UserService"; export interface IUserService { diff --git a/server/src/services/infrastructure/NotificationServices/Webhook.ts b/server/src/services/infrastructure/NotificationServices/Webhook.ts index 09f81882f..88931a870 100644 --- a/server/src/services/infrastructure/NotificationServices/Webhook.ts +++ b/server/src/services/infrastructure/NotificationServices/Webhook.ts @@ -1,4 +1,8 @@ -import { IMonitor, INotificationChannel } from "@/db/models/index.js"; +import { + IMonitor, + INotificationChannel, + IIncident, +} from "@/db/models/index.js"; import { IAlert, IMessageService } from "./IMessageService.js"; import ApiError from "@/utils/ApiError.js"; import got from "got"; @@ -13,7 +17,7 @@ class WebhookService implements IMessageService { this.SERVICE_NAME = SERVICE_NAME; } - buildAlert = (monitor: IMonitor) => { + buildAlert = (monitor: IMonitor, incident: IIncident) => { const name = monitor?.name || "Unnamed monitor"; const monitorStatus = monitor?.status || "unknown status"; const url = monitor?.url || "no URL"; @@ -23,6 +27,10 @@ class WebhookService implements IMessageService { name, url, status: monitorStatus, + resolved: incident.resolved, + resolutionType: incident.resolutionType, + resolvedBy: incident.resolvedBy?.toString(), + resolutionNote: incident.resolutionNote, checkTime, alertTime, }; @@ -51,6 +59,10 @@ class WebhookService implements IMessageService { status: "Test status", checkTime: new Date(), alertTime: new Date(), + resolved: true, + resolutionType: "auto", + resolvedBy: "system", + resolutionNote: "This is a test message", }, channel ); diff --git a/server/tests/services/infrastructure/NetworkService.test.js b/server/tests/services/infrastructure/NetworkService.test.js new file mode 100644 index 000000000..14c119192 --- /dev/null +++ b/server/tests/services/infrastructure/NetworkService.test.js @@ -0,0 +1,495 @@ +import NetworkService from "@/services/infrastructure/NetworkService.js"; +import { Types } from "mongoose"; +import ping from "ping"; +jest.mock("@/config/index.js", () => ({ + config: { PAGESPEED_API_KEY: "test-key" }, +})); +const configModule = jest.requireMock("@/config/index.js"); +jest.mock("got", () => { + const extend = jest.fn(); + class MockHTTPError extends Error { + response; + timings; + constructor(message, options = {}) { + super(message); + this.response = options.response; + this.timings = options.timings; + } + } + return { + __esModule: true, + default: { extend }, + HTTPError: MockHTTPError, + }; +}); +jest.mock("cacheable-lookup", () => ({ + __esModule: true, + default: class CacheableLookup { + }, +})); +jest.mock("ping", () => ({ + promise: { probe: jest.fn() }, +})); +const getGotModule = () => jest.requireMock("got"); +const buildMonitor = (overrides = {}) => ({ + _id: new Types.ObjectId(), + teamId: new Types.ObjectId(), + type: "http", + url: "https://example.com", + secret: "secret", + interval: 60000, + isActive: true, + status: "up", + n: 1, + latestChecks: [], + save: jest.fn(), + createdBy: new Types.ObjectId(), + updatedBy: new Types.ObjectId(), + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}); +const createService = (clientMock) => { + const gotModule = getGotModule(); + const extendMock = gotModule.default.extend; + const requestMock = clientMock ?? jest.fn(); + extendMock.mockReturnValue(requestMock); + const service = new NetworkService(gotModule.default); + return { service, requestMock, extendMock, gotModule }; +}; +const invokeBuildStatusResponse = (service, monitor, response, error) => service.buildStatusResponse(monitor, response, error); +beforeEach(() => { + const gotModule = getGotModule(); + gotModule.default.extend.mockReset(); + ping.promise.probe.mockReset(); + configModule.config.PAGESPEED_API_KEY = "test-key"; +}); +beforeEach(() => { + const gotModule = getGotModule(); + gotModule.default.extend.mockReset(); + ping.promise.probe.mockReset(); + configModule.config.PAGESPEED_API_KEY = "test-key"; +}); +describe("NetworkService.buildStatusResponse", () => { + it("maps successful response details", () => { + const { service } = createService(); + const timings = { + phases: { total: 321 }, + }; + const response = { + statusCode: 201, + statusMessage: "Accepted", + ok: true, + timings, + }; + const monitor = buildMonitor(); + const result = invokeBuildStatusResponse(service, monitor, response, null); + expect(result.monitorId).toBe(monitor._id.toString()); + expect(result.teamId).toBe(monitor.teamId.toString()); + expect(result.type).toBe(monitor.type); + expect(result.code).toBe(201); + expect(result.status).toBe("up"); + expect(result.message).toBe("Accepted"); + expect(result.responseTime).toBe(321); + expect(result.timings).toBe(timings); + }); + it("returns default error payload for generic failures", () => { + const { service } = createService(); + const monitor = buildMonitor(); + const error = new Error("timeout"); + const result = invokeBuildStatusResponse(service, monitor, null, error); + expect(result.monitorId).toBe(monitor._id.toString()); + expect(result.status).toBe("down"); + expect(result.code).toBe(5000); + expect(result.message).toBe("timeout"); + expect(result.responseTime).toBe(0); + expect(result.timings).toEqual({ phases: {} }); + }); + it("returns default error payload for generic failures without message", () => { + const { service } = createService(); + const monitor = buildMonitor(); + const error = new Error("timeout"); + error.message = ""; + const result = invokeBuildStatusResponse(service, monitor, null, error); + expect(result.monitorId).toBe(monitor._id.toString()); + expect(result.status).toBe("down"); + expect(result.code).toBe(5000); + expect(result.message).toBe("Network error"); + expect(result.responseTime).toBe(0); + expect(result.timings).toEqual({ phases: {} }); + }); + it("honors HTTPError metadata", () => { + const { service, gotModule } = createService(); + const { HTTPError } = gotModule; + const monitor = buildMonitor(); + const timings = { phases: { total: 987 } }; + const httpError = new HTTPError("Bad Gateway", { + response: { statusCode: 502 }, + timings, + }); + const result = invokeBuildStatusResponse(service, monitor, null, httpError); + expect(result.status).toBe("down"); + expect(result.code).toBe(502); + expect(result.message).toBe("Bad Gateway"); + expect(result.responseTime).toBe(987); + expect(result.timings).toBe(timings); + }); +}); +describe("NetworkService.requestHttp", () => { + it("requests monitor URL and maps successful response", async () => { + const timings = { phases: { total: 123 } }; + const response = { + statusCode: 204, + statusMessage: "No Content", + ok: true, + timings, + }; + const requestMock = jest.fn().mockResolvedValue(response); + const { service, extendMock } = createService(requestMock); + const monitor = buildMonitor({ url: "https://api.example.com" }); + const result = await service.requestHttp(monitor); + expect(extendMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith("https://api.example.com"); + expect(result.status).toBe("up"); + expect(result.code).toBe(204); + expect(result.message).toBe("No Content"); + expect(result.responseTime).toBe(123); + expect(result.timings).toBe(timings); + }); + it("throws when monitor is missing a URL", async () => { + const requestMock = jest.fn(); + const { service, extendMock } = createService(requestMock); + const monitor = buildMonitor({ url: undefined }); + await expect(service.requestHttp(monitor)).rejects.toThrow("No URL provided"); + expect(extendMock).toHaveBeenCalledTimes(1); + expect(requestMock).not.toHaveBeenCalled(); + }); + it("returns fallback response when request throws", async () => { + const error = new Error("connection reset"); + const requestMock = jest.fn().mockRejectedValue(error); + const { service, extendMock } = createService(requestMock); + const monitor = buildMonitor({ url: "https://api.example.com" }); + const result = await service.requestHttp(monitor); + expect(extendMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith("https://api.example.com"); + expect(result.status).toBe("down"); + expect(result.code).toBe(5000); + expect(result.message).toBe("connection reset"); + expect(result.responseTime).toBe(0); + expect(result.timings).toEqual({ phases: {} }); + }); +}); +// requestInfrastructure block covers success path and every guard/exception branch so all lines execute. +describe("NetworkService.requestInfrastructure", () => { + it("requests infrastructure endpoint with auth header and attaches payload", async () => { + const body = { data: { cpu: 10 }, capture: { version: "1" } }; + const timings = { phases: { total: 456 } }; + const response = { + statusCode: 200, + statusMessage: "OK", + ok: true, + timings, + body, + }; + const requestMock = jest.fn().mockResolvedValue(response); + const { service, extendMock } = createService(requestMock); + const monitor = buildMonitor({ + type: "infrastructure", + url: "https://infra.example.com", + secret: "topsecret", + }); + const result = await service.requestInfrastructure(monitor); + expect(extendMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith("https://infra.example.com", { + headers: { Authorization: "Bearer topsecret" }, + responseType: "json", + }); + expect(result.status).toBe("up"); + expect(result.code).toBe(200); + expect(result.message).toBe("OK"); + expect(result.responseTime).toBe(456); + expect(result.payload).toBe(body); + }); + it("throws when monitor is missing a URL", async () => { + const requestMock = jest.fn(); + const { service, extendMock } = createService(requestMock); + const monitor = buildMonitor({ + type: "infrastructure", + url: undefined, + }); + await expect(service.requestInfrastructure(monitor)).rejects.toThrow("No URL provided"); + expect(extendMock).toHaveBeenCalledTimes(1); + expect(requestMock).not.toHaveBeenCalled(); + }); + it("throws when monitor is missing a secret", async () => { + const requestMock = jest.fn(); + const { service, extendMock } = createService(requestMock); + const monitor = buildMonitor({ + type: "infrastructure", + secret: undefined, + }); + await expect(service.requestInfrastructure(monitor)).rejects.toThrow("No secret provided for infrastructure monitor"); + expect(extendMock).toHaveBeenCalledTimes(1); + expect(requestMock).not.toHaveBeenCalled(); + }); + it("returns fallback response when infrastructure payload missing", async () => { + const response = { + statusCode: 200, + statusMessage: "OK", + ok: true, + timings: { phases: { total: 111 } }, + body: undefined, + }; + const requestMock = jest.fn().mockResolvedValue(response); + const { service, extendMock } = createService(requestMock); + const monitor = buildMonitor({ + type: "infrastructure", + url: "https://infra.example.com", + secret: "secret", + }); + const result = await service.requestInfrastructure(monitor); + expect(extendMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith("https://infra.example.com", { + headers: { Authorization: "Bearer secret" }, + responseType: "json", + }); + expect(result.status).toBe("down"); + expect(result.code).toBe(5000); + expect(result.message).toBe("No payload received from infrastructure monitor"); + }); + it("returns fallback response when infrastructure request rejects", async () => { + const error = new Error("timeout"); + const requestMock = jest.fn().mockRejectedValue(error); + const { service, extendMock } = createService(requestMock); + const monitor = buildMonitor({ + type: "infrastructure", + url: "https://infra.example.com", + secret: "secret", + }); + const result = await service.requestInfrastructure(monitor); + expect(extendMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith("https://infra.example.com", { + headers: { Authorization: "Bearer secret" }, + responseType: "json", + }); + expect(result.status).toBe("down"); + expect(result.message).toBe("timeout"); + expect(result.code).toBe(5000); + }); +}); +describe("NetworkService.requestPagespeed", () => { + const pagespeedUrl = "https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://site.example.com&category=seo&category=accessibility&category=best-practices&category=performance&key=test-key"; + it("requests pagespeed URL and attaches payload", async () => { + const primaryResponse = { + statusCode: 200, + statusMessage: "OK", + ok: true, + timings: { phases: { total: 222 } }, + }; + const payload = { lighthouseResult: { score: 0.9 } }; + const secondaryResponse = { body: payload }; + const requestMock = jest + .fn() + .mockResolvedValueOnce(primaryResponse) + .mockResolvedValueOnce(secondaryResponse); + const { service, extendMock } = createService(requestMock); + const monitor = buildMonitor({ + type: "pagespeed", + url: "https://site.example.com", + }); + const result = await service.requestPagespeed(monitor); + expect(extendMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenNthCalledWith(1, "https://site.example.com"); + expect(requestMock).toHaveBeenNthCalledWith(2, pagespeedUrl, { + responseType: "json", + }); + expect(result.status).toBe("up"); + expect(result.payload).toBe(payload); + }); + it("throws when pagespeed API key missing", async () => { + configModule.config.PAGESPEED_API_KEY = ""; + const requestMock = jest.fn(); + const { service, extendMock } = createService(requestMock); + const monitor = buildMonitor({ type: "pagespeed" }); + await expect(service.requestPagespeed(monitor)).rejects.toThrow("No API key provided for pagespeed monitor"); + expect(extendMock).toHaveBeenCalledTimes(1); + expect(requestMock).not.toHaveBeenCalled(); + }); + it("throws when monitor lacks URL", async () => { + const requestMock = jest.fn(); + const { service, extendMock } = createService(requestMock); + const monitor = buildMonitor({ + type: "pagespeed", + url: undefined, + }); + await expect(service.requestPagespeed(monitor)).rejects.toThrow("No URL provided"); + expect(extendMock).toHaveBeenCalledTimes(1); + expect(requestMock).not.toHaveBeenCalled(); + }); + it("returns fallback response when initial request fails", async () => { + const error = new Error("timeout"); + const payload = { lighthouseResult: { score: 0.8 } }; + const requestMock = jest + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce({ body: payload }); + const { service, extendMock } = createService(requestMock); + const monitor = buildMonitor({ + type: "pagespeed", + url: "https://s", + }); + const result = await service.requestPagespeed(monitor); + expect(extendMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenNthCalledWith(1, "https://s"); + expect(result.status).toBe("down"); + expect(result.message).toBe("timeout"); + expect(result.payload).toBe(payload); + }); + it("throws ApiError when pagespeed payload missing", async () => { + const primaryResponse = { + statusCode: 200, + statusMessage: "OK", + ok: true, + timings: { phases: { total: 111 } }, + }; + const requestMock = jest + .fn() + .mockResolvedValueOnce(primaryResponse) + .mockResolvedValueOnce({ body: undefined }); + const { service, extendMock } = createService(requestMock); + const monitor = buildMonitor({ + type: "pagespeed", + url: "https://site.example.com", + }); + await expect(service.requestPagespeed(monitor)).rejects.toThrow("No payload received from pagespeed monitor"); + expect(extendMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenNthCalledWith(2, pagespeedUrl, { + responseType: "json", + }); + }); +}); +describe("NetworkService.requestPing", () => { + it("maps successful ping with string time", async () => { + ping.promise.probe.mockResolvedValue({ + alive: true, + time: "12.5", + }); + const { service } = createService(); + const monitor = buildMonitor({ type: "ping", url: "8.8.8.8" }); + const result = await service.requestPing(monitor); + expect(ping.promise.probe).toHaveBeenCalledWith("8.8.8.8"); + expect(result.status).toBe("up"); + expect(result.responseTime).toBeCloseTo(12.5); + expect(result.timings?.phases).toEqual({}); + }); + it("maps failure ping with invalid time to zero", async () => { + ping.promise.probe.mockResolvedValue({ + alive: false, + time: "NaN", + }); + const { service } = createService(); + const monitor = buildMonitor({ type: "ping", url: "1.1.1.1" }); + const result = await service.requestPing(monitor); + expect(result.status).toBe("down"); + expect(result.responseTime).toBe(0); + expect(result.message).toBe("Ping successful"); + }); + it("handles numeric ping times", async () => { + ping.promise.probe.mockResolvedValue({ + alive: true, + time: 18, + }); + const { service } = createService(); + const monitor = buildMonitor({ type: "ping", url: "9.9.9.9" }); + const result = await service.requestPing(monitor); + expect(result.responseTime).toBe(18); + expect(result.status).toBe("up"); + }); +}); +describe("NetworkService.requestStatus", () => { + it("routes http monitors through requestHttp", async () => { + const { service } = createService(); + const httpSpy = jest.spyOn(service, "requestHttp").mockResolvedValueOnce({ + monitorId: "1", + teamId: "t", + type: "http", + status: "up", + message: "OK", + responseTime: 1, + timings: { phases: {} }, + }); + const monitor = buildMonitor({ type: "http" }); + const result = await service.requestStatus(monitor); + expect(httpSpy).toHaveBeenCalledTimes(1); + expect(result.status).toBe("up"); + }); + it("routes https monitors through requestHttp", async () => { + const { service } = createService(); + const httpSpy = jest.spyOn(service, "requestHttp").mockResolvedValueOnce({ + monitorId: "2", + teamId: "t", + type: "https", + status: "up", + message: "OK", + responseTime: 2, + timings: { phases: {} }, + }); + const monitor = buildMonitor({ type: "https" }); + await service.requestStatus(monitor); + expect(httpSpy).toHaveBeenCalledTimes(1); + }); + it("routes infrastructure monitors through requestInfrastructure", async () => { + const { service } = createService(); + const infraSpy = jest + .spyOn(service, "requestInfrastructure") + .mockResolvedValueOnce({ + monitorId: "3", + teamId: "t", + type: "infrastructure", + status: "up", + message: "OK", + responseTime: 3, + timings: { phases: {} }, + }); + const monitor = buildMonitor({ type: "infrastructure" }); + await service.requestStatus(monitor); + expect(infraSpy).toHaveBeenCalledTimes(1); + }); + it("routes pagespeed monitors through requestPagespeed", async () => { + const { service } = createService(); + const pageSpy = jest + .spyOn(service, "requestPagespeed") + .mockResolvedValueOnce({ + monitorId: "4", + teamId: "t", + type: "pagespeed", + status: "up", + message: "OK", + responseTime: 4, + timings: { phases: {} }, + }); + const monitor = buildMonitor({ type: "pagespeed" }); + await service.requestStatus(monitor); + expect(pageSpy).toHaveBeenCalledTimes(1); + }); + it("routes ping monitors through requestPing", async () => { + const { service } = createService(); + const pingSpy = jest.spyOn(service, "requestPing").mockResolvedValueOnce({ + monitorId: "5", + teamId: "t", + type: "ping", + status: "down", + message: "Timeout", + responseTime: 0, + timings: { phases: {} }, + }); + const monitor = buildMonitor({ type: "ping" }); + await service.requestStatus(monitor); + expect(pingSpy).toHaveBeenCalledTimes(1); + }); + it("throws for unsupported monitor types", async () => { + const { service } = createService(); + const monitor = buildMonitor({ type: "websocket" }); + await expect(service.requestStatus(monitor)).rejects.toThrow("Not implemented"); + }); +}); diff --git a/server/tests/services/infrastructure/StatusService.test.js b/server/tests/services/infrastructure/StatusService.test.js new file mode 100644 index 000000000..cffcdafff --- /dev/null +++ b/server/tests/services/infrastructure/StatusService.test.js @@ -0,0 +1,244 @@ +import StatusService from "@/services/infrastructure/StatusService.js"; +import { MonitorStats } from "@/db/models/index.js"; +import ApiError from "@/utils/ApiError.js"; +import { Types } from "mongoose"; +const buildStats = (overrides = {}) => { + const saveMock = overrides.save || + jest.fn().mockImplementation(async function () { + return this; + }); + return { + monitorId: {}, + avgResponseTime: 0, + maxResponseTime: 0, + totalChecks: 1, + totalUpChecks: 0, + totalDownChecks: 0, + uptimePercentage: 0, + lastCheckTimestamp: 0, + lastResponseTime: 0, + timeOfLastFailure: 0, + currentStreak: 0, + currentStreakStatus: "up", + currentStreakStartedAt: 0, + createdAt: new Date(), + updatedAt: new Date(), + save: saveMock, + ...overrides, + }; +}; +const buildStatusResponse = (overrides = {}) => ({ + monitorId: "monitor-id", + teamId: "team-id", + type: "http", + status: "up", + message: "OK", + responseTime: 0, + ...overrides, +}); +const buildMonitor = (overrides) => { + const monitor = { + _id: new Types.ObjectId(), + teamId: "team-id", + status: "up", + latestChecks: [], + n: 1, + save: jest.fn(), + ...overrides, + }; + if (!monitor.latestChecks) { + monitor.latestChecks = []; + } + monitor.save.mockImplementation(async () => monitor); + return { + monitor: monitor, + saveMock: monitor.save, + }; +}; +describe("StatusService.calculateAvgResponseTime", () => { + const service = new StatusService(); + it("returns latest response time when average is unset", () => { + const stats = buildStats({ avgResponseTime: 0, totalChecks: 1 }); + const statusResponse = buildStatusResponse({ responseTime: 250 }); + const result = service.calculateAvgResponseTime(stats, statusResponse); + expect(result).toBe(250); + }); + it("computes rolling average when checks exist", () => { + const stats = buildStats({ avgResponseTime: 120, totalChecks: 5 }); + const statusResponse = buildStatusResponse({ responseTime: 300 }); + const result = service.calculateAvgResponseTime(stats, statusResponse); + expect(result).toBeCloseTo(156); + }); +}); +describe("StatusService.updateMonitorStatus", () => { + const service = new StatusService(); + it("initializing monitor adopts first status", async () => { + const { monitor, saveMock } = buildMonitor({ + status: "initializing", + latestChecks: [], + }); + const statusResponse = buildStatusResponse({ status: "up", responseTime: 120 }); + const [savedMonitor, statusChanged] = await service.updateMonitorStatus(monitor, statusResponse); + expect(saveMock).toHaveBeenCalledTimes(1); + expect(monitor.status).toBe("up"); + expect(monitor.latestChecks).toHaveLength(1); + expect(monitor.latestChecks?.[0]?.status).toBe("up"); + expect(monitor.lastCheckedAt).toBeInstanceOf(Date); + expect(savedMonitor).toBe(monitor); + expect(statusChanged).toBe(false); + }); + it("returns early when not enough checks accumulated", async () => { + const { monitor, saveMock } = buildMonitor({ + status: "up", + n: 3, + latestChecks: [], + }); + const statusResponse = buildStatusResponse({ status: "down", responseTime: 90 }); + const [, statusChanged] = await service.updateMonitorStatus(monitor, statusResponse); + expect(saveMock).toHaveBeenCalledTimes(1); + expect(monitor.status).toBe("up"); + expect(monitor.latestChecks).toHaveLength(1); + expect(statusChanged).toBe(false); + }); + it("updates status when last n checks differ", async () => { + const { monitor, saveMock } = buildMonitor({ + status: "up", + n: 2, + latestChecks: [ + { + status: "down", + responseTime: 80, + checkedAt: new Date("2024-01-01"), + }, + ], + }); + const statusResponse = buildStatusResponse({ status: "down", responseTime: 70 }); + const [, statusChanged] = await service.updateMonitorStatus(monitor, statusResponse); + expect(saveMock).toHaveBeenCalledTimes(1); + expect(monitor.status).toBe("down"); + expect(statusChanged).toBe(true); + expect(monitor.latestChecks.slice(-2).every((c) => c.status === "down")).toBe(true); + }); + it("keeps only latest 25 checks", async () => { + const existingChecks = Array.from({ length: 25 }, (_, index) => ({ + status: "up", + responseTime: index, + checkedAt: new Date(`2024-01-${index + 1}`), + })); + const { monitor, saveMock } = buildMonitor({ + status: "up", + n: 1, + latestChecks: [...existingChecks], + }); + const statusResponse = buildStatusResponse({ status: "up", responseTime: 200 }); + await service.updateMonitorStatus(monitor, statusResponse); + expect(saveMock).toHaveBeenCalledTimes(1); + expect(monitor.latestChecks).toHaveLength(25); + expect(monitor.latestChecks?.[0]?.responseTime).toBe(1); + expect(monitor.latestChecks?.some((check) => check.responseTime === 0)).toBe(false); + const lastCheck = monitor.latestChecks?.[monitor.latestChecks.length - 1] ?? null; + expect(lastCheck?.responseTime).toBe(200); + }); + it("initializes latestChecks when missing", async () => { + const { monitor, saveMock } = buildMonitor({}); + delete monitor.latestChecks; + const statusResponse = buildStatusResponse({ status: "up", responseTime: 75 }); + const [, statusChanged] = await service.updateMonitorStatus(monitor, statusResponse); + expect(saveMock).toHaveBeenCalledTimes(1); + expect(Array.isArray(monitor.latestChecks)).toBe(true); + expect(monitor.latestChecks).toHaveLength(1); + expect(statusChanged).toBe(false); + }); +}); +describe("StatusService.updateMonitorStats", () => { + const service = new StatusService(); + let findOneSpy; + beforeEach(() => { + findOneSpy = jest.spyOn(MonitorStats, "findOne"); + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + it("throws ApiError when monitor stats are missing", async () => { + findOneSpy.mockResolvedValue(null); + const { monitor } = buildMonitor({}); + const statusResponse = buildStatusResponse({ status: "up" }); + await expect(service.updateMonitorStats(monitor, statusResponse, false)).rejects.toBeInstanceOf(ApiError); + await expect(service.updateMonitorStats(monitor, statusResponse, false)).rejects.toMatchObject({ message: "MonitorStats not found", status: 500 }); + expect(findOneSpy).toHaveBeenCalledWith({ monitorId: monitor._id }); + }); + it("increments counters and preserves streak when status unchanged", async () => { + const now = new Date("2024-02-01T12:00:00Z"); + jest.setSystemTime(now); + const stats = buildStats({ + totalChecks: 4, + totalUpChecks: 3, + totalDownChecks: 1, + avgResponseTime: 100, + maxResponseTime: 250, + lastResponseTime: 90, + currentStreak: 2, + currentStreakStatus: "up", + currentStreakStartedAt: 111, + lastCheckTimestamp: 1, + timeOfLastFailure: 555, + }); + const saveMock = stats.save; + findOneSpy.mockResolvedValue(stats); + const { monitor } = buildMonitor({}); + const statusResponse = buildStatusResponse({ status: "up", responseTime: 150 }); + const result = await service.updateMonitorStats(monitor, statusResponse, false); + expect(findOneSpy).toHaveBeenCalledWith({ monitorId: monitor._id }); + expect(saveMock).toHaveBeenCalledTimes(1); + expect(result).toBe(stats); + expect(stats.totalChecks).toBe(5); + expect(stats.totalUpChecks).toBe(4); + expect(stats.totalDownChecks).toBe(1); + expect(stats.currentStreak).toBe(3); + expect(stats.currentStreakStatus).toBe("up"); + expect(stats.currentStreakStartedAt).toBe(111); + expect(stats.lastCheckTimestamp).toBe(now.getTime()); + expect(stats.timeOfLastFailure).toBe(555); + expect(stats.avgResponseTime).toBeCloseTo(110); + expect(stats.uptimePercentage).toBeCloseTo(4 / 5); + expect(stats.lastResponseTime).toBe(150); + expect(stats.maxResponseTime).toBe(250); + }); + it("resets streak and records failure on status change to down", async () => { + const now = new Date("2024-02-01T12:05:00Z"); + jest.setSystemTime(now); + const stats = buildStats({ + totalChecks: 10, + totalUpChecks: 7, + totalDownChecks: 3, + avgResponseTime: 200, + maxResponseTime: 300, + currentStreak: 5, + currentStreakStatus: "up", + currentStreakStartedAt: 999, + timeOfLastFailure: 444, + }); + const saveMock = stats.save; + findOneSpy.mockResolvedValue(stats); + const { monitor } = buildMonitor({}); + const statusResponse = buildStatusResponse({ status: "down", responseTime: 400 }); + const result = await service.updateMonitorStats(monitor, statusResponse, true); + expect(findOneSpy).toHaveBeenCalledWith({ monitorId: monitor._id }); + expect(saveMock).toHaveBeenCalledTimes(1); + expect(result).toBe(stats); + expect(stats.totalChecks).toBe(11); + expect(stats.totalUpChecks).toBe(7); + expect(stats.totalDownChecks).toBe(4); + expect(stats.currentStreak).toBe(1); + expect(stats.currentStreakStatus).toBe("down"); + expect(stats.currentStreakStartedAt).toBe(now.getTime()); + expect(stats.lastCheckTimestamp).toBe(now.getTime()); + expect(stats.timeOfLastFailure).toBe(now.getTime()); + expect(stats.avgResponseTime).toBeCloseTo((200 * 10 + 400) / 11); + expect(stats.uptimePercentage).toBeCloseTo(7 / 11); + expect(stats.lastResponseTime).toBe(400); + expect(stats.maxResponseTime).toBe(400); + }); +}); diff --git a/server/tests/utils/ApiError.test.js b/server/tests/utils/ApiError.test.js new file mode 100644 index 000000000..90a19edbd --- /dev/null +++ b/server/tests/utils/ApiError.test.js @@ -0,0 +1,14 @@ +import ApiError from "@/utils/ApiError.js"; +describe("ApiError", () => { + it("captures message and status", () => { + const error = new ApiError("boom", 418); + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("boom"); + expect(error.status).toBe(418); + expect(error.name).toBe("ApiError"); + }); + it("defaults status to 500", () => { + const error = new ApiError("failure"); + expect(error.status).toBe(500); + }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json index 83d3c70cb..de96e917c 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -11,5 +11,7 @@ "moduleResolution": "nodenext", "skipLibCheck": true, "noUncheckedIndexedAccess": true - } + }, + "include": ["src"], + "exclude": ["tests", "dist", "node_modules"] }