diff --git a/server/src/service/infrastructure/networkService.ts b/server/src/service/infrastructure/networkService.ts index 9111ef7b2..ca85d12ac 100644 --- a/server/src/service/infrastructure/networkService.ts +++ b/server/src/service/infrastructure/networkService.ts @@ -118,7 +118,7 @@ export class NetworkService implements INetworkService { }, }); - if (response?.data?.status !== "success") return false; + if (response.data?.status !== "success") return false; return true; } catch (err: unknown) { const originalMessage = err instanceof Error ? err.message : String(err); diff --git a/server/test/unit/services/networkService.test.ts b/server/test/unit/services/networkService.test.ts new file mode 100644 index 000000000..c9f496166 --- /dev/null +++ b/server/test/unit/services/networkService.test.ts @@ -0,0 +1,482 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { NetworkService } from "../../../src/service/infrastructure/networkService.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import { NETWORK_ERROR } from "../../../src/service/infrastructure/network/utils.ts"; +import type { IStatusProvider } from "../../../src/service/infrastructure/network/IStatusProvider.ts"; +import type { Monitor, MonitorType } from "../../../src/types/index.ts"; +import type { AxiosStatic } from "axios"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const createMockAxios = () => + ({ + post: jest.fn(), + }) as unknown as jest.Mocked; + +const createMockProvider = (type: string, result?: unknown) => { + const provider: jest.Mocked> = { + type, + supports: jest.fn((t: MonitorType) => t === type) as any, + handle: jest.fn().mockResolvedValue( + result ?? { + monitorId: "mon-1", + teamId: "team-1", + type, + status: true, + code: 200, + message: "OK", + } + ), + }; + return provider; +}; + +const createService = (providers: IStatusProvider[] = []) => { + const logger = createMockLogger(); + const axios = createMockAxios(); + const service = new NetworkService(axios, logger as any, providers); + return { service, logger, axios }; +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("NetworkService", () => { + // ── Static / instance properties ───────────────────────────────────────── + + describe("serviceName", () => { + it("returns NetworkService from static property", () => { + expect(NetworkService.SERVICE_NAME).toBe("NetworkService"); + }); + + it("returns NetworkService from instance getter", () => { + const { service } = createService(); + expect(service.serviceName).toBe("NetworkService"); + }); + }); + + // ── requestStatus ──────────────────────────────────────────────────────── + + describe("requestStatus", () => { + it("delegates to the matching provider", async () => { + const provider = createMockProvider("http"); + const { service } = createService([provider]); + const monitor = { type: "http", _id: "mon-1" } as unknown as Monitor & { type: "http" }; + + const result = await service.requestStatus(monitor); + + expect(provider.supports).toHaveBeenCalledWith("http"); + expect(provider.handle).toHaveBeenCalledWith(monitor); + expect(result).toEqual( + expect.objectContaining({ + monitorId: "mon-1", + status: true, + code: 200, + }) + ); + }); + + it("selects the correct provider when multiple are registered", async () => { + const httpProvider = createMockProvider("http"); + const pingProvider = createMockProvider("ping"); + const { service } = createService([httpProvider, pingProvider]); + const monitor = { type: "ping", _id: "mon-2" } as unknown as Monitor & { type: "ping" }; + + await service.requestStatus(monitor); + + expect(httpProvider.handle).not.toHaveBeenCalled(); + expect(pingProvider.handle).toHaveBeenCalledWith(monitor); + }); + + it("returns unsupported-type response when no provider matches", async () => { + const { service } = createService([]); + const monitor = { type: "unknown_type" as any, _id: "mon-1" } as unknown as Monitor & { type: any }; + + const result = await service.requestStatus(monitor); + + expect(result).toEqual({ + monitorId: "unknown", + teamId: "unknown", + type: "unknown", + status: false, + code: NETWORK_ERROR, + message: "Unsupported type: unknown_type", + }); + }); + }); + + // ── requestWebhook ─────────────────────────────────────────────────────── + + describe("requestWebhook", () => { + it("sends a POST and returns success response", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockResolvedValue({ status: 200, data: { ok: true } }); + + const result = await service.requestWebhook("slack", "https://hooks.example.com", { text: "hello" }); + + expect(axios.post).toHaveBeenCalledWith("https://hooks.example.com", { text: "hello" }, { headers: { "Content-Type": "application/json" } }); + expect(result).toEqual({ + type: "webhook", + status: true, + code: 200, + message: "Successfully sent slack notification", + payload: { ok: true }, + }); + }); + + it("returns failure with response data on axios error with response", async () => { + const { service, axios, logger } = createService(); + const axiosError = Object.assign(new Error("Request failed"), { + response: { status: 403, data: { error: "forbidden" } }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestWebhook("discord", "https://hooks.example.com", {}); + + expect(result).toEqual({ + type: "webhook", + status: false, + code: 403, + message: "Failed to send discord notification", + payload: { error: "forbidden" }, + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Request failed", + service: "NetworkService", + method: "requestWebhook", + }) + ); + }); + + it("returns failure with NETWORK_ERROR when axios error has no response status", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("timeout"), { + response: { status: undefined, data: undefined }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestWebhook("slack", "https://hooks.example.com", {}); + + expect(result.code).toBe(NETWORK_ERROR); + }); + + it("returns failure with NETWORK_ERROR on non-axios error (no response property)", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(new Error("DNS failure")); + + const result = await service.requestWebhook("slack", "https://hooks.example.com", {}); + + expect(result).toEqual({ + type: "webhook", + status: false, + code: NETWORK_ERROR, + message: "Failed to send slack notification", + }); + }); + + it("handles non-Error thrown values", async () => { + const { service, axios, logger } = createService(); + (axios.post as jest.Mock).mockRejectedValue("string error"); + + const result = await service.requestWebhook("slack", "https://hooks.example.com", {}); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "string error" })); + expect(result).toEqual({ + type: "webhook", + status: false, + code: NETWORK_ERROR, + message: "Failed to send slack notification", + }); + }); + + it("returns NETWORK_ERROR when error has response key but response is undefined", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("fail"), { response: undefined }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestWebhook("slack", "https://hooks.example.com", {}); + + expect(result.code).toBe(NETWORK_ERROR); + expect(result.payload).toBeUndefined(); + }); + }); + + // ── requestPagerDuty ───────────────────────────────────────────────────── + + describe("requestPagerDuty", () => { + const args = { + message: "Server down", + routingKey: "routing-key-123", + monitorUrl: "https://monitor.example.com", + }; + + it("sends correct payload and returns true on success", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockResolvedValue({ data: { status: "success" } }); + + const result = await service.requestPagerDuty(args); + + expect(result).toBe(true); + expect(axios.post).toHaveBeenCalledWith( + "https://events.pagerduty.com/v2/enqueue", + expect.objectContaining({ + routing_key: "routing-key-123", + event_action: "trigger", + payload: expect.objectContaining({ + summary: "Server down", + severity: "critical", + source: "https://monitor.example.com", + }), + }) + ); + }); + + it("returns false when response status is not success", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockResolvedValue({ data: { status: "invalid event" } }); + + const result = await service.requestPagerDuty(args); + + expect(result).toBe(false); + }); + + it("returns false when response data is undefined", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockResolvedValue({ data: undefined }); + + const result = await service.requestPagerDuty(args); + + expect(result).toBe(false); + }); + + it("throws AppError on axios failure with response data", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("Request failed"), { + response: { data: { message: "invalid routing key" } }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + await expect(service.requestPagerDuty(args)).rejects.toThrow("Request failed"); + }); + + it("throws AppError on network error without response", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(new Error("ECONNREFUSED")); + + await expect(service.requestPagerDuty(args)).rejects.toThrow("ECONNREFUSED"); + }); + + it("throws AppError with default message for non-Error thrown value", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(null); + + await expect(service.requestPagerDuty(args)).rejects.toThrow("null"); + }); + + it("uses fallback message when Error has empty string message", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(new Error("")); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.message).toBe("Error sending PagerDuty notification"); + } + }); + + it("includes responseData when non-Error object has response property", async () => { + const { service, axios } = createService(); + const errorObj = { response: { data: { detail: "bad key" } } }; + (axios.post as jest.Mock).mockRejectedValue(errorObj); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.details).toEqual({ responseData: { detail: "bad key" } }); + } + }); + + it("sets responseData to undefined when error is a primitive (non-object)", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(42); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.details).toEqual({ responseData: undefined }); + } + }); + + it("sets responseData to undefined when object has response key set to undefined", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue({ response: undefined }); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.details).toEqual({ responseData: undefined }); + } + }); + + it("includes responseData in AppError details when available", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("Bad request"), { + response: { data: { errors: ["invalid key"] } }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.details).toEqual({ + responseData: { errors: ["invalid key"] }, + }); + } + }); + + it("sets responseData to undefined in AppError when error has no response", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockRejectedValue(new Error("timeout")); + + try { + await service.requestPagerDuty(args); + expect.unreachable("should have thrown"); + } catch (err: any) { + expect(err.details).toEqual({ responseData: undefined }); + } + }); + }); + + // ── requestMatrix ──────────────────────────────────────────────────────── + + describe("requestMatrix", () => { + const args = { + homeserverUrl: "https://matrix.example.com", + accessToken: "token-abc", + roomId: "!room:example.com", + message: "Alert", + }; + + it("sends correct payload and returns success response", async () => { + const { service, axios } = createService(); + (axios.post as jest.Mock).mockResolvedValue({ status: 200, data: { event_id: "$abc" } }); + + const result = await service.requestMatrix(args); + + expect(result).toEqual({ + status: true, + code: 200, + message: "Successfully sent Matrix notification", + }); + expect(axios.post).toHaveBeenCalledWith( + "https://matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message?access_token=token-abc", + { + msgtype: "m.text", + body: "Alert", + format: "org.matrix.custom.html", + formatted_body: "Alert", + }, + { headers: { "Content-Type": "application/json" } } + ); + }); + + it("returns failure with response data on axios error with response (Error instance)", async () => { + const { service, axios, logger } = createService(); + const axiosError = Object.assign(new Error("Forbidden"), { + response: { status: 403, data: { errcode: "M_FORBIDDEN" } }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestMatrix(args); + + expect(result).toEqual({ + status: false, + code: 403, + message: "Failed to send Matrix notification", + payload: { errcode: "M_FORBIDDEN" }, + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Forbidden", + service: "NetworkService", + method: "requestMatrix", + }) + ); + }); + + it("returns NETWORK_ERROR when axios error response has no status", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("timeout"), { + response: { status: 0, data: undefined }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestMatrix(args); + + expect(result.code).toBe(NETWORK_ERROR); + }); + + it("returns failure with NETWORK_ERROR on Error without response property", async () => { + const { service, axios, logger } = createService(); + (axios.post as jest.Mock).mockRejectedValue(new Error("DNS failure")); + + const result = await service.requestMatrix(args); + + expect(result).toEqual({ + status: false, + code: NETWORK_ERROR, + message: "Failed to send Matrix notification", + }); + // First warn call is from the Error instanceof branch + expect(logger.warn).toHaveBeenCalledTimes(2); + }); + + it("returns failure with NETWORK_ERROR on non-Error thrown value", async () => { + const { service, axios, logger } = createService(); + (axios.post as jest.Mock).mockRejectedValue("string error"); + + const result = await service.requestMatrix(args); + + expect(result).toEqual({ + status: false, + code: NETWORK_ERROR, + message: "Failed to send Matrix notification", + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: "string error", + service: "NetworkService", + method: "requestMatrix", + }) + ); + }); + + it("returns NETWORK_ERROR when Error has response key but response is undefined", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("fail"), { response: undefined }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestMatrix(args); + + expect(result.code).toBe(NETWORK_ERROR); + }); + + it("returns NETWORK_ERROR when Error response has undefined status", async () => { + const { service, axios } = createService(); + const axiosError = Object.assign(new Error("fail"), { + response: { status: undefined, data: { error: "unknown" } }, + }); + (axios.post as jest.Mock).mockRejectedValue(axiosError); + + const result = await service.requestMatrix(args); + + expect(result.code).toBe(NETWORK_ERROR); + expect(result.payload).toEqual({ error: "unknown" }); + }); + }); +});