271 lines
8.4 KiB
TypeScript
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();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|