From bb55f3b3ebef48abf2fb86cc1769d965f3dcf135 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 9 Apr 2026 09:41:17 -0700 Subject: [PATCH] add tests for notificaitonMessageBuilder --- .../notificationMessageBuilder.test.ts | 761 ++++++++++++++++++ 1 file changed, 761 insertions(+) create mode 100644 server/test/unit/services/notificationMessageBuilder.test.ts diff --git a/server/test/unit/services/notificationMessageBuilder.test.ts b/server/test/unit/services/notificationMessageBuilder.test.ts new file mode 100644 index 000000000..d10bd1604 --- /dev/null +++ b/server/test/unit/services/notificationMessageBuilder.test.ts @@ -0,0 +1,761 @@ +import { describe, expect, it, beforeEach } from "@jest/globals"; +import { NotificationMessageBuilder } from "../../../src/service/infrastructure/notificationMessageBuilder.ts"; +import type { Monitor, MonitorStatusResponse, HardwareStatusPayload } from "../../../src/types/index.ts"; +import type { MonitorActionDecision } from "../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + name: "Test Monitor", + url: "https://example.com", + type: "http", + status: "down", + teamId: "team-1", + cpuAlertThreshold: undefined, + memoryAlertThreshold: undefined, + diskAlertThreshold: undefined, + tempAlertThreshold: undefined, + ...overrides, + }) as Monitor; + +const makeDecision = (overrides?: Partial): MonitorActionDecision => ({ + shouldCreateIncident: false, + shouldResolveIncident: false, + shouldSendNotification: true, + incidentReason: null, + notificationReason: "status_change", + ...overrides, +}); + +const makeStatusResponse = (overrides?: Partial): MonitorStatusResponse => + ({ + monitorId: "mon-1", + teamId: "team-1", + type: "http", + status: false, + code: 500, + message: "Internal Server Error", + ...overrides, + }) as MonitorStatusResponse; + +const makeHardwarePayload = (overrides?: Partial): HardwareStatusPayload => + ({ + data: { + cpu: { usage_percent: 0.5, temperature: [45] }, + memory: { usage_percent: 0.6 }, + disk: [{ usage_percent: 0.7 }], + ...overrides, + }, + }) as HardwareStatusPayload; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("NotificationMessageBuilder", () => { + let builder: NotificationMessageBuilder; + + beforeEach(() => { + builder = new NotificationMessageBuilder(); + }); + + describe("SERVICE_NAME", () => { + it("returns NotificationMessageBuilder", () => { + expect(NotificationMessageBuilder.SERVICE_NAME).toBe("NotificationMessageBuilder"); + }); + }); + + // ── buildMessage ───────────────────────────────────────────────────── + + describe("buildMessage", () => { + it("builds a monitor_down message", () => { + const monitor = makeMonitor({ status: "down" }); + const decision = makeDecision(); + const response = makeStatusResponse(); + + const msg = builder.buildMessage(monitor, response, decision, "https://app.example.com"); + + expect(msg.type).toBe("monitor_down"); + expect(msg.severity).toBe("critical"); + expect(msg.monitor).toEqual({ + id: "mon-1", + name: "Test Monitor", + url: "https://example.com", + type: "http", + status: "down", + }); + expect(msg.content.title).toBe("Monitor Down: Test Monitor"); + expect(msg.clientHost).toBe("https://app.example.com"); + expect(msg.metadata).toEqual({ + teamId: "team-1", + notificationReason: "status_change", + }); + }); + + it("builds a monitor_up message", () => { + const monitor = makeMonitor({ status: "up", type: "http" }); + const decision = makeDecision({ notificationReason: "status_change" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "https://app.example.com"); + + expect(msg.type).toBe("monitor_up"); + expect(msg.severity).toBe("success"); + expect(msg.content.title).toBe("Monitor Recovered: Test Monitor"); + }); + + it("builds a threshold_breach message", () => { + const monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 }); + const decision = makeDecision({ notificationReason: "threshold_breach" }); + const response = makeStatusResponse({ + type: "hardware", + payload: makeHardwarePayload({ cpu: { usage_percent: 0.9, temperature: [50] } as any }), + } as any); + + const msg = builder.buildMessage(monitor, response, decision, "https://app.example.com"); + + expect(msg.type).toBe("threshold_breach"); + expect(msg.severity).toBe("warning"); + expect(msg.content.title).toBe("Threshold Exceeded: Test Monitor"); + expect(msg.content.thresholds).toBeDefined(); + expect(msg.content.thresholds!.length).toBeGreaterThan(0); + }); + + it("builds a threshold_resolved message for hardware monitor recovering", () => { + const monitor = makeMonitor({ status: "up", type: "hardware" }); + const decision = makeDecision({ notificationReason: "status_change" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "https://app.example.com"); + + expect(msg.type).toBe("threshold_resolved"); + expect(msg.severity).toBe("success"); + expect(msg.content.title).toBe("Thresholds Resolved: Test Monitor"); + }); + + it("uses notificationReason from decision, falling back to status_change", () => { + const monitor = makeMonitor({ status: "down" }); + const decision = makeDecision({ notificationReason: null }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, "https://app.example.com"); + + expect(msg.metadata.notificationReason).toBe("status_change"); + }); + }); + + // ── determineNotificationType (via buildMessage) ───────────────────── + + describe("determineNotificationType", () => { + it("returns monitor_down when status is down, even if notificationReason is threshold_breach", () => { + const monitor = makeMonitor({ status: "down" }); + const decision = makeDecision({ notificationReason: "threshold_breach" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, ""); + + expect(msg.type).toBe("monitor_down"); + }); + + it("returns threshold_breach when reason is threshold_breach and status is not down", () => { + const monitor = makeMonitor({ status: "up", type: "hardware" }); + const decision = makeDecision({ notificationReason: "threshold_breach" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, ""); + + expect(msg.type).toBe("threshold_breach"); + }); + + it("returns threshold_resolved for hardware monitor with status_change and up", () => { + const monitor = makeMonitor({ status: "up", type: "hardware" }); + const decision = makeDecision({ notificationReason: "status_change" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, ""); + + expect(msg.type).toBe("threshold_resolved"); + }); + + it("returns monitor_up for non-hardware monitor with status up", () => { + const monitor = makeMonitor({ status: "up", type: "http" }); + const decision = makeDecision({ notificationReason: "status_change" }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, ""); + + expect(msg.type).toBe("monitor_up"); + }); + + it("returns monitor_up as default for unrecognized status", () => { + const monitor = makeMonitor({ status: "unknown" as any }); + const decision = makeDecision({ notificationReason: null }); + + const msg = builder.buildMessage(monitor, makeStatusResponse(), decision, ""); + + expect(msg.type).toBe("monitor_up"); + }); + }); + + // ── determineSeverity (via buildMessage) ───────────────────────────── + + describe("determineSeverity", () => { + it("returns critical for monitor_down", () => { + const msg = builder.buildMessage(makeMonitor({ status: "down" }), makeStatusResponse(), makeDecision(), ""); + expect(msg.severity).toBe("critical"); + }); + + it("returns warning for threshold_breach", () => { + const msg = builder.buildMessage( + makeMonitor({ status: "up", type: "hardware" }), + makeStatusResponse(), + makeDecision({ notificationReason: "threshold_breach" }), + "" + ); + expect(msg.severity).toBe("warning"); + }); + + it("returns success for monitor_up", () => { + const msg = builder.buildMessage( + makeMonitor({ status: "up", type: "http" }), + makeStatusResponse(), + makeDecision({ notificationReason: "status_change" }), + "" + ); + expect(msg.severity).toBe("success"); + }); + + it("returns success for threshold_resolved", () => { + const msg = builder.buildMessage( + makeMonitor({ status: "up", type: "hardware" }), + makeStatusResponse(), + makeDecision({ notificationReason: "status_change" }), + "" + ); + expect(msg.severity).toBe("success"); + }); + }); + + // ── buildContent variants ──────────────────────────────────────────── + + describe("buildContent", () => { + describe("monitor_down", () => { + it("includes response code and error message when present", () => { + const monitor = makeMonitor({ status: "down" }); + const response = makeStatusResponse({ code: 503, message: "Service Unavailable" }); + + const msg = builder.buildMessage(monitor, response, makeDecision(), ""); + + expect(msg.content.details).toContain("Response Code: 503"); + expect(msg.content.details).toContain("Error: Service Unavailable"); + }); + + it("omits response code when falsy", () => { + const monitor = makeMonitor({ status: "down" }); + const response = makeStatusResponse({ code: 0, message: "" }); + + const msg = builder.buildMessage(monitor, response, makeDecision(), ""); + + expect(msg.content.details).not.toContainEqual(expect.stringContaining("Response Code:")); + expect(msg.content.details).not.toContainEqual(expect.stringContaining("Error:")); + }); + + it("includes URL, Status, and Type in details", () => { + const monitor = makeMonitor({ status: "down" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + + expect(msg.content.details).toContain("URL: https://example.com"); + expect(msg.content.details).toContain("Status: Down"); + expect(msg.content.details).toContain("Type: http"); + }); + + it("sets summary text", () => { + const monitor = makeMonitor({ status: "down" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + expect(msg.content.summary).toBe('Monitor "Test Monitor" is currently down and unreachable.'); + }); + + it("sets timestamp", () => { + const monitor = makeMonitor({ status: "down" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + expect(msg.content.timestamp).toBeInstanceOf(Date); + }); + }); + + describe("monitor_up", () => { + it("includes recovery details", () => { + const monitor = makeMonitor({ status: "up", type: "http" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + + expect(msg.content.title).toBe("Monitor Recovered: Test Monitor"); + expect(msg.content.summary).toBe('Monitor "Test Monitor" is back up and operational.'); + expect(msg.content.details).toContain("Status: Up"); + }); + }); + + describe("threshold_breach", () => { + it("includes threshold breaches in content", () => { + const monitor = makeMonitor({ status: "up", type: "hardware", cpuAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.9, temperature: [50] } as any }), + } as any); + + const msg = builder.buildMessage(monitor, response, makeDecision({ notificationReason: "threshold_breach" }), ""); + + expect(msg.content.thresholds).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + metric: "cpu", + currentValue: 90, + threshold: 80, + }), + ]) + ); + }); + }); + + describe("threshold_resolved", () => { + it("includes resolved details", () => { + const monitor = makeMonitor({ status: "up", type: "hardware" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + + expect(msg.content.title).toBe("Thresholds Resolved: Test Monitor"); + expect(msg.content.summary).toBe('Monitor "Test Monitor" thresholds have returned to normal.'); + expect(msg.content.details).toContain("Status: Up"); + }); + }); + + describe("default content", () => { + it("builds default content for unhandled notification type", () => { + // Force default by creating a scenario where type falls through + // We can test buildContent default by subclassing or testing indirectly + // The default case in buildContent handles "test" type among others + // Since determineNotificationType never returns "test", we test via + // a monitor with unrecognized status that maps to monitor_up + // Instead, let's verify the default case exists by checking that + // non-standard types would get default content + const monitor = makeMonitor({ status: "up", type: "http" }); + const msg = builder.buildMessage(monitor, makeStatusResponse(), makeDecision(), ""); + + // monitor_up is handled, so content should be monitor_up specific + expect(msg.content.title).toBe("Monitor Recovered: Test Monitor"); + }); + }); + }); + + // ── determineSeverity edge cases (via private method) ──────────────── + + describe("determineSeverity edge cases", () => { + it("returns info for test type", () => { + const result = (builder as any).determineSeverity("test"); + expect(result).toBe("info"); + }); + + it("returns info for unknown type (default)", () => { + const result = (builder as any).determineSeverity("unknown_type"); + expect(result).toBe("info"); + }); + }); + + // ── buildContent edge cases (via private method) ───────────────────── + + describe("buildContent edge cases", () => { + it("returns default content for unhandled notification type", () => { + const monitor = makeMonitor({ status: "up", type: "http" }); + const result = (builder as any).buildContent("test", monitor, makeStatusResponse()); + + expect(result.title).toBe("Monitor: Test Monitor"); + expect(result.summary).toBe('Status update for monitor "Test Monitor".'); + expect(result.details).toContain("URL: https://example.com"); + expect(result.details).toContain("Status: up"); + expect(result.details).toContain("Type: http"); + }); + }); + + // ── extractThresholdBreaches ───────────────────────────────────────── + + describe("extractThresholdBreaches", () => { + it("returns empty array for non-hardware monitor", () => { + const monitor = makeMonitor({ type: "http" }); + const response = makeStatusResponse(); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toEqual([]); + }); + + it("returns empty array when payload is missing", () => { + const monitor = makeMonitor({ type: "hardware" }); + const response = makeStatusResponse({ payload: undefined } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toEqual([]); + }); + + it("returns empty array when hardware data is missing", () => { + const monitor = makeMonitor({ type: "hardware" }); + const response = makeStatusResponse({ payload: { data: undefined } } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toEqual([]); + }); + + // ── CPU ────────────────────────────────────────────────────────── + + describe("cpu threshold", () => { + it("detects CPU breach when usage exceeds threshold", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.9, temperature: [50] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toContainEqual( + expect.objectContaining({ + metric: "cpu", + currentValue: 90, + threshold: 80, + unit: "%", + formattedValue: "90.0%", + }) + ); + }); + + it("does not report CPU breach when usage is below threshold", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [50] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined(); + }); + + it("skips CPU check when cpuAlertThreshold is undefined", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: undefined }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.99 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined(); + }); + + it("skips CPU check when cpuAlertThreshold is null", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: null as any }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.99 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined(); + }); + + it("skips CPU check when cpu usage_percent is undefined", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: undefined } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined(); + }); + + it("skips CPU check when cpu object is undefined", () => { + const monitor = makeMonitor({ type: "hardware", cpuAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: undefined as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "cpu")).toBeUndefined(); + }); + }); + + // ── Memory ─────────────────────────────────────────────────────── + + describe("memory threshold", () => { + it("detects memory breach when usage exceeds threshold", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: { usage_percent: 0.85 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toContainEqual( + expect.objectContaining({ + metric: "memory", + currentValue: 85, + threshold: 70, + unit: "%", + formattedValue: "85.0%", + }) + ); + }); + + it("does not report memory breach when usage is below threshold", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 90 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: { usage_percent: 0.5 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "memory")).toBeUndefined(); + }); + + it("skips memory check when memoryAlertThreshold is undefined", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: undefined }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: { usage_percent: 0.99 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "memory")).toBeUndefined(); + }); + + it("skips memory check when memoryAlertThreshold is null", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: null as any }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: { usage_percent: 0.99 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "memory")).toBeUndefined(); + }); + + it("skips memory check when memory usage_percent is undefined", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: { usage_percent: undefined } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "memory")).toBeUndefined(); + }); + + it("skips memory check when memory object is undefined", () => { + const monitor = makeMonitor({ type: "hardware", memoryAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ memory: undefined as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "memory")).toBeUndefined(); + }); + }); + + // ── Disk ───────────────────────────────────────────────────────── + + describe("disk threshold", () => { + it("detects disk breach using highest usage across multiple disks", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ + disk: [{ usage_percent: 0.5 }, { usage_percent: 0.95 }, { usage_percent: 0.7 }] as any, + }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toContainEqual( + expect.objectContaining({ + metric: "disk", + currentValue: 95, + threshold: 80, + formattedValue: "95.0%", + }) + ); + }); + + it("does not report disk breach when all disks are below threshold", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 90 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ + disk: [{ usage_percent: 0.5 }, { usage_percent: 0.6 }] as any, + }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "disk")).toBeUndefined(); + }); + + it("skips disk check when diskAlertThreshold is undefined", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: undefined }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ disk: [{ usage_percent: 0.99 }] as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "disk")).toBeUndefined(); + }); + + it("skips disk check when diskAlertThreshold is null", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: null as any }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ disk: [{ usage_percent: 0.99 }] as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "disk")).toBeUndefined(); + }); + + it("skips disk check when disk is not an array", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ disk: "not-an-array" as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "disk")).toBeUndefined(); + }); + + it("skips disks with undefined usage_percent", () => { + const monitor = makeMonitor({ type: "hardware", diskAlertThreshold: 50 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ + disk: [{ usage_percent: undefined }, { usage_percent: 0.3 }] as any, + }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "disk")).toBeUndefined(); + }); + }); + + // ── Temperature ────────────────────────────────────────────────── + + describe("temperature threshold", () => { + it("detects temperature breach from array of temperatures", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [65, 75, 68] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toContainEqual( + expect.objectContaining({ + metric: "temp", + currentValue: 75, + threshold: 70, + unit: "°C", + formattedValue: "75.0°C", + }) + ); + }); + + it("handles single temperature value (non-array)", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: 80 } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches).toContainEqual( + expect.objectContaining({ + metric: "temp", + currentValue: 80, + threshold: 70, + }) + ); + }); + + it("does not report temperature breach when below threshold", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 80 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [60, 65] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "temp")).toBeUndefined(); + }); + + it("skips temperature check when tempAlertThreshold is undefined", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: undefined }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [99] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "temp")).toBeUndefined(); + }); + + it("skips temperature check when tempAlertThreshold is null", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: null as any }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: [99] } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "temp")).toBeUndefined(); + }); + + it("skips temperature check when cpu.temperature is falsy", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: { usage_percent: 0.5, temperature: null } as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "temp")).toBeUndefined(); + }); + + it("skips temperature check when cpu object is undefined", () => { + const monitor = makeMonitor({ type: "hardware", tempAlertThreshold: 70 }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ cpu: undefined as any }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + expect(breaches.find((b) => b.metric === "temp")).toBeUndefined(); + }); + }); + + // ── Combined ───────────────────────────────────────────────────── + + describe("combined thresholds", () => { + it("detects multiple breaches simultaneously", () => { + const monitor = makeMonitor({ + type: "hardware", + cpuAlertThreshold: 80, + memoryAlertThreshold: 70, + diskAlertThreshold: 85, + tempAlertThreshold: 65, + }); + const response = makeStatusResponse({ + payload: makeHardwarePayload({ + cpu: { usage_percent: 0.9, temperature: [70] } as any, + memory: { usage_percent: 0.85 } as any, + disk: [{ usage_percent: 0.95 }] as any, + }), + } as any); + + const breaches = builder.extractThresholdBreaches(monitor, response); + + const metrics = breaches.map((b) => b.metric); + expect(metrics).toContain("cpu"); + expect(metrics).toContain("memory"); + expect(metrics).toContain("disk"); + expect(metrics).toContain("temp"); + }); + }); + }); +});