233 lines
7.6 KiB
TypeScript
233 lines
7.6 KiB
TypeScript
import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals";
|
|
import { WebSocketProvider } from "../../../../src/service/networkProviders/WebSocketProvider.ts";
|
|
import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts";
|
|
import { NETWORK_ERROR } from "../../../../src/types/network.ts";
|
|
import type { Monitor } from "../../../../src/domain/monitors/monitor.type.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",
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── Peer reachability ────────────────────────────────────────────────────
|
|
// `ws` rejects a non-101 handshake with a plain Error, so only the errno cases can be told apart from the
|
|
// instance losing its egress. That leaves a server answering the handshake with an HTTP error indistinct.
|
|
|
|
const createErrorWS = (error: Error) =>
|
|
jest.fn().mockImplementation(() => ({
|
|
on: jest.fn((event: string, cb: Function) => {
|
|
if (event === "error") process.nextTick(() => cb(error));
|
|
}),
|
|
close: jest.fn(),
|
|
})) as any;
|
|
|
|
it.each([["ECONNREFUSED"], ["ECONNRESET"]])("reports the peer as having answered for %s", async (code) => {
|
|
const provider = new WebSocketProvider(createErrorWS(Object.assign(new Error("connect failed"), { code })));
|
|
|
|
const result = await provider.handle(makeMonitor());
|
|
|
|
expect(result.status).toBe(false);
|
|
expect(result.code).toBe(NETWORK_ERROR);
|
|
expect(result.peerResponded).toBe(true);
|
|
});
|
|
|
|
it.each([["ETIMEDOUT"], ["ENOTFOUND"], ["EHOSTUNREACH"]])("reports the peer as silent for %s", async (code) => {
|
|
const provider = new WebSocketProvider(createErrorWS(Object.assign(new Error("connect failed"), { code })));
|
|
|
|
const result = await provider.handle(makeMonitor());
|
|
|
|
expect(result.peerResponded).toBe(false);
|
|
});
|
|
|
|
it("reports the peer as having answered when the socket opens", async () => {
|
|
const provider = new WebSocketProvider(createMockWS("open"));
|
|
|
|
const result = await provider.handle(makeMonitor());
|
|
|
|
expect(result.peerResponded).toBe(true);
|
|
});
|
|
});
|