import { describe, expect, it, jest } from "@jest/globals"; import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; import { NETWORK_ERROR } from "../../../../src/service/network/utils.ts"; import type { Monitor } from "../../../../src/domain/monitors/monitor.types.ts"; // ── Mocks ──────────────────────────────────────────────────────────────────── jest.unstable_mockModule("cacheable-lookup", () => ({ default: jest.fn().mockImplementation(() => ({})), })); const mockGot = jest.fn(); // got instance returned by got.extend() mockGot.mockImplementation(() => Promise.resolve()); (mockGot as any).extend = jest.fn().mockReturnValue(mockGot); jest.unstable_mockModule("got", () => ({ type: { Got: {} }, HTTPError: class HTTPError extends Error { response: any; timings: any; constructor(msg: string, response?: any, timings?: any) { super(msg); this.name = "HTTPError"; this.response = response; this.timings = timings; } }, RequestError: class RequestError extends Error { response: any; timings: any; constructor(msg: string, response?: any, timings?: any) { super(msg); this.name = "RequestError"; this.response = response; this.timings = timings; } }, })); const { HttpProvider } = await import("../../../../src/service/network/HttpProvider.ts"); const gotModule = await import("got"); const { HTTPError, RequestError } = gotModule; // ── Helpers ────────────────────────────────────────────────────────────────── const makeMonitor = (overrides?: Partial): Monitor => ({ id: "mon-1", teamId: "team-1", type: "http", url: "https://example.com", secret: undefined, jsonPath: undefined, ignoreTlsErrors: false, useAdvancedMatching: false, matchMethod: undefined, expectedValue: undefined, customUpCodes: [], ...overrides, }) as Monitor; const createMockMatcher = (result?: { ok: boolean; message: string; extracted?: unknown }) => ({ validate: jest.fn().mockReturnValue(result ?? { ok: true, message: "Success" }), }); const makeGotResponse = (overrides?: Record) => ({ ok: true, statusCode: 200, statusMessage: "OK", headers: { "content-type": "text/html" }, body: "", timings: { phases: { firstByte: 100, total: 120 } }, ...overrides, }); const createProvider = (matcher?: any) => { const advancedMatcher = matcher ?? createMockMatcher(); const provider = new HttpProvider(mockGot as any, advancedMatcher); return { provider, advancedMatcher }; }; // ── Contract ───────────────────────────────────────────────────────────────── testStatusProviderContract("HttpProvider", { create: () => { mockGot.mockResolvedValue(makeGotResponse()); return createProvider().provider; }, supportedType: "http", unsupportedType: "ping", makeMonitor: () => makeMonitor(), }); // ── Tests ──────────────────────────────────────────────────────────────────── describe("HttpProvider", () => { // ── Success paths ──────────────────────────────────────────────────── describe("success responses", () => { it("returns success for a standard HTML response", async () => { mockGot.mockResolvedValue(makeGotResponse()); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result).toEqual( expect.objectContaining({ monitorId: "mon-1", teamId: "team-1", type: "http", status: true, code: 200, message: "OK", responseTime: 100, }) ); }); it("parses JSON body when content-type is application/json", async () => { mockGot.mockResolvedValue( makeGotResponse({ headers: { "content-type": "application/json" }, body: '{"status":"ok"}', }) ); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.payload).toEqual({ status: "ok" }); }); it("returns raw body when JSON parsing fails", async () => { mockGot.mockResolvedValue( makeGotResponse({ headers: { "content-type": "application/json" }, body: "not-json", }) ); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.payload).toBe("not-json"); }); it("returns raw body for non-JSON content", async () => { mockGot.mockResolvedValue(makeGotResponse({ body: "test" })); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.payload).toBe("test"); }); it("passes Authorization header when secret is set", async () => { mockGot.mockResolvedValue(makeGotResponse()); const { provider } = createProvider(); await provider.handle(makeMonitor({ secret: "my-token" })); expect(mockGot).toHaveBeenCalledWith( "https://example.com", expect.objectContaining({ headers: { Authorization: "Bearer my-token" }, }) ); }); it("passes undefined headers when no secret", async () => { mockGot.mockResolvedValue(makeGotResponse()); const { provider } = createProvider(); await provider.handle(makeMonitor({ secret: undefined })); expect(mockGot).toHaveBeenCalledWith( "https://example.com", expect.objectContaining({ headers: undefined, }) ); }); it("defaults responseTime to 0 when timings.phases.firstByte is undefined", async () => { mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { firstByte: undefined } } })); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.responseTime).toBe(0); }); it("defaults statusMessage to 'OK' when undefined", async () => { mockGot.mockResolvedValue(makeGotResponse({ statusMessage: undefined })); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.message).toBe("OK"); }); it("uses empty string for content-type when header is missing", async () => { mockGot.mockResolvedValue(makeGotResponse({ headers: {} })); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); // Should treat as non-JSON expect(result.payload).toBe(""); }); }); // ── jsonPath + non-JSON response ───────────────────────────────────── describe("jsonPath validation", () => { it("returns failure when jsonPath is set but response is not JSON", async () => { mockGot.mockResolvedValue(makeGotResponse({ headers: { "content-type": "text/html" } })); const { provider } = createProvider(); const result = await provider.handle(makeMonitor({ jsonPath: "status" })); expect(result.status).toBe(false); expect(result.message).toBe("Response is not JSON"); }); it("defaults responseTime to 0 in non-JSON jsonPath response when firstByte is undefined", async () => { mockGot.mockResolvedValue( makeGotResponse({ headers: { "content-type": "text/html" }, timings: { phases: { firstByte: undefined } }, }) ); const { provider } = createProvider(); const result = await provider.handle(makeMonitor({ jsonPath: "status" })); expect(result.responseTime).toBe(0); }); }); // ── AdvancedMatcher integration ────────────────────────────────────── describe("advanced matching", () => { it("uses matcher result for status and message", async () => { mockGot.mockResolvedValue(makeGotResponse()); const matcher = createMockMatcher({ ok: false, message: "Mismatch", extracted: "value" }); const { provider } = createProvider(matcher); const result = await provider.handle(makeMonitor({ useAdvancedMatching: true })); expect(result.status).toBe(false); expect(result.message).toBe("Mismatch"); expect(result.extracted).toBe("value"); }); it("sets status to false when status code is non-2xx even if matcher passes", async () => { mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301 })); const matcher = createMockMatcher({ ok: true, message: "Success" }); const { provider } = createProvider(matcher); const result = await provider.handle(makeMonitor()); expect(result.status).toBe(false); }); }); // ── Error handling ─────────────────────────────────────────────────── describe("error handling", () => { it("handles HTTPError with response and timings", async () => { const err = new HTTPError("Not Found"); (err as any).response = { statusCode: 404 }; (err as any).timings = { phases: { total: 50 } }; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result).toEqual( expect.objectContaining({ status: false, code: 404, message: "Not Found", responseTime: 50, }) ); }); it("handles RequestError with response and timings", async () => { const err = new RequestError("ECONNREFUSED"); (err as any).response = { statusCode: undefined }; (err as any).timings = { phases: { total: undefined } }; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.code).toBe(NETWORK_ERROR); expect(result.responseTime).toBe(0); }); it("handles HTTPError without response (defaults to NETWORK_ERROR)", async () => { const err = new HTTPError("Timeout"); (err as any).response = undefined; (err as any).timings = undefined; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.code).toBe(NETWORK_ERROR); expect(result.responseTime).toBe(0); }); it("handles generic Error (non-HTTPError/RequestError)", async () => { mockGot.mockRejectedValue(new Error("DNS lookup failed")); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result).toEqual( expect.objectContaining({ status: false, code: NETWORK_ERROR, message: "DNS lookup failed", responseTime: 0, }) ); }); it("handles non-Error thrown values", async () => { mockGot.mockRejectedValue("string error"); const { provider } = createProvider(); const result = await provider.handle(makeMonitor()); expect(result.message).toBe("string error"); expect(result.code).toBe(NETWORK_ERROR); }); it("throws when url is missing", async () => { const { provider } = createProvider(); await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for HTTP monitor"); }); }); // ── customUpCodes ──────────────────────────────────────────────────── describe("customUpCodes", () => { it("returns status true when HTTPError status code is in customUpCodes", async () => { const err = new HTTPError("Unauthorized"); (err as any).response = { statusCode: 401 }; (err as any).timings = { phases: { total: 30 } }; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor({ customUpCodes: [401] })); expect(result.status).toBe(true); expect(result.code).toBe(401); }); it("returns status false when HTTPError status code is not in customUpCodes", async () => { const err = new HTTPError("Internal Server Error"); (err as any).response = { statusCode: 500 }; (err as any).timings = { phases: { total: 40 } }; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor({ customUpCodes: [401, 403] })); expect(result.status).toBe(false); expect(result.code).toBe(500); }); it("returns status false when customUpCodes is empty", async () => { const err = new HTTPError("Unauthorized"); (err as any).response = { statusCode: 401 }; (err as any).timings = { phases: { total: 25 } }; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor({ customUpCodes: [] })); expect(result.status).toBe(false); expect(result.code).toBe(401); }); it("treats non-2xx success response as up when status code is in customUpCodes", async () => { mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301, statusMessage: "Moved Permanently" })); const { provider } = createProvider(); const result = await provider.handle(makeMonitor({ customUpCodes: [301] })); expect(result.status).toBe(true); expect(result.code).toBe(301); }); it("does not override matcher failure even when status code is in customUpCodes", async () => { mockGot.mockResolvedValue(makeGotResponse({ ok: false, statusCode: 301 })); const matcher = createMockMatcher({ ok: false, message: "Body mismatch" }); const { provider } = createProvider(matcher); const result = await provider.handle(makeMonitor({ customUpCodes: [301] })); expect(result.status).toBe(false); expect(result.message).toBe("Body mismatch"); }); it("does not override matcher failure on HTTPError when status code is in customUpCodes", async () => { const err = new HTTPError("Unauthorized"); (err as any).response = { statusCode: 401, body: '{"status":"down"}', headers: { "content-type": "application/json" }, }; (err as any).timings = { phases: { total: 30 } }; mockGot.mockRejectedValue(err); const matcher = createMockMatcher({ ok: false, message: "Body mismatch", extracted: "down", }); const { provider } = createProvider(matcher); const result = await provider.handle(makeMonitor({ customUpCodes: [401], useAdvancedMatching: true })); expect(result.status).toBe(false); expect(result.message).toBe("Body mismatch"); expect(result.payload).toEqual({ status: "down" }); expect(result.extracted).toBe("down"); }); }); // ── HTTP method (GET / HEAD) ───────────────────────────────────────── describe("request method", () => { it("passes GET to got when method is GET", async () => { mockGot.mockResolvedValue(makeGotResponse()); const { provider } = createProvider(); await provider.handle(makeMonitor({ method: "GET" })); expect(mockGot).toHaveBeenCalledWith("https://example.com", expect.objectContaining({ method: "GET" })); }); it("passes HEAD to got when method is HEAD", async () => { mockGot.mockResolvedValue(makeGotResponse({ body: "" })); const { provider } = createProvider(); await provider.handle(makeMonitor({ method: "HEAD" })); expect(mockGot).toHaveBeenCalledWith("https://example.com", expect.objectContaining({ method: "HEAD" })); }); it("does not mark a HEAD monitor down on an empty body even with advanced matching on", async () => { mockGot.mockResolvedValue(makeGotResponse({ body: "", headers: {} })); // A matcher that would fail — proving HEAD never reaches it const matcher = createMockMatcher({ ok: false, message: "Extracted value is falsy" }); const { provider, advancedMatcher } = createProvider(matcher); const result = await provider.handle(makeMonitor({ method: "HEAD", useAdvancedMatching: true, jsonPath: "status" })); expect(result.status).toBe(true); expect(advancedMatcher.validate).not.toHaveBeenCalled(); }); it("bases HEAD status on the status code (down when not an up code)", async () => { const err = new HTTPError("Internal Server Error"); (err as any).response = { statusCode: 500, body: "", headers: {} }; (err as any).timings = { phases: { total: 40 } }; mockGot.mockRejectedValue(err); const { provider } = createProvider(); const result = await provider.handle(makeMonitor({ method: "HEAD" })); expect(result.status).toBe(false); expect(result.code).toBe(500); }); it("respects customUpCodes for HEAD requests without consulting the matcher", async () => { const err = new HTTPError("Unauthorized"); (err as any).response = { statusCode: 401, body: "", headers: {} }; (err as any).timings = { phases: { total: 30 } }; mockGot.mockRejectedValue(err); const { provider, advancedMatcher } = createProvider(); const result = await provider.handle(makeMonitor({ method: "HEAD", customUpCodes: [401], useAdvancedMatching: true })); expect(result.status).toBe(true); expect(result.code).toBe(401); expect(advancedMatcher.validate).not.toHaveBeenCalled(); }); }); });