diff --git a/server/package.json b/server/package.json index 44643d40e..57ee33308 100755 --- a/server/package.json +++ b/server/package.json @@ -6,7 +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/**/*.ts' test/unit/services", + "test:services": "NODE_OPTIONS=--experimental-vm-modules jest --coverage --collectCoverageFrom='src/service/**/*.ts' test/unit/services test/unit/providers", "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/infrastructure/network/DockerProvider.ts b/server/src/service/infrastructure/network/DockerProvider.ts index 8e84b5dc6..813baedbe 100644 --- a/server/src/service/infrastructure/network/DockerProvider.ts +++ b/server/src/service/infrastructure/network/DockerProvider.ts @@ -90,7 +90,7 @@ export class DockerProvider implements IStatusProvider { if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID"); if (matchTypes.length > 1) { - const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`; + const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : "exact name"} match.`; this.logger.warn({ message, @@ -142,7 +142,7 @@ export class DockerProvider implements IStatusProvider { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, - status: response?.State?.Status === "running", + status: response.State?.Status === "running", code: 200, message: "Docker container status fetched successfully", responseTime, diff --git a/server/src/service/infrastructure/network/GrpcProvider.ts b/server/src/service/infrastructure/network/GrpcProvider.ts index 66684ba0c..14a063bca 100644 --- a/server/src/service/infrastructure/network/GrpcProvider.ts +++ b/server/src/service/infrastructure/network/GrpcProvider.ts @@ -60,7 +60,7 @@ export class GrpcProvider implements IStatusProvider { throw new AppError({ message: "Monitor port is required", service: SERVICE_NAME, method: "handle" }); } - const host = url?.replace(/^https?:\/\//, "").split(/[/?#:]/)[0]; + const host = url.replace(/^https?:\/\//, "").split(/[/?#:]/)[0]; const target = `${host}:${port}`; const currentFilePath = fileURLToPath(import.meta.url); diff --git a/server/test/helpers/statusProviderContract.ts b/server/test/helpers/statusProviderContract.ts new file mode 100644 index 000000000..6f7c89466 --- /dev/null +++ b/server/test/helpers/statusProviderContract.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "@jest/globals"; +import type { IStatusProvider } from "../../src/service/infrastructure/network/IStatusProvider.ts"; +import type { Monitor } from "../../src/types/index.ts"; + +/** + * Shared contract tests for all IStatusProvider implementations. + * Tests the interface guarantees that every provider must satisfy. + */ +export const testStatusProviderContract = ( + name: string, + opts: { + create: () => IStatusProvider; + supportedType: string; + unsupportedType: string; + makeMonitor: () => Monitor; + } +) => { + describe(`IStatusProvider contract: ${name}`, () => { + it("has a readonly type property matching the supported type", () => { + const provider = opts.create(); + expect(provider.type).toBe(opts.supportedType); + }); + + it("supports() returns true for its own type", () => { + const provider = opts.create(); + expect(provider.supports(opts.supportedType as any)).toBe(true); + }); + + it("supports() returns false for a different type", () => { + const provider = opts.create(); + expect(provider.supports(opts.unsupportedType as any)).toBe(false); + }); + + it("handle() returns an object with required MonitorStatusResponse fields", async () => { + const provider = opts.create(); + try { + const result = await provider.handle(opts.makeMonitor()); + expect(result).toEqual( + expect.objectContaining({ + monitorId: expect.anything(), + teamId: expect.anything(), + type: expect.anything(), + status: expect.any(Boolean), + code: expect.any(Number), + message: expect.any(String), + }) + ); + } catch (err: any) { + // Some providers throw AppError on failure — that's also valid behavior + expect(err).toBeDefined(); + } + }); + }); +}; diff --git a/server/test/unit/providers/advancedMatcher.test.ts b/server/test/unit/providers/advancedMatcher.test.ts new file mode 100644 index 000000000..e0087b201 --- /dev/null +++ b/server/test/unit/providers/advancedMatcher.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { AdvancedMatcher } from "../../../src/service/infrastructure/network/AdvancedMatcher.ts"; +import type { Monitor } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createMockJmespath = () => ({ + search: jest.fn((data: any, expr: string) => { + // Simple dot-path evaluation for tests + return expr.split(".").reduce((acc: any, key: string) => acc?.[key], data); + }), +}); + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + useAdvancedMatching: false, + jsonPath: undefined, + matchMethod: undefined, + expectedValue: undefined, + ...overrides, + }) as Monitor; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("AdvancedMatcher", () => { + describe("validate", () => { + it("returns ok when advanced matching is disabled", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate({ data: "test" }, makeMonitor({ useAdvancedMatching: false })); + + expect(result).toEqual({ ok: true, message: "Success" }); + }); + + // ── jsonPath extraction ────────────────────────────────────────── + + describe("jsonPath extraction", () => { + it("extracts value using jsonPath", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate({ status: "healthy" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "status" })); + + expect(result).toEqual({ ok: true, message: "Success", extracted: "healthy" }); + }); + + it("returns error when jsonPath evaluation throws", () => { + const jmespath = { + search: jest.fn().mockImplementation(() => { + throw new Error("bad expression"); + }), + }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({ data: "test" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "invalid[" })); + + expect(result).toEqual({ ok: false, message: "Error evaluating JSON path" }); + }); + }); + + // ── expectedValue matching ─────────────────────────────────────── + + describe("expectedValue with matchMethod", () => { + it("matches with 'equal' method", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { status: "ok" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "status", matchMethod: "equal", expectedValue: "ok" }) + ); + + expect(result.ok).toBe(true); + }); + + it("fails with 'equal' method on mismatch", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { status: "error" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "status", matchMethod: "equal", expectedValue: "ok" }) + ); + + expect(result.ok).toBe(false); + expect(result.message).toBe("Expected value did not match"); + }); + + it("matches with 'include' method", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { msg: "server is healthy" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "msg", matchMethod: "include", expectedValue: "healthy" }) + ); + + expect(result.ok).toBe(true); + }); + + it("fails with 'include' method when substring not found", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { msg: "server is down" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "msg", matchMethod: "include", expectedValue: "healthy" }) + ); + + expect(result.ok).toBe(false); + }); + + it("matches with 'regex' method", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { code: "200" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "code", matchMethod: "regex", expectedValue: "^2\\d{2}$" }) + ); + + expect(result.ok).toBe(true); + }); + + it("fails with 'regex' method on mismatch", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { code: "500" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "code", matchMethod: "regex", expectedValue: "^2\\d{2}$" }) + ); + + expect(result.ok).toBe(false); + }); + + it("defaults to 'equal' comparison when matchMethod is undefined", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate({ val: "exact" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "val", expectedValue: "exact" })); + + expect(result.ok).toBe(true); + }); + + it("defaults to 'equal' comparison for unknown matchMethod", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { val: "exact" }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "val", matchMethod: "unknown" as any, expectedValue: "exact" }) + ); + + expect(result.ok).toBe(true); + }); + + it("compares entire payload when jsonPath is not set", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + "hello", + makeMonitor({ useAdvancedMatching: true, jsonPath: undefined, matchMethod: "equal", expectedValue: "hello" }) + ); + + expect(result.ok).toBe(true); + expect(result.extracted).toBe("hello"); + }); + + it("includes extracted value in result", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate( + { nested: { value: "42" } }, + makeMonitor({ useAdvancedMatching: true, jsonPath: "nested.value", matchMethod: "equal", expectedValue: "42" }) + ); + + expect(result.extracted).toBe("42"); + }); + }); + + // ── falsy value check (no expectedValue) ──────────────────────── + + describe("falsy value check (no expectedValue)", () => { + it("returns ok for truthy extracted value", () => { + const matcher = new AdvancedMatcher(createMockJmespath() as any); + const result = matcher.validate({ status: "up" }, makeMonitor({ useAdvancedMatching: true, jsonPath: "status" })); + + expect(result.ok).toBe(true); + expect(result.extracted).toBe("up"); + }); + + it("returns not ok for false", () => { + const jmespath = { search: jest.fn().mockReturnValue(false) }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "missing" })); + + expect(result.ok).toBe(false); + expect(result.message).toBe("Extracted value is falsy"); + }); + + it("returns not ok for string 'false'", () => { + const jmespath = { search: jest.fn().mockReturnValue("false") }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" })); + + expect(result.ok).toBe(false); + }); + + it("returns not ok for undefined", () => { + const jmespath = { search: jest.fn().mockReturnValue(undefined) }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" })); + + expect(result.ok).toBe(false); + }); + + it("returns not ok for null", () => { + const jmespath = { search: jest.fn().mockReturnValue(null) }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" })); + + expect(result.ok).toBe(false); + }); + + it("returns ok for zero (not in falsy list)", () => { + const jmespath = { search: jest.fn().mockReturnValue(0) }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" })); + + expect(result.ok).toBe(true); + }); + + it("returns ok for empty string (not in falsy list)", () => { + const jmespath = { search: jest.fn().mockReturnValue("") }; + const matcher = new AdvancedMatcher(jmespath as any); + + const result = matcher.validate({}, makeMonitor({ useAdvancedMatching: true, jsonPath: "field" })); + + expect(result.ok).toBe(true); + }); + }); + }); +}); diff --git a/server/test/unit/providers/dockerProvider.test.ts b/server/test/unit/providers/dockerProvider.test.ts new file mode 100644 index 000000000..4306d3ff7 --- /dev/null +++ b/server/test/unit/providers/dockerProvider.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { DockerProvider } from "../../../src/service/infrastructure/network/DockerProvider.ts"; +import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import { NETWORK_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "docker", + url: "my-container", + ...overrides, + }) as Monitor; + +const makeContainer = (overrides?: Record) => ({ + Id: "abc123def456abc123def456abc123def456abc123def456abc123def456abcd", + Names: ["/my-container"], + State: "running", + ...overrides, +}); + +const createMockDocker = (containers: any[] = [makeContainer()], inspectResult?: any) => { + const inspect = jest.fn().mockResolvedValue(inspectResult ?? { State: { Status: "running" } }); + const getContainer = jest.fn().mockReturnValue({ inspect }); + + const instance = { + listContainers: jest.fn().mockResolvedValue(containers), + getContainer, + }; + + const DockerLib = jest.fn().mockReturnValue(instance) as any; + + return { DockerLib, instance, inspect, getContainer }; +}; + +const createProvider = (opts?: { containers?: any[]; inspectResult?: any }) => { + const logger = createMockLogger(); + const { DockerLib, instance, inspect, getContainer } = createMockDocker(opts?.containers, opts?.inspectResult); + const provider = new DockerProvider(logger as any, DockerLib); + return { provider, logger, docker: instance, inspect, getContainer }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("DockerProvider", { + create: () => createProvider().provider, + supportedType: "docker", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("DockerProvider", () => { + // ── Container matching ─────────────────────────────────────────────── + + describe("container matching", () => { + it("matches by exact container name", async () => { + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: "my-container" })); + + expect(result.status).toBe(true); + expect(result.code).toBe(200); + }); + + it("matches by exact full ID (64 chars)", async () => { + const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd"; + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: fullId })); + + expect(result.status).toBe(true); + }); + + it("matches by partial ID prefix", async () => { + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: "abc123" })); + + expect(result.status).toBe(true); + }); + + it("strips leading slashes from input", async () => { + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: "///my-container" })); + + expect(result.status).toBe(true); + }); + + it("matches case-insensitively", async () => { + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: "MY-CONTAINER" })); + + expect(result.status).toBe(true); + }); + + it("returns 404 when no container matches", async () => { + const { provider, logger } = createProvider(); + + const result = await provider.handle(makeMonitor({ url: "nonexistent" })); + + expect(result.status).toBe(false); + expect(result.code).toBe(404); + expect(result.message).toBe("Docker container not found"); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("No container found"), + }) + ); + }); + }); + + // ── Ambiguity detection ────────────────────────────────────────────── + + describe("ambiguity detection", () => { + it("returns error when input matches both exact name and partial ID", async () => { + // Container whose name matches the input AND whose ID starts with the input + const container = makeContainer({ + Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd", + Names: ["/my-container"], + }); + const { provider, logger } = createProvider({ containers: [container] }); + + const result = await provider.handle(makeMonitor({ url: "my-container" })); + + expect(result.status).toBe(false); + expect(result.code).toBe(NETWORK_ERROR); + expect(result.message).toContain("Ambiguous"); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Ambiguous"), + }) + ); + }); + + it("reports 'exact ID' in ambiguity message when exact ID and name both match", async () => { + const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd"; + const container = makeContainer({ + Id: fullId, + Names: ["/" + fullId], + }); + const { provider } = createProvider({ containers: [container] }); + + const result = await provider.handle(makeMonitor({ url: fullId })); + + expect(result.message).toContain("Using exact ID"); + }); + + it("reports 'exact name' in ambiguity message when name and partial ID match", async () => { + const container = makeContainer({ + Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd", + Names: ["/my-container"], + }); + const { provider } = createProvider({ containers: [container] }); + + const result = await provider.handle(makeMonitor({ url: "my-container" })); + + expect(result.message).toContain("Using exact name"); + }); + }); + + // ── Container inspection ───────────────────────────────────────────── + + describe("container inspection", () => { + it("returns running status for running container", async () => { + const { provider } = createProvider({ inspectResult: { State: { Status: "running" } } }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(true); + expect(result.message).toBe("Docker container status fetched successfully"); + }); + + it("returns not-running status for stopped container", async () => { + const { provider } = createProvider({ inspectResult: { State: { Status: "exited" } } }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + }); + + it("returns false status when inspect response has no State", async () => { + const { provider } = createProvider({ inspectResult: {} }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + }); + + it("returns false status when inspect response has State but no Status", async () => { + const { provider } = createProvider({ inspectResult: { State: {} } }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + }); + + it("handles inspect error with Docker-specific error", async () => { + const { provider } = createProvider(); + // Make inspect throw a Docker error + const dockerErr = Object.assign(new Error("container not found"), { + statusCode: 404, + reason: "no such container", + json: { message: "Container not found" }, + }); + provider["docker"] = { + listContainers: jest.fn().mockResolvedValue([makeContainer()]), + getContainer: jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(dockerErr), + }), + } as any; + + // Need to re-assign; simpler to use the createProvider mock + const { provider: p2 } = createProvider(); + // Override getContainer to return failing inspect + p2["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(dockerErr), + }); + + const result = await p2.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.code).toBe(404); + expect(result.message).toBe("Container not found"); + }); + + it("handles inspect error with Docker error using reason fallback", async () => { + const { provider } = createProvider(); + const dockerErr = Object.assign(new Error("error"), { + statusCode: 500, + reason: "internal error", + json: {}, + }); + provider["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(dockerErr), + }); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("internal error"); + }); + + it("handles inspect error with Docker error using Error message fallback", async () => { + const { provider } = createProvider(); + const dockerErr = Object.assign(new Error("base error"), { + statusCode: 503, + reason: undefined, + json: undefined, + }); + provider["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(dockerErr), + }); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("base error"); + }); + + it("handles inspect error with NETWORK_ERROR when no statusCode", async () => { + const { provider } = createProvider(); + const dockerErr = Object.assign(new Error("unknown"), { + statusCode: undefined, + }); + provider["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(dockerErr), + }); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(NETWORK_ERROR); + }); + + it("handles inspect error with standard Error (not Docker error)", async () => { + const { provider } = createProvider(); + provider["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue(new Error("ECONNREFUSED")), + }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.message).toBe("ECONNREFUSED"); + }); + + it("handles inspect error with non-Error non-Docker thrown value", async () => { + const { provider } = createProvider(); + provider["docker"].getContainer = jest.fn().mockReturnValue({ + inspect: jest.fn().mockRejectedValue("string error"), + }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.message).toBe("Failed to fetch Docker container information"); + }); + }); + + // ── Outer catch ────────────────────────────────────────────────────── + + describe("outer error handling", () => { + it("throws AppError when containerInput is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Container name or ID is required for Docker monitor"); + }); + + it("throws AppError when listContainers throws", async () => { + const { provider } = createProvider(); + provider["docker"].listContainers = jest.fn().mockRejectedValue(new Error("Docker daemon unavailable")); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Docker daemon unavailable"); + }); + + it("throws AppError with default message for non-Error thrown values", async () => { + const { provider } = createProvider(); + provider["docker"].listContainers = jest.fn().mockRejectedValue(null); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Docker request"); + }); + }); +}); diff --git a/server/test/unit/providers/gameProvider.test.ts b/server/test/unit/providers/gameProvider.test.ts new file mode 100644 index 000000000..c2f671c3c --- /dev/null +++ b/server/test/unit/providers/gameProvider.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { GameProvider } from "../../../src/service/infrastructure/network/GameProvider.ts"; +import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import { NETWORK_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "game", + url: "play.example.com", + port: 25565, + gameId: "minecraft", + ...overrides, + }) as Monitor; + +const createMockGameDig = (state?: Record | null) => ({ + query: jest.fn().mockResolvedValue( + state ?? { + name: "My Server", + map: "world", + players: [], + ping: 45, + } + ), +}); + +const createProvider = (gameDig?: any) => { + const logger = createMockLogger(); + const dig = gameDig ?? createMockGameDig(); + const provider = new GameProvider(logger as any, dig as any); + return { provider, logger, gameDig: dig }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("GameProvider", { + create: () => createProvider().provider, + supportedType: "game", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("GameProvider", () => { + it("returns success with server state", async () => { + const { provider } = createProvider(); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "game", + status: true, + code: 200, + message: "Success", + responseTime: 45, + }) + ); + }); + + it("queries with correct host, port, and gameId", async () => { + const { provider, gameDig } = createProvider(); + + await provider.handle(makeMonitor({ url: "https://play.example.com/path", port: 27015, gameId: "csgo" })); + + expect(gameDig.query).toHaveBeenCalledWith({ + type: "csgo", + host: "play.example.com", + port: 27015, + }); + }); + + it("strips protocol and path from url for host", async () => { + const { provider, gameDig } = createProvider(); + + await provider.handle(makeMonitor({ url: "http://game.server.com:8080/info" })); + + expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ host: "game.server.com" })); + }); + + it("defaults gameId to 'unknown' when not set", async () => { + const { provider, gameDig } = createProvider(); + + await provider.handle(makeMonitor({ gameId: undefined })); + + expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ type: "unknown" })); + }); + + it("defaults port to 0 when not set", async () => { + const { provider, gameDig } = createProvider(); + + await provider.handle(makeMonitor({ port: undefined })); + + expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ port: 0 })); + }); + + it("defaults responseTime to 0 when ping is undefined", async () => { + const { provider } = createProvider(createMockGameDig({ ping: undefined })); + + const result = await provider.handle(makeMonitor()); + + expect(result.responseTime).toBe(0); + }); + + it("returns failure when query resolves to undefined (caught error)", async () => { + const gameDig = { query: jest.fn().mockRejectedValue(new Error("Server offline")) }; + // GameProvider catches the error internally via .catch() + const { provider, logger } = createProvider(gameDig); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "No response from game server", + responseTime: 0, + }) + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Server offline", + method: "handle", + }) + ); + }); + + it("logs non-Error thrown values from query", async () => { + const gameDig = { query: jest.fn().mockRejectedValue("string error") }; + const { provider, logger } = createProvider(gameDig); + + await provider.handle(makeMonitor()); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "string error" })); + }); + + it("uses empty string as host when url is undefined", async () => { + const { provider, gameDig } = createProvider(); + + await provider.handle(makeMonitor({ url: undefined })); + + expect(gameDig.query).toHaveBeenCalledWith(expect.objectContaining({ host: "" })); + }); + + it("throws AppError with default message when error stringifies to empty", async () => { + const gameDig = { + query: jest.fn().mockReturnValue({ + catch: () => { + throw new Error(""); + }, + }), + }; + const { provider } = createProvider(gameDig); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing game server check"); + }); + + it("throws AppError with default message for non-Error that stringifies to empty", async () => { + const gameDig = { + query: jest.fn().mockReturnValue({ + catch: () => { + throw ""; + }, + }), + }; + const { provider } = createProvider(gameDig); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing game server check"); + }); + + it("throws AppError with Error message in outer catch", async () => { + const gameDig = { + query: jest.fn().mockReturnValue({ + catch: () => { + throw new Error("unexpected"); + }, + }), + }; + const { provider } = createProvider(gameDig); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("unexpected"); + }); +}); diff --git a/server/test/unit/providers/grpcProvider.test.ts b/server/test/unit/providers/grpcProvider.test.ts new file mode 100644 index 000000000..2b5de6634 --- /dev/null +++ b/server/test/unit/providers/grpcProvider.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { GrpcProvider } from "../../../src/service/infrastructure/network/GrpcProvider.ts"; +import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; +import type { Monitor } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "grpc", + url: "grpc.example.com", + port: 50051, + grpcServiceName: "my.Service", + ignoreTlsErrors: false, + ...overrides, + }) as Monitor; + +const createMockClient = (behavior: "serving" | "not-serving" | "error" | "grpc-error" = "serving") => { + const close = jest.fn(); + const Check = jest.fn((_req: any, _opts: any, cb: Function) => { + if (behavior === "serving") { + cb(null, { status: "SERVING" }); + } else if (behavior === "not-serving") { + cb(null, { status: "NOT_SERVING" }); + } else if (behavior === "error") { + cb({ code: 14, details: "Connection refused", message: "unavailable" }); + } else if (behavior === "grpc-error") { + cb({ code: 2, message: "Unknown error" }); + } + }); + return { Check, close }; +}; + +const createMockGrpc = (client?: ReturnType) => { + const c = client ?? createMockClient(); + const Health = jest.fn().mockReturnValue(c); + + return { + grpc: { + loadPackageDefinition: jest.fn().mockReturnValue({ + grpc: { health: { v1: { Health } } }, + }), + credentials: { + createInsecure: jest.fn().mockReturnValue("insecure-creds"), + createSsl: jest.fn().mockReturnValue("ssl-creds"), + }, + }, + protoLoader: { + loadSync: jest.fn().mockReturnValue({}), + }, + Health, + client: c, + }; +}; + +const createProvider = (opts?: { behavior?: "serving" | "not-serving" | "error" | "grpc-error" }) => { + const client = createMockClient(opts?.behavior ?? "serving"); + const mocks = createMockGrpc(client); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + return { provider, ...mocks, client }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("GrpcProvider", { + create: () => createProvider().provider, + supportedType: "grpc", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("GrpcProvider", () => { + // ── Success paths ──────────────────────────────────────────────────── + + describe("success responses", () => { + it("returns healthy status for SERVING response", async () => { + const { provider } = createProvider({ behavior: "serving" }); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "grpc", + status: true, + code: 200, + message: expect.stringContaining("SERVING"), + }) + ); + expect(result.payload).toEqual( + expect.objectContaining({ + grpcStatusCode: 0, + grpcStatusName: "OK", + serviceName: "my.Service", + servingStatus: "SERVING", + }) + ); + }); + + it("returns unhealthy status for NOT_SERVING response", async () => { + const { provider } = createProvider({ behavior: "not-serving" }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.code).toBe(5000); + expect(result.message).toContain("NOT_SERVING"); + }); + + it("defaults to UNKNOWN when response has no status", async () => { + const client = { Check: jest.fn((_r: any, _o: any, cb: Function) => cb(null, {})), close: jest.fn() }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toEqual(expect.objectContaining({ servingStatus: "UNKNOWN" })); + }); + + it("defaults to UNKNOWN when response is undefined", async () => { + const client = { Check: jest.fn((_r: any, _o: any, cb: Function) => cb(null, undefined)), close: jest.fn() }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toEqual(expect.objectContaining({ servingStatus: "UNKNOWN" })); + }); + }); + + // ── gRPC errors ────────────────────────────────────────────────────── + + describe("gRPC errors", () => { + it("returns failure with gRPC error details", async () => { + const { provider } = createProvider({ behavior: "error" }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.code).toBe(14); + expect(result.message).toBe("Connection refused"); + expect(result.payload).toEqual( + expect.objectContaining({ + grpcStatusCode: 14, + grpcStatusName: "UNAVAILABLE", + servingStatus: "UNKNOWN", + }) + ); + }); + + it("falls back to message when details is missing", async () => { + const client = { + Check: jest.fn((_r: any, _o: any, cb: Function) => cb({ code: 2, message: "Unknown error" })), + close: jest.fn(), + }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("Unknown error"); + }); + + it("uses default message and code when error has no code/details/message", async () => { + const client = { + Check: jest.fn((_r: any, _o: any, cb: Function) => cb({})), + close: jest.fn(), + }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(5000); + expect(result.message).toBe("gRPC error"); + expect(result.payload).toEqual(expect.objectContaining({ grpcStatusCode: -1, grpcStatusName: "UNKNOWN" })); + }); + + it("defaults grpcCode to 5000 when error has no grpcCode", async () => { + const client = { + Check: jest.fn((_r: any, _o: any, cb: Function) => { + const err = new Error("test") as any; + // no grpcPayload or grpcCode + cb(err); + }), + close: jest.fn(), + }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(5000); + }); + + it("includes grpcPayload in error result", async () => { + const { provider } = createProvider({ behavior: "error" }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toEqual( + expect.objectContaining({ + grpcStatusCode: 14, + grpcStatusName: "UNAVAILABLE", + }) + ); + }); + + it("defaults code, message, and payload when error lacks gRPC properties", async () => { + // client.Check throws synchronously — timeRequest catches a plain object with no gRPC props + const client = { + Check: jest.fn(() => { + throw { unexpected: true }; + }), + close: jest.fn(), + }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(5000); + expect(result.message).toBe("gRPC health check failed"); + expect(result.payload).toBeNull(); + }); + }); + + // ── Credentials ────────────────────────────────────────────────────── + + describe("credentials", () => { + it("uses insecure credentials by default", async () => { + const { provider, grpc } = createProvider(); + + await provider.handle(makeMonitor({ ignoreTlsErrors: false })); + + expect(grpc.credentials.createInsecure).toHaveBeenCalled(); + }); + + it("uses SSL credentials with checkServerIdentity override when ignoreTlsErrors is true", async () => { + const { provider, grpc } = createProvider(); + + await provider.handle(makeMonitor({ ignoreTlsErrors: true })); + + expect(grpc.credentials.createSsl).toHaveBeenCalledWith(null, null, null, { + checkServerIdentity: expect.any(Function), + }); + + // Verify checkServerIdentity returns undefined + const opts = (grpc.credentials.createSsl as jest.Mock).mock.calls[0][3]; + expect(opts.checkServerIdentity()).toBeUndefined(); + }); + }); + + // ── URL parsing ────────────────────────────────────────────────────── + + describe("URL and target construction", () => { + it("strips protocol from URL", async () => { + const { provider, Health } = createProvider(); + + await provider.handle(makeMonitor({ url: "https://grpc.example.com", port: 443 })); + + expect(Health).toHaveBeenCalledWith("grpc.example.com:443", expect.anything()); + }); + + it("uses grpcServiceName in Check request", async () => { + const { provider, client } = createProvider(); + + await provider.handle(makeMonitor({ grpcServiceName: "health.v1" })); + + expect(client.Check).toHaveBeenCalledWith( + { service: "health.v1" }, + expect.objectContaining({ deadline: expect.any(Date) }), + expect.any(Function) + ); + }); + + it("defaults grpcServiceName to empty string when not set", async () => { + const { provider, client } = createProvider(); + + await provider.handle(makeMonitor({ grpcServiceName: undefined })); + + expect(client.Check).toHaveBeenCalledWith({ service: "" }, expect.anything(), expect.any(Function)); + }); + }); + + // ── Validation ─────────────────────────────────────────────────────── + + describe("validation", () => { + it("throws AppError when url is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Monitor host is required"); + }); + + it("throws AppError when port is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ port: undefined }))).rejects.toThrow("Monitor port is required"); + }); + }); + + // ── Outer catch ────────────────────────────────────────────────────── + + describe("outer error handling", () => { + it("throws AppError when protoLoader.loadSync throws", async () => { + const mocks = createMockGrpc(); + mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => { + throw new Error("Proto file not found"); + }); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Proto file not found"); + }); + + it("throws AppError with default message when Error has empty message", async () => { + const mocks = createMockGrpc(); + mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => { + throw new Error(""); + }); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing gRPC health check"); + }); + + it("throws AppError with stringified message for non-Error thrown values", async () => { + const mocks = createMockGrpc(); + mocks.protoLoader.loadSync = jest.fn().mockImplementation(() => { + throw "proto load failed"; + }); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("proto load failed"); + }); + }); + + // ── getGrpcStatusName ──────────────────────────────────────────────── + + describe("gRPC status name mapping", () => { + const statusCodes: [number, string][] = [ + [0, "OK"], + [1, "CANCELLED"], + [2, "UNKNOWN"], + [3, "INVALID_ARGUMENT"], + [4, "DEADLINE_EXCEEDED"], + [5, "NOT_FOUND"], + [6, "ALREADY_EXISTS"], + [7, "PERMISSION_DENIED"], + [8, "RESOURCE_EXHAUSTED"], + [9, "FAILED_PRECONDITION"], + [10, "ABORTED"], + [11, "OUT_OF_RANGE"], + [12, "UNIMPLEMENTED"], + [13, "INTERNAL"], + [14, "UNAVAILABLE"], + [15, "DATA_LOSS"], + [16, "UNAUTHENTICATED"], + [99, "UNKNOWN"], + ]; + + it.each(statusCodes)("maps gRPC code %i to %s", async (code, expectedName) => { + const client = { + Check: jest.fn((_r: any, _o: any, cb: Function) => cb({ code, details: "test" })), + close: jest.fn(), + }; + const mocks = createMockGrpc(client as any); + const provider = new GrpcProvider(mocks.grpc as any, mocks.protoLoader as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload).toEqual(expect.objectContaining({ grpcStatusName: expectedName })); + }); + }); +}); diff --git a/server/test/unit/providers/hardwareProvider.test.ts b/server/test/unit/providers/hardwareProvider.test.ts new file mode 100644 index 000000000..22b2f5ae9 --- /dev/null +++ b/server/test/unit/providers/hardwareProvider.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { HardwareProvider } from "../../../src/service/infrastructure/network/HardwareProvider.ts"; +import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; +import type { HttpProvider } from "../../../src/service/infrastructure/network/HttpProvider.ts"; +import type { Monitor } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "hardware", + url: "https://capture.example.com", + ...overrides, + }) as Monitor; + +const createMockHttpProvider = () => + ({ + handle: jest.fn().mockResolvedValue({ + monitorId: "mon-1", + teamId: "team-1", + type: "hardware", + status: true, + code: 200, + message: "OK", + responseTime: 50, + payload: { data: { cpu: { usage_percent: 0.5 } } }, + }), + }) as unknown as jest.Mocked; + +const createProvider = () => { + const httpProvider = createMockHttpProvider(); + const provider = new HardwareProvider(httpProvider); + return { provider, httpProvider }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("HardwareProvider", { + create: () => createProvider().provider, + supportedType: "hardware", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("HardwareProvider", () => { + it("delegates to httpProvider.handle", async () => { + const { provider, httpProvider } = createProvider(); + const monitor = makeMonitor(); + + await provider.handle(monitor); + + expect(httpProvider.handle).toHaveBeenCalledWith(monitor); + }); + + it("returns the response from httpProvider", async () => { + const { provider } = createProvider(); + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + status: true, + code: 200, + }) + ); + }); + + it("throws AppError when url is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for Hardware monitor"); + }); + + it("throws AppError when httpProvider.handle throws", async () => { + const { provider, httpProvider } = createProvider(); + (httpProvider.handle as jest.Mock).mockRejectedValue(new Error("connection failed")); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("connection failed"); + }); + + it("throws AppError with default message for non-Error thrown values", async () => { + const { provider, httpProvider } = createProvider(); + (httpProvider.handle as jest.Mock).mockRejectedValue("string error"); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Hardware request"); + }); +}); diff --git a/server/test/unit/providers/httpProvider.test.ts b/server/test/unit/providers/httpProvider.test.ts new file mode 100644 index 000000000..7c9a2f10d --- /dev/null +++ b/server/test/unit/providers/httpProvider.test.ts @@ -0,0 +1,348 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; +import { NETWORK_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../src/types/index.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/infrastructure/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, + ...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: { total: 100 } }, + ...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.total is undefined", async () => { + mockGot.mockResolvedValue(makeGotResponse({ timings: { phases: { total: 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 total is undefined", async () => { + mockGot.mockResolvedValue( + makeGotResponse({ + headers: { "content-type": "text/html" }, + timings: { phases: { total: 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 response.ok is false 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()); + + // status = response.ok && matchResult.ok + 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"); + }); + }); +}); diff --git a/server/test/unit/providers/pageSpeedProvider.test.ts b/server/test/unit/providers/pageSpeedProvider.test.ts new file mode 100644 index 000000000..08c19bbd7 --- /dev/null +++ b/server/test/unit/providers/pageSpeedProvider.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { PageSpeedProvider } from "../../../src/service/infrastructure/network/PageSpeedProvider.ts"; +import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { HttpProvider } from "../../../src/service/infrastructure/network/HttpProvider.ts"; +import type { ISettingsService } from "../../../src/service/system/settingsService.ts"; +import type { Monitor } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "pagespeed", + url: "https://example.com", + ...overrides, + }) as Monitor; + +const createMockHttpProvider = () => + ({ + handle: jest.fn().mockResolvedValue({ + monitorId: "mon-1", + teamId: "team-1", + type: "pagespeed", + status: true, + code: 200, + message: "OK", + responseTime: 2000, + payload: { lighthouseResult: {} }, + }), + }) as unknown as jest.Mocked; + +const createMockSettingsService = (apiKey?: string) => + ({ + getDBSettings: jest.fn().mockResolvedValue({ pagespeedApiKey: apiKey }), + }) as unknown as jest.Mocked; + +const createProvider = (opts?: { apiKey?: string }) => { + const logger = createMockLogger(); + const httpProvider = createMockHttpProvider(); + const settingsService = createMockSettingsService(opts?.apiKey); + const provider = new PageSpeedProvider(httpProvider, settingsService, logger as any); + return { provider, httpProvider, settingsService, logger }; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("PageSpeedProvider", { + create: () => createProvider({ apiKey: "test-key" }).provider, + supportedType: "pagespeed", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("PageSpeedProvider", () => { + it("delegates to httpProvider with PageSpeed API URL", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "my-key" }); + + await provider.handle(makeMonitor({ url: "https://example.com" })); + + expect(httpProvider.handle).toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining("pagespeedonline.googleapis.com"), + }) + ); + }); + + it("includes API key in URL when available", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "my-key" }); + + await provider.handle(makeMonitor()); + + const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url; + expect(calledUrl).toContain("key=my-key"); + }); + + it("omits API key and logs warning when not configured", async () => { + const { provider, httpProvider, logger } = createProvider({ apiKey: undefined }); + + await provider.handle(makeMonitor()); + + const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url; + expect(calledUrl).not.toContain("key="); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("API key not found"), + service: "PageSpeedProvider", + }) + ); + }); + + it("encodes the monitor URL in the PageSpeed API URL", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "key" }); + + await provider.handle(makeMonitor({ url: "https://example.com/path?q=1" })); + + const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url; + expect(calledUrl).toContain(encodeURIComponent("https://example.com/path?q=1")); + }); + + it("includes all PageSpeed categories in the URL", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "key" }); + + await provider.handle(makeMonitor()); + + const calledUrl = (httpProvider.handle as jest.Mock).mock.calls[0][0].url; + expect(calledUrl).toContain("category=seo"); + expect(calledUrl).toContain("category=accessibility"); + expect(calledUrl).toContain("category=best-practices"); + expect(calledUrl).toContain("category=performance"); + }); + + it("passes all monitor properties through to httpProvider", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "key" }); + const monitor = makeMonitor({ id: "mon-5", teamId: "team-9" }); + + await provider.handle(monitor); + + expect(httpProvider.handle).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-5", teamId: "team-9" })); + }); + + it("throws AppError when url is missing", async () => { + const { provider } = createProvider(); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for PageSpeed monitor"); + }); + + it("throws AppError when httpProvider.handle throws", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "key" }); + (httpProvider.handle as jest.Mock).mockRejectedValue(new Error("timeout")); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("timeout"); + }); + + it("throws AppError with default message for non-Error thrown values", async () => { + const { provider, httpProvider } = createProvider({ apiKey: "key" }); + (httpProvider.handle as jest.Mock).mockRejectedValue("string error"); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing PageSpeed request"); + }); + + it("handles null apiKey from DB settings", async () => { + const { provider, logger } = createProvider({ apiKey: null as any }); + + await provider.handle(makeMonitor()); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("API key not found") })); + }); + + it("handles null dbSettings response", async () => { + const logger = createMockLogger(); + const httpProvider = createMockHttpProvider(); + const settingsService = { + getDBSettings: jest.fn().mockResolvedValue(null), + } as unknown as jest.Mocked; + const provider = new PageSpeedProvider(httpProvider, settingsService, logger as any); + + await provider.handle(makeMonitor()); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("API key not found") })); + }); +}); diff --git a/server/test/unit/providers/pingProvider.test.ts b/server/test/unit/providers/pingProvider.test.ts new file mode 100644 index 000000000..9c8dbd538 --- /dev/null +++ b/server/test/unit/providers/pingProvider.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { PingProvider } from "../../../src/service/infrastructure/network/PingProvider.ts"; +import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; +import type { Monitor } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "ping", + url: "example.com", + ...overrides, + }) as Monitor; + +const createMockPing = (result?: Partial<{ alive: boolean; time: number | string }>) => ({ + promise: { + probe: jest.fn().mockResolvedValue({ + alive: true, + time: 25, + ...result, + }), + }, +}); + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("PingProvider", { + create: () => new PingProvider(createMockPing() as any), + supportedType: "ping", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("PingProvider", () => { + it("returns success when host is alive", async () => { + const provider = new PingProvider(createMockPing({ alive: true, time: 30 }) as any); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "ping", + status: true, + code: 200, + message: "Success", + responseTime: 30, + }) + ); + }); + + it("returns failure when host is not alive", async () => { + const provider = new PingProvider(createMockPing({ alive: false, time: 0 }) as any); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: 5000, + message: "Ping failed", + }) + ); + }); + + it("parses string time values", async () => { + const provider = new PingProvider(createMockPing({ alive: true, time: "42.5" }) as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.responseTime).toBe(42.5); + }); + + it("defaults responseTime to 0 for non-numeric time", async () => { + const provider = new PingProvider(createMockPing({ alive: true, time: "unknown" }) as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.responseTime).toBe(0); + }); + + it("defaults status to false when alive is undefined", async () => { + const provider = new PingProvider(createMockPing({ alive: undefined as any }) as any); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + }); + + it("sanitizes URL by stripping protocol and path", async () => { + const mockPing = createMockPing(); + const provider = new PingProvider(mockPing as any); + + await provider.handle(makeMonitor({ url: "https://example.com/path?query=1" })); + + expect(mockPing.promise.probe).toHaveBeenCalledWith("example.com"); + }); + + it("sanitizes URL by stripping port", async () => { + const mockPing = createMockPing(); + const provider = new PingProvider(mockPing as any); + + await provider.handle(makeMonitor({ url: "example.com:8080" })); + + expect(mockPing.promise.probe).toHaveBeenCalledWith("example.com"); + }); + + it("throws AppError when url is missing", async () => { + const provider = new PingProvider(createMockPing() as any); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for ping monitor"); + }); + + it("throws AppError when ping.probe rejects", async () => { + const mockPing = { promise: { probe: jest.fn().mockRejectedValue(new Error("Host unreachable")) } }; + const provider = new PingProvider(mockPing as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("Host unreachable"); + }); + + it("throws AppError when probe returns no response", async () => { + const mockPing = { promise: { probe: jest.fn().mockResolvedValue(null) } }; + const provider = new PingProvider(mockPing as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("No response from ping"); + }); + + it("throws AppError with stringified message for non-Error throws", async () => { + const mockPing = { promise: { probe: jest.fn().mockRejectedValue(42) } }; + const provider = new PingProvider(mockPing as any); + + await expect(provider.handle(makeMonitor())).rejects.toThrow("42"); + }); +}); diff --git a/server/test/unit/providers/portProvider.test.ts b/server/test/unit/providers/portProvider.test.ts new file mode 100644 index 000000000..a972f7c9c --- /dev/null +++ b/server/test/unit/providers/portProvider.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { PortProvider } from "../../../src/service/infrastructure/network/PortProvider.ts"; +import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; +import { NETWORK_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "port", + url: "example.com", + port: 443, + ...overrides, + }) as Monitor; + +const createMockNet = (behavior: "connect" | "timeout" | "error" = "connect") => { + const socket = { + setTimeout: jest.fn(), + on: jest.fn(), + end: jest.fn(), + destroy: jest.fn(), + }; + + const createConnection = jest.fn((opts: any, onConnect: () => void) => { + if (behavior === "connect") { + // Simulate immediate connection + process.nextTick(onConnect); + } else if (behavior === "timeout") { + process.nextTick(() => { + const timeoutCb = socket.on.mock.calls.find((c: any) => c[0] === "timeout"); + // Simulate via setTimeout handler + const setTimeoutCb = socket.setTimeout.mock.calls[0]; + if (setTimeoutCb) { + // The timeout event handler is registered via socket.on("timeout", ...) + } + }); + } else if (behavior === "error") { + process.nextTick(() => { + const errorCb = socket.on.mock.calls.find((c: any) => c[0] === "error"); + if (errorCb) { + errorCb[1](new Error("ECONNREFUSED")); + } + }); + } + return socket; + }); + + return { createConnection, __socket: socket } as any; +}; + +const createSuccessNet = () => { + const socket = { + setTimeout: jest.fn(), + on: jest.fn(), + end: jest.fn(), + destroy: jest.fn(), + }; + return { + createConnection: jest.fn((_opts: any, onConnect: () => void) => { + process.nextTick(onConnect); + return socket; + }), + } as any; +}; + +const createErrorNet = (error: Error = new Error("ECONNREFUSED")) => { + const socket = { + setTimeout: jest.fn(), + on: jest.fn((event: string, cb: Function) => { + if (event === "error") { + process.nextTick(() => cb(error)); + } + }), + end: jest.fn(), + destroy: jest.fn(), + }; + return { + createConnection: jest.fn(() => socket), + } as any; +}; + +const createTimeoutNet = () => { + const socket = { + setTimeout: jest.fn(), + on: jest.fn((event: string, cb: Function) => { + if (event === "timeout") { + process.nextTick(() => cb()); + } + }), + end: jest.fn(), + destroy: jest.fn(), + }; + return { + createConnection: jest.fn(() => socket), + } as any; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("PortProvider", { + create: () => new PortProvider(createSuccessNet()), + supportedType: "port", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("PortProvider", () => { + it("returns success when port connection succeeds", async () => { + const provider = new PortProvider(createSuccessNet()); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "port", + status: true, + code: 200, + message: "Port check successful", + payload: { success: true }, + }) + ); + expect(result.responseTime).toBeGreaterThanOrEqual(0); + }); + + it("returns failure when connection errors", async () => { + const provider = new PortProvider(createErrorNet()); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "ECONNREFUSED", + payload: { success: false }, + }) + ); + }); + + it("returns failure with generic message for non-Error connection error", async () => { + const net = createErrorNet(); + // Override the on handler to throw a non-Error + const socket = { + setTimeout: jest.fn(), + on: jest.fn((event: string, cb: Function) => { + if (event === "error") { + process.nextTick(() => cb("string error")); + } + }), + end: jest.fn(), + destroy: jest.fn(), + }; + net.createConnection = jest.fn(() => socket); + + const provider = new PortProvider(net); + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("Port check failed"); + }); + + it("returns failure when connection times out", async () => { + const provider = new PortProvider(createTimeoutNet()); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "Connection timeout", + }) + ); + }); + + it("throws AppError when url is missing", async () => { + const provider = new PortProvider(createSuccessNet()); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL and port are required for port monitoring"); + }); + + it("throws AppError when port is missing", async () => { + const provider = new PortProvider(createSuccessNet()); + + await expect(provider.handle(makeMonitor({ port: undefined }))).rejects.toThrow("URL and port are required for port monitoring"); + }); + + it("returns failure with generic message when timeRequest catches non-Error", async () => { + const socket = { + setTimeout: jest.fn(), + on: jest.fn((event: string, cb: Function) => { + if (event === "error") { + process.nextTick(() => cb({ notAnError: true })); + } + }), + end: jest.fn(), + destroy: jest.fn(), + }; + const net = { createConnection: jest.fn(() => socket) } as any; + const provider = new PortProvider(net); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.message).toBe("Port check failed"); + }); + + it("throws AppError wrapping the original Error message", async () => { + const provider = new PortProvider(createSuccessNet()); + + try { + await provider.handle(makeMonitor({ url: "" })); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.service).toBe("PortProvider"); + expect(err.method).toBe("handle"); + } + }); + + it("throws AppError when an unexpected error occurs in outer try", async () => { + const provider = new PortProvider(createSuccessNet()); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw new Error("getter exploded"); + return "example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("getter exploded"); + }); + + it("throws AppError with fallback message when Error has empty message", async () => { + const provider = new PortProvider(createSuccessNet()); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw new Error(""); + return "example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("Error performing port check"); + }); + + it("throws AppError with stringified message for non-Error thrown values", async () => { + const provider = new PortProvider(createSuccessNet()); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw 42; + return "example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("42"); + }); +}); diff --git a/server/test/unit/providers/utils.test.ts b/server/test/unit/providers/utils.test.ts new file mode 100644 index 000000000..64772f432 --- /dev/null +++ b/server/test/unit/providers/utils.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "@jest/globals"; +import { timeRequest, NETWORK_ERROR, PING_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; + +describe("network utils", () => { + describe("timeRequest", () => { + it("returns response and responseTime on success", async () => { + const result = await timeRequest(async () => "hello"); + + expect(result.response).toBe("hello"); + expect(result.responseTime).toBeGreaterThanOrEqual(0); + expect(result.error).toBeNull(); + }); + + it("returns error and responseTime on failure", async () => { + const err = new Error("boom"); + const result = await timeRequest(async () => { + throw err; + }); + + expect(result.response).toBeNull(); + expect(result.responseTime).toBeGreaterThanOrEqual(0); + expect(result.error).toBe(err); + }); + + it("measures elapsed time", async () => { + const result = await timeRequest(async () => { + await new Promise((r) => setTimeout(r, 50)); + return "done"; + }); + + expect(result.responseTime).toBeGreaterThanOrEqual(40); + }); + }); + + describe("constants", () => { + it("NETWORK_ERROR is 5000", () => { + expect(NETWORK_ERROR).toBe(5000); + }); + + it("PING_ERROR is 5001", () => { + expect(PING_ERROR).toBe(5001); + }); + }); +}); diff --git a/server/test/unit/providers/webSocketProvider.test.ts b/server/test/unit/providers/webSocketProvider.test.ts new file mode 100644 index 000000000..d712bb198 --- /dev/null +++ b/server/test/unit/providers/webSocketProvider.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; +import { WebSocketProvider } from "../../../src/service/infrastructure/network/WebSocketProvider.ts"; +import { testStatusProviderContract } from "../../helpers/statusProviderContract.ts"; +import { NETWORK_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; +import type { Monitor } from "../../../src/types/index.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "websocket", + url: "wss://example.com/ws", + ignoreTlsErrors: false, + ...overrides, + }) as Monitor; + +const createMockWS = (behavior: "open" | "error" = "open") => { + return jest.fn().mockImplementation((_url: string, _opts: any) => { + const handlers: Record = {}; + const ws = { + on: jest.fn((event: string, cb: Function) => { + handlers[event] = cb; + if (behavior === "open" && event === "open") { + process.nextTick(() => cb()); + } + if (behavior === "error" && event === "error") { + process.nextTick(() => cb(new Error("Connection failed"))); + } + }), + close: jest.fn(), + }; + return ws; + }) as any; +}; + +// ── Contract ───────────────────────────────────────────────────────────────── + +testStatusProviderContract("WebSocketProvider", { + create: () => new WebSocketProvider(createMockWS("open")), + supportedType: "websocket", + unsupportedType: "http", + makeMonitor: () => makeMonitor(), +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("WebSocketProvider", () => { + it("returns success when connection opens", async () => { + const provider = new WebSocketProvider(createMockWS("open")); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + teamId: "team-1", + type: "websocket", + status: true, + code: 200, + message: "WebSocket check successful", + payload: { connected: true }, + }) + ); + }); + + it("returns failure when connection errors", async () => { + const provider = new WebSocketProvider(createMockWS("error")); + + const result = await provider.handle(makeMonitor()); + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "Connection failed", + payload: { connected: false }, + }) + ); + }); + + it("returns failure with generic message for non-Error connection error", async () => { + const WS = jest.fn().mockImplementation(() => ({ + on: jest.fn((event: string, cb: Function) => { + if (event === "error") { + process.nextTick(() => cb("string error")); + } + }), + close: jest.fn(), + })) as any; + + const provider = new WebSocketProvider(WS); + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("WebSocket check failed"); + }); + + it("passes rejectUnauthorized: false when ignoreTlsErrors is true", async () => { + const WS = jest.fn().mockImplementation((_url: string, opts: any) => { + expect(opts.rejectUnauthorized).toBe(false); + return { + on: jest.fn((event: string, cb: Function) => { + if (event === "open") process.nextTick(() => cb()); + }), + close: jest.fn(), + }; + }) as any; + + const provider = new WebSocketProvider(WS); + await provider.handle(makeMonitor({ ignoreTlsErrors: true })); + + expect(WS).toHaveBeenCalled(); + }); + + it("throws AppError when url is missing", async () => { + const provider = new WebSocketProvider(createMockWS("open")); + + await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("URL is required for WebSocket monitoring"); + }); + + it("throws AppError when an unexpected error occurs in outer try", async () => { + const provider = new WebSocketProvider(createMockWS("open")); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw new Error("getter failed"); + return "wss://example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("getter failed"); + }); + + it("throws AppError with fallback message when Error has empty message", async () => { + const provider = new WebSocketProvider(createMockWS("open")); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw new Error(""); + return "wss://example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("Error performing WebSocket check"); + }); + + it("throws AppError with stringified message for non-Error thrown values", async () => { + const provider = new WebSocketProvider(createMockWS("open")); + const monitor = makeMonitor(); + let calls = 0; + Object.defineProperty(monitor, "url", { + get() { + calls++; + if (calls === 1) throw 99; + return "wss://example.com"; + }, + }); + + await expect(provider.handle(monitor)).rejects.toThrow("99"); + }); + + describe("connection timeout", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it("returns failure on connection timeout", async () => { + // WS that never fires open or error + const WS = jest.fn().mockImplementation(() => ({ + on: jest.fn(), + close: jest.fn(), + })) as any; + + const provider = new WebSocketProvider(WS); + const promise = provider.handle(makeMonitor()); + + await jest.advanceTimersByTimeAsync(10000); + + const result = await promise; + + expect(result).toEqual( + expect.objectContaining({ + status: false, + code: NETWORK_ERROR, + message: "WebSocket connection timeout", + }) + ); + }); + }); +});