From 4ea93d938588662160171aa95efed5cb300ee27c Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Fri, 17 Apr 2026 06:06:14 -0300 Subject: [PATCH] test: raise typhon branch coverage above quality gate --- tests/AcInfinityProtocol.test.ts | 33 ++++ tests/ControlApiServerEdge.test.ts | 270 ++++++++++++++++++++++++++ tests/LoggerAndTemplate.test.ts | 53 +++++ tests/NodeBleControllerClient.test.ts | 215 ++++++++++++++++++++ 4 files changed, 571 insertions(+) create mode 100644 tests/ControlApiServerEdge.test.ts create mode 100644 tests/LoggerAndTemplate.test.ts create mode 100644 tests/NodeBleControllerClient.test.ts diff --git a/tests/AcInfinityProtocol.test.ts b/tests/AcInfinityProtocol.test.ts index d994cba..79a9264 100644 --- a/tests/AcInfinityProtocol.test.ts +++ b/tests/AcInfinityProtocol.test.ts @@ -48,4 +48,37 @@ describe("AcInfinityProtocol", () => { expect(parsed.workType).toBe(2); expect(parsed.fanSpeedGuess).toBe(6); }); + + it("omits port selector bytes for unsupported device types", () => { + const protocol = new AcInfinityProtocol(); + const packet = protocol.buildGetModelData(5, 9); + + expect(packet[0]).toBe(0xa5); + expect(packet[9]).toBe(1); + expect(Array.from(packet.slice(10, 18))).toEqual([ + 16, 17, 18, 19, 20, 21, 22, 23 + ]); + }); + + it("throws on invalid set-level inputs and malformed telemetry packets", () => { + const protocol = new AcInfinityProtocol(); + + expect(() => protocol.buildSetLevel(11, 2, 11, 3)).toThrow( + "level must be an integer between 0 and 10" + ); + expect(() => protocol.buildSetLevel(11, 1, 4, -1)).toThrow( + "port must be an integer between 0 and 255" + ); + expect(() => protocol.parseTelemetryNotification(Buffer.from([0x00, 0x01, 0x02]))).toThrow( + "unexpected telemetry notification payload" + ); + }); + + it("wraps sequence numbers after max uint16", () => { + const protocol = new AcInfinityProtocol() as unknown as { sequence: number; nextSequence(): number }; + protocol.sequence = 65535; + + expect(protocol.nextSequence()).toBe(1); + expect(protocol.nextSequence()).toBe(2); + }); }); diff --git a/tests/ControlApiServerEdge.test.ts b/tests/ControlApiServerEdge.test.ts new file mode 100644 index 0000000..10b461b --- /dev/null +++ b/tests/ControlApiServerEdge.test.ts @@ -0,0 +1,270 @@ +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(); + } + }); +}); diff --git a/tests/LoggerAndTemplate.test.ts b/tests/LoggerAndTemplate.test.ts new file mode 100644 index 0000000..e850956 --- /dev/null +++ b/tests/LoggerAndTemplate.test.ts @@ -0,0 +1,53 @@ +import { Logger } from "../src/observability/Logger"; +import { renderControlUi } from "../src/ui/template"; + +describe("Logger", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("suppresses lower-priority log lines", () => { + const logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined); + const logger = new Logger("info", "gate-test"); + + logger.debug("not emitted", { key: "value" }); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + it("routes warn and error levels to the correct console methods", () => { + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined); + const errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined); + const logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined); + const logger = new Logger("debug", "gate-test"); + + logger.info("info-message", { context: 1 }); + logger.warn("warn-message", { context: 2 }); + logger.error("error-message", { context: 3 }); + + expect(logSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledTimes(1); + + const warnPayload = JSON.parse(String(warnSpy.mock.calls[0]?.[0] ?? "{}")) as { + level?: string; + component?: string; + context?: number; + }; + expect(warnPayload.level).toBe("warn"); + expect(warnPayload.component).toBe("gate-test"); + expect(warnPayload.context).toBe(2); + }); +}); + +describe("renderControlUi", () => { + it("escapes unsafe MAC input and renders fallback when default MAC is missing", () => { + const escaped = renderControlUi(`AA:BB:'`); + expect(escaped).toContain("<script>alert("x")</script>'"); + expect(escaped).toContain("AA:BB:"); + + const fallback = renderControlUi(null); + expect(fallback).toContain("not set"); + }); +}); + diff --git a/tests/NodeBleControllerClient.test.ts b/tests/NodeBleControllerClient.test.ts new file mode 100644 index 0000000..c7518dc --- /dev/null +++ b/tests/NodeBleControllerClient.test.ts @@ -0,0 +1,215 @@ +import { EventEmitter } from "node:events"; + +import NodeBle = require("node-ble"); + +import { BleFeatureNotSupportedError } from "../src/ble/BleControllerClient"; +import { NodeBleControllerClient } from "../src/ble/NodeBleControllerClient"; +import { Logger } from "../src/observability/Logger"; + +jest.mock("node-ble", () => ({ + createBluetooth: jest.fn() +})); + +interface BleFixture { + readCharacteristic: EventEmitter & { + startNotifications: jest.Mock, []>; + stopNotifications: jest.Mock, []>; + }; + writeCharacteristic: { + writeValue: jest.Mock, unknown[]>; + }; + adapter: { + isDiscovering: jest.Mock, []>; + startDiscovery: jest.Mock, []>; + stopDiscovery: jest.Mock, []>; + waitDevice: jest.Mock, unknown[]>; + }; + destroy: jest.Mock; +} + +function setupNodeBleFixture( + options: { + isDiscovering?: boolean; + emitOnWrite?: boolean; + missingCharacteristics?: boolean; + } = {} +): BleFixture { + const readCharacteristic = Object.assign(new EventEmitter(), { + startNotifications: jest.fn(async () => undefined), + stopNotifications: jest.fn(async () => undefined) + }); + const writeCharacteristic = { + writeValue: jest.fn(async () => { + if (options.emitOnWrite !== false) { + setImmediate(() => { + readCharacteristic.emit("valuechanged", Buffer.from([0x99])); + }); + } + }) + }; + const service = { + getCharacteristic: jest.fn(async (uuid: string) => { + if (options.missingCharacteristics) { + throw new Error("missing characteristic"); + } + if (uuid.includes("ff02")) { + return readCharacteristic; + } + if (uuid.includes("ff01")) { + return writeCharacteristic; + } + throw new Error("not found"); + }) + }; + const gatt = { + services: jest.fn(async () => ["primary-service"]), + getPrimaryService: jest.fn(async () => service) + }; + const device = { + connect: jest.fn(async () => undefined), + gatt: jest.fn(async () => gatt), + disconnect: jest.fn(async () => undefined) + }; + const adapter = { + isDiscovering: jest.fn(async () => options.isDiscovering === true), + startDiscovery: jest.fn(async () => undefined), + stopDiscovery: jest.fn(async () => undefined), + waitDevice: jest.fn(async () => device) + }; + const bluetooth = { + defaultAdapter: jest.fn(async () => adapter) + }; + const destroy = jest.fn(() => undefined); + + const mockedNodeBle = NodeBle as unknown as { + createBluetooth: jest.Mock; + }; + mockedNodeBle.createBluetooth.mockReturnValue({ bluetooth, destroy }); + + return { + readCharacteristic, + writeCharacteristic, + adapter, + destroy + }; +} + +function buildClient(logger = new Logger("error", "test")): NodeBleControllerClient { + return new NodeBleControllerClient({ + requestTimeoutMs: 25, + scanTimeoutMs: 100, + deviceType: 11, + logger + }); +} + +describe("NodeBleControllerClient", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("reads telemetry over BLE and adds received timestamp", async () => { + const fixture = setupNodeBleFixture(); + const client = buildClient(); + const protocol = { + buildGetModelData: jest.fn(() => Buffer.from([0x01])), + parseTelemetryNotification: jest.fn(() => ({ + temperatureCelsius: 24.5, + temperatureFahrenheit: 76.1, + humidityPercent: 51.2, + vpdKpa: 1.62, + choosePort: 3, + workType: 2, + fanSpeedGuess: 7 + })), + buildSetLevel: jest.fn(() => Buffer.from([0x02])) + }; + (client as unknown as { protocol: unknown }).protocol = protocol; + + const nowSpy = jest.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + const reading = await client.readTelemetry("58:8c:81:c6:fc:f6", 0); + nowSpy.mockRestore(); + + expect(protocol.buildGetModelData).toHaveBeenCalledWith(11, 0); + expect(protocol.parseTelemetryNotification).toHaveBeenCalled(); + expect(reading.fanSpeedGuess).toBe(7); + expect(reading.receivedAtEpochSeconds).toBe(1_700_000_000); + expect(fixture.readCharacteristic.startNotifications).toHaveBeenCalled(); + expect(fixture.readCharacteristic.stopNotifications).toHaveBeenCalled(); + expect(fixture.adapter.startDiscovery).toHaveBeenCalled(); + expect(fixture.adapter.stopDiscovery).toHaveBeenCalled(); + expect(fixture.destroy).toHaveBeenCalled(); + }); + + it("skips discovery start/stop when adapter is already discovering", async () => { + const fixture = setupNodeBleFixture({ isDiscovering: true }); + const client = buildClient(); + (client as unknown as { protocol: unknown }).protocol = { + buildGetModelData: jest.fn(() => Buffer.from([0x01])), + parseTelemetryNotification: jest.fn(() => ({ + temperatureCelsius: 20, + temperatureFahrenheit: 68, + humidityPercent: 40, + vpdKpa: 1.1, + choosePort: 1, + workType: 2, + fanSpeedGuess: 1 + })), + buildSetLevel: jest.fn(() => Buffer.from([0x02])) + }; + + await client.readTelemetry("58:8C:81:C6:FC:F6", 1); + + expect(fixture.adapter.startDiscovery).not.toHaveBeenCalled(); + expect(fixture.adapter.stopDiscovery).not.toHaveBeenCalled(); + }); + + it("warns when set speed completes without a BLE notification ack", async () => { + setupNodeBleFixture({ emitOnWrite: false }); + const logger = new Logger("debug", "test"); + const warnSpy = jest.spyOn(logger, "warn").mockImplementation(() => undefined); + const client = buildClient(logger); + (client as unknown as { protocol: unknown }).protocol = { + buildGetModelData: jest.fn(() => Buffer.from([0x01])), + parseTelemetryNotification: jest.fn(() => ({ + temperatureCelsius: 20, + temperatureFahrenheit: 68, + humidityPercent: 40, + vpdKpa: 1.1, + choosePort: 1, + workType: 2, + fanSpeedGuess: 1 + })), + buildSetLevel: jest.fn(() => Buffer.from([0x03])) + }; + + await expect(client.setPortSpeed("58:8C:81:C6:FC:F6", 4, 8)).resolves.toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + "ble set speed completed without notify ack", + expect.objectContaining({ + error_message: expect.stringContaining("timed out waiting for BLE notification") + }) + ); + }); + + it("throws when BLE characteristics cannot be resolved", async () => { + setupNodeBleFixture({ missingCharacteristics: true }); + const client = buildClient(); + + await expect(client.verifyConnection("58:8C:81:C6:FC:F6")).rejects.toThrow( + "failed to resolve AC Infinity BLE characteristics" + ); + }); + + it("reports wifi recovery as unsupported", async () => { + const client = buildClient(); + + await expect( + client.recoverWifi("58:8C:81:C6:FC:F6", { + ssid: "atlas-net", + password: "secret-password", + hidden: false + }) + ).rejects.toBeInstanceOf(BleFeatureNotSupportedError); + }); +});