From c9d33c429256028481be4acd83ba810d7d6fd65b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 14 Nov 2025 19:10:34 +0000 Subject: [PATCH] add tests --- server/src/init/services.ts | 4 +- .../services/infrastructure/NetworkService.ts | 31 +-- .../infrastructure/NetworkService.test.ts | 194 ++++++++++++++++++ 3 files changed, 215 insertions(+), 14 deletions(-) create mode 100644 server/tests/services/infrastructure/NetworkService.test.ts diff --git a/server/src/init/services.ts b/server/src/init/services.ts index 228864e40..71db4170b 100644 --- a/server/src/init/services.ts +++ b/server/src/init/services.ts @@ -1,3 +1,5 @@ +import got from "got"; + import { CheckService, InviteService, @@ -38,7 +40,7 @@ export const initServices = async () => { const emailService = new EmailService(userService); const slackService = new SlackService(); const webhookService = new WebhookService(); - const networkService = new NetworkService(); + const networkService = new NetworkService(got); const statusService = new StatusService(); const notificationService = new NotificationService(userService); const incidentService = new IncidentService(); diff --git a/server/src/services/infrastructure/NetworkService.ts b/server/src/services/infrastructure/NetworkService.ts index bd9d4c58e..57d73e7aa 100644 --- a/server/src/services/infrastructure/NetworkService.ts +++ b/server/src/services/infrastructure/NetworkService.ts @@ -1,5 +1,4 @@ import { Got, HTTPError } from "got"; -import got from "got"; import ping from "ping"; import { IMonitor } from "@/db/models/index.js"; import { GotTimings } from "@/db/models/checks/Check.js"; @@ -48,15 +47,15 @@ export interface StatusResponse { class NetworkService implements INetworkService { public SERVICE_NAME = SERVICE_NAME; - private got: Got; + private client: Got; private NETWORK_ERROR: number; - constructor() { + constructor(got: Got) { this.SERVICE_NAME = SERVICE_NAME; this.NETWORK_ERROR = 5000; const cacheable = new CacheableLookup(); - this.got = got.extend({ + this.client = got.extend({ dnsCache: cacheable, timeout: { request: 30000, @@ -112,7 +111,7 @@ class NetworkService implements INetworkService { } try { - const response: Response = await this.got(url); + const response: Response = await this.client(url); return this.buildStatusResponse(monitor, response, null); } catch (error) { return this.buildStatusResponse(monitor, null, error); @@ -134,10 +133,13 @@ class NetworkService implements INetworkService { let statusResponse: StatusResponse; try { - const response: Response | null = await this.got(url, { - headers: { Authorization: `Bearer ${secret}` }, - responseType: "json", - }); + const response: Response | null = await this.client( + url, + { + headers: { Authorization: `Bearer ${secret}` }, + responseType: "json", + } + ); statusResponse = this.buildStatusResponse(monitor, response, null); if (!response?.body) { @@ -167,7 +169,7 @@ class NetworkService implements INetworkService { let statusResponse: StatusResponse; try { - const response: Response = await this.got(url); + const response: Response = await this.client(url); statusResponse = this.buildStatusResponse( monitor, response, @@ -178,9 +180,12 @@ class NetworkService implements INetworkService { } const pagespeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${url}&category=seo&category=accessibility&category=best-practices&category=performance&key=${apiKey}`; - const pagespeedResponse = await this.got(pagespeedUrl, { - responseType: "json", - }); + const pagespeedResponse = await this.client( + pagespeedUrl, + { + responseType: "json", + } + ); const payload = pagespeedResponse.body; if (payload) { statusResponse.payload = payload; diff --git a/server/tests/services/infrastructure/NetworkService.test.ts b/server/tests/services/infrastructure/NetworkService.test.ts new file mode 100644 index 000000000..7893abdfc --- /dev/null +++ b/server/tests/services/infrastructure/NetworkService.test.ts @@ -0,0 +1,194 @@ +import type { IMonitor } from "@/db/models/monitors/Monitor.js"; +import NetworkService from "@/services/infrastructure/NetworkService.js"; +import type { StatusResponse } from "@/services/infrastructure/NetworkService.js"; +import type { Response } from "got"; +import type { Got } from "got"; +import { Types } from "mongoose"; + +jest.mock("got", () => { + const extend = jest.fn(); + + class MockHTTPError extends Error { + public response?: any; + public timings?: any; + constructor(message?: string, options: any = {}) { + 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 {}, +})); + +const getGotModule = () => jest.requireMock("got"); + +const buildMonitor = (overrides: Partial = {}): IMonitor => + ({ + _id: new Types.ObjectId(), + teamId: new Types.ObjectId(), + type: "http" as any, + 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, + } as unknown as IMonitor); + +const createService = (clientMock?: jest.Mock) => { + const gotModule = getGotModule(); + const extendMock = gotModule.default.extend as jest.Mock; + const requestMock = clientMock ?? jest.fn(); + extendMock.mockReturnValue(requestMock); + const service = new NetworkService(gotModule.default as unknown as Got); + return { service, requestMock, extendMock, gotModule }; +}; + +const invokeBuildStatusResponse = ( + service: NetworkService, + monitor: IMonitor, + response: Response | null, + error: any | null +) => + (service as unknown as { + buildStatusResponse: ( + monitor: IMonitor, + response: Response | null, + error: any | null + ) => StatusResponse; + }).buildStatusResponse(monitor, response, error); + +beforeEach(() => { + const gotModule = getGotModule(); + (gotModule.default.extend as jest.Mock).mockReset(); +}); + +describe("NetworkService.buildStatusResponse", () => { + it("maps successful response details", () => { + const { service } = createService(); + const timings = { + phases: { total: 321 }, + } as Response["timings"]; + const response = { + statusCode: 201, + statusMessage: "Accepted", + ok: true, + timings, + } as Response; + 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("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 } } as Response["timings"]; + const response = { + statusCode: 204, + statusMessage: "No Content", + ok: true, + timings, + } as Response; + 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 as unknown as string }); + + 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: {} }); + }); +});