typhon/tests/ControlApiServerEdge.test.ts

271 lines
8.4 KiB
TypeScript

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<ControllerControlStatus> {
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<ControllerControlStatus> {
return this.getStatus();
}
public async getPorts(): Promise<ControlPortState[]> {
return this.ports;
}
public async setPortSpeed(port: number, speedLevel: number): Promise<void> {
this.ports = this.ports.map((item) => (
item.port === port
? { ...item, currentSpeedLevel: speedLevel, powerState: speedLevel > 0 }
: item
));
}
public async recoverWifi(request: WifiRecoveryRequest): Promise<WifiRecoveryResult> {
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<string, string>
): 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();
}
});
});