import http from "node:http"; import { ControlPortState, ControllerControlStatus, WifiRecoveryRequest, WifiRecoveryResult } from "../src/domain/ControlTypes"; import { ControlApiServer } from "../src/http/ControlApiServer"; import { Logger } from "../src/observability/Logger"; import { ControllerControlBackend } from "../src/services/ControllerControlBackend"; import { RuleEngineService } from "../src/services/RuleEngineService"; function nextPort(): number { return 30_000 + Math.floor(Math.random() * 20_000); } class EdgeBackend implements ControllerControlBackend { public throwStatus = false; public wifiRecoveryRequests: WifiRecoveryRequest[] = []; private ports: ControlPortState[] = [ { port: 1, name: "Port 1", fanGroup: "outlet", currentSpeedLevel: 0, online: true, powerState: false }, { port: 2, name: "Port 2", fanGroup: "inside_inlet", currentSpeedLevel: 0, online: true, powerState: false }, { port: 3, name: "Port 3", fanGroup: "outside_inlet", currentSpeedLevel: 0, online: true, powerState: false }, { port: 4, name: "Port 4", fanGroup: "interior", currentSpeedLevel: 0, online: true, powerState: false } ]; public async getStatus(): Promise { if (this.throwStatus) { throw new Error("status boom"); } return { mode: "ble", backend: "fake", controllerMac: null, connected: false, paired: false, telemetrySource: "ble", lastSnapshotEpochSeconds: null, capabilities: { pairing: true, portControl: true, advancedRules: true }, ports: this.ports, notes: [] }; } public async pair(): Promise { return this.getStatus(); } public async getPorts(): Promise { return this.ports; } public async setPortSpeed(port: number, speedLevel: number): Promise { this.ports = this.ports.map((item) => ( item.port === port ? { ...item, currentSpeedLevel: speedLevel, powerState: speedLevel > 0 } : item )); } public async recoverWifi(request: WifiRecoveryRequest): Promise { this.wifiRecoveryRequests.push(request); return { queued: true, backend: "fake", controllerMac: null, notes: ["queued"] }; } } async function requestRaw( port: number, method: string, path: string, body?: string, headers?: Record ): Promise<{ statusCode: number; text: string; contentType: string }> { return new Promise((resolve, reject) => { const req = http.request( { host: "127.0.0.1", port, method, path, headers }, (res) => { const chunks: Buffer[] = []; res.on("data", (chunk) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); res.on("end", () => { resolve({ statusCode: res.statusCode ?? 0, text: Buffer.concat(chunks).toString("utf8"), contentType: String(res.headers["content-type"] ?? "") }); }); } ); req.on("error", reject); if (body !== undefined) { req.write(body); } req.end(); }); } async function requestJson( port: number, method: string, path: string, payload?: unknown ): Promise<{ statusCode: number; payload: unknown }> { const body = payload === undefined ? undefined : JSON.stringify(payload); const response = await requestRaw( port, method, path, body, body ? { "content-type": "application/json" } : undefined ); return { statusCode: response.statusCode, payload: JSON.parse(response.text) }; } describe("ControlApiServer edge cases", () => { it("serves health and ui routes", async () => { const backend = new EdgeBackend(); const port = nextPort(); const server = new ControlApiServer( port, backend, new RuleEngineService(), new Logger("error", "test"), null ); try { await server.start(); const health = await requestJson(port, "GET", "/healthz"); expect(health.statusCode).toBe(200); expect(health.payload).toEqual({ ok: true }); const ui = await requestRaw(port, "GET", "/ui"); expect(ui.statusCode).toBe(200); expect(ui.contentType).toContain("text/html"); expect(ui.text).toContain("Typhon v2 Control (Scaffold)"); } finally { await server.stop(); } }); it("returns structured validation errors for malformed requests", async () => { const backend = new EdgeBackend(); const port = nextPort(); const server = new ControlApiServer( port, backend, new RuleEngineService(), new Logger("error", "test"), null ); try { await server.start(); const badJson = await requestRaw( port, "POST", "/api/v2/pair", "{bad-json", { "content-type": "application/json" } ); expect(badJson.statusCode).toBe(400); expect(JSON.parse(badJson.text)).toEqual({ error: "request body must be valid JSON" }); const hugeBody = `{\"payload\":\"${"x".repeat(70 * 1024)}\"}`; const tooLarge = await requestRaw( port, "POST", "/api/v2/rules", hugeBody, { "content-type": "application/json" } ); expect(tooLarge.statusCode).toBe(413); expect(JSON.parse(tooLarge.text)).toEqual({ error: "request body too large" }); const missingSpeed = await requestJson(port, "POST", "/api/v2/ports/1/speed", {}); expect(missingSpeed.statusCode).toBe(400); expect(missingSpeed.payload).toEqual({ error: "speed_level is required" }); const missingSsid = await requestJson(port, "POST", "/api/v2/wifi/recover", { password: "pw" }); expect(missingSsid.statusCode).toBe(400); expect(missingSsid.payload).toEqual({ error: "ssid is required" }); const missingPassword = await requestJson(port, "POST", "/api/v2/wifi/recover", { ssid: "atlas" }); expect(missingPassword.statusCode).toBe(400); expect(missingPassword.payload).toEqual({ error: "password is required" }); const invalidRulesBody = await requestJson(port, "POST", "/api/v2/rules", []); expect(invalidRulesBody.statusCode).toBe(400); expect(invalidRulesBody.payload).toEqual({ error: "request body must be a JSON object" }); const notFound = await requestJson(port, "GET", "/api/v2/unknown"); expect(notFound.statusCode).toBe(404); expect(notFound.payload).toEqual({ error: "not found" }); } finally { await server.stop(); } }); it("handles rules read/write and unexpected backend exceptions", async () => { const backend = new EdgeBackend(); const logger = new Logger("debug", "test"); const loggerSpy = jest.spyOn(logger, "error").mockImplementation(() => undefined); const port = nextPort(); const server = new ControlApiServer(port, backend, new RuleEngineService(), logger, null); try { await server.start(); const rulesBefore = await requestJson(port, "GET", "/api/v2/rules"); expect(rulesBefore.statusCode).toBe(200); expect(rulesBefore.payload).toEqual({ rules: expect.objectContaining({ mode: "manual", minimumHoldSeconds: 60, temperatureTargetC: 24, temperatureBandC: 0.5, humidityTargetPercent: 45, humidityBandPercent: 3 }) }); expect((rulesBefore.payload as { rules: { updatedAtEpochSeconds: unknown } }).rules.updatedAtEpochSeconds).toEqual(expect.any(Number)); const rulesAfter = await requestJson(port, "POST", "/api/v2/rules", { humidityBandPercent: 5 }); expect(rulesAfter.statusCode).toBe(200); expect(rulesAfter.payload).toEqual({ rules: expect.objectContaining({ mode: "manual", humidityBandPercent: 5 }) }); expect((rulesAfter.payload as { rules: { updatedAtEpochSeconds: unknown } }).rules.updatedAtEpochSeconds).toEqual(expect.any(Number)); backend.throwStatus = true; const failedStatus = await requestJson(port, "GET", "/api/v2/status"); expect(failedStatus.statusCode).toBe(500); expect(failedStatus.payload).toEqual({ error: "internal server error" }); expect(loggerSpy).toHaveBeenCalledWith( "control api request failed", expect.objectContaining({ error_message: "status boom" }) ); } finally { await server.stop(); } }); });