add provider tests
This commit is contained in:
1 parent
dca09daf35
commit
63efef65c7
15 files changed
+2434
-4
No files matched your search
+1
-1
@@ -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",
|
||||
|
||||
@@ -90,7 +90,7 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
||||
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<DockerStatusPayload> {
|
||||
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,
|
||||
|
||||
@@ -60,7 +60,7 @@ export class GrpcProvider implements IStatusProvider<GrpcStatusPayload> {
|
||||
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);
|
||||
|
||||
@@ -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<unknown>;
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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>): 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "docker",
|
||||
url: "my-container",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const makeContainer = (overrides?: Record<string, any>) => ({
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>): Monitor =>
|
||||
({
|
||||
id: "mon-1",
|
||||
teamId: "team-1",
|
||||
type: "game",
|
||||
url: "play.example.com",
|
||||
port: 25565,
|
||||
gameId: "minecraft",
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const createMockGameDig = (state?: Record<string, any> | 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");
|
||||
});
|
||||
});
|
||||
@@ -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>): 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<typeof createMockClient>) => {
|
||||
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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>): 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<HttpProvider>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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>): 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<string, any>) => ({
|
||||
ok: true,
|
||||
statusCode: 200,
|
||||
statusMessage: "OK",
|
||||
headers: { "content-type": "text/html" },
|
||||
body: "<html></html>",
|
||||
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: "<html>test</html>" }));
|
||||
const { provider } = createProvider();
|
||||
|
||||
const result = await provider.handle(makeMonitor());
|
||||
|
||||
expect(result.payload).toBe("<html>test</html>");
|
||||
});
|
||||
|
||||
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("<html></html>");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>): 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<HttpProvider>;
|
||||
|
||||
const createMockSettingsService = (apiKey?: string) =>
|
||||
({
|
||||
getDBSettings: jest.fn().mockResolvedValue({ pagespeedApiKey: apiKey }),
|
||||
}) as unknown as jest.Mocked<ISettingsService>;
|
||||
|
||||
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<ISettingsService>;
|
||||
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") }));
|
||||
});
|
||||
});
|
||||
@@ -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>): 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");
|
||||
});
|
||||
});
|
||||
@@ -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>): 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>): 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<string, Function> = {};
|
||||
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",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user