import { AcInfinityApiClient, AcInfinityApiError, type FetchLike, type HttpResponseLike } from "../src/http/AcInfinityApiClient"; interface CapturedCall { input: string; init: { method: string; headers: Record; body: URLSearchParams; signal: AbortSignal; dispatcher?: unknown; }; } function makeResponse(body: unknown, status = 200): HttpResponseLike { return { status, async json(): Promise { return body; } }; } describe("AcInfinityApiClient", () => { it("logs in, fetches controllers and mode settings, and maps values", async () => { const calls: CapturedCall[] = []; const responses: HttpResponseLike[] = [ makeResponse({ code: 200, msg: "success", data: { appId: "token-1" } }), makeResponse({ code: 200, msg: "success", data: [ { devId: "controller-a", devName: "Main Tent", online: 1, devType: 20, newFrameworkDevice: true, temperature: 2434, humidity: 5510, vpdnums: 123, deviceInfo: { ports: [ { port: 1, portName: "Inline Fan", online: 1, loadState: 1, speak: 6, curMode: 2, portResistance: 1200 } ] } } ] }), makeResponse({ code: 200, msg: "success", data: { atType: 2, onSpead: 7, offSpead: 1 } }) ]; const fetchMock: FetchLike = async (input, init) => { calls.push({ input, init }); const response = responses.shift(); if (!response) { throw new Error("no response queued"); } return response; }; const client = new AcInfinityApiClient( "http://example.test", "grower@example.com", "abcdefghijklmnopqrstuvwxyz123", 1000, fetchMock ); const snapshot = await client.fetchSnapshot(); await client.close(); expect(calls).toHaveLength(3); expect(calls[0]?.input).toContain("/api/user/appUserLogin"); expect(calls[1]?.input).toContain("/api/user/devInfoListAll"); expect(calls[2]?.input).toContain("/api/dev/getdevModeSettingList"); const loginBody = calls[0]?.init.body.toString() ?? ""; expect(loginBody).toContain("appPasswordl=abcdefghijklmnopqrstuvwxy"); expect(snapshot.controllers).toHaveLength(1); const controller = snapshot.controllers[0]; expect(controller?.name).toBe("Main Tent"); expect(controller?.temperatureCelsius).toBe(24.34); expect(controller?.relativeHumidityRatio).toBe(0.551); expect(controller?.vpdKpa).toBe(1.23); expect(controller?.deviceType).toBe(20); expect(controller?.newFrameworkDevice).toBe(true); expect(controller?.ports).toHaveLength(1); const port = controller?.ports[0]; expect(port?.currentSpeedLevel).toBe(6); expect(port?.onSpeedLevel).toBe(7); expect(port?.offSpeedLevel).toBe(1); expect(port?.mode).toBe("on"); expect(port?.fanGroup).toBe("unknown"); expect(port?.connectedDevice).toBe(true); expect(port?.resistanceOhms).toBe(1200); }); it("uses deviceInfo climate values when top-level values are null", async () => { const responses: HttpResponseLike[] = [ makeResponse({ code: 200, msg: "success", data: { appId: "token-1" } }), makeResponse({ code: 200, msg: "success", data: [ { devId: "controller-a", devName: "Main Tent", online: 1, temperature: null, humidity: null, vpdnums: null, deviceInfo: { temperature: 2406, humidity: 4106, vpdnums: 176, ports: [] } } ] }) ]; const fetchMock: FetchLike = async () => { const response = responses.shift(); if (!response) { throw new Error("no response queued"); } return response; }; const client = new AcInfinityApiClient( "http://example.test", "grower@example.com", "secret", 1000, fetchMock ); const snapshot = await client.fetchSnapshot(); await client.close(); const controller = snapshot.controllers[0]; expect(controller?.temperatureCelsius).toBe(24.06); expect(controller?.relativeHumidityRatio).toBe(0.4106); expect(controller?.vpdKpa).toBe(1.76); }); it("classifies fan groups and skips empty/disconnected ports", async () => { const responses: HttpResponseLike[] = [ makeResponse({ code: 200, msg: "success", data: { appId: "token-1" } }), makeResponse({ code: 200, msg: "success", data: [ { devId: "controller-b", devName: "Tent B", online: 1, temperature: 2300, humidity: 5000, vpdnums: 140, deviceInfo: { ports: [ { port: 0, portName: "ignored" }, { port: 1, portName: "Outlet - Exhaust", online: 1, loadState: 1, speak: 5, curMode: 1, portResistance: 1500 }, { port: 2, portName: "Inside Inlet", online: 1, loadState: 1, speak: 6, curMode: 0, portResistance: 1600 }, { port: 3, portName: "Outside Inlet", online: 1, loadState: 1, speak: 4, curMode: 0, portResistance: 1700 }, { port: 4, portName: "Fans", online: 1, loadState: 1, speak: 3, curMode: 0 }, { port: 5, portName: "Disconnected", online: 0, loadState: 0, speak: 0, curMode: 0, portResistance: 70000 } ] } } ] }), makeResponse({ code: 200, msg: "success", data: { atType: 2, onSpead: 5, offSpead: 1 } }), makeResponse({ code: 200, msg: "success", data: { atType: 7, onSpead: 6, offSpead: 2 } }), makeResponse({ code: 200, msg: "success", data: { atType: 8, onSpead: 4, offSpead: 2 } }), makeResponse({ code: 200, msg: "success", data: { atType: 3, onSpead: 3, offSpead: 1 } }) ]; const fetchMock: FetchLike = async () => { const response = responses.shift(); if (!response) { throw new Error("no response queued"); } return response; }; const client = new AcInfinityApiClient( "http://example.test", "grower@example.com", "secret", 1000, fetchMock ); const snapshot = await client.fetchSnapshot(); await client.close(); const controller = snapshot.controllers[0]; expect(controller?.ports).toHaveLength(4); const fanGroups = controller?.ports.map((port) => port.fanGroup); expect(fanGroups).toEqual(["outlet", "inside_inlet", "outside_inlet", "interior"]); expect(controller?.ports[0]?.mode).toBe("off"); expect(controller?.ports[1]?.mode).toBe("schedule"); expect(controller?.ports[2]?.mode).toBe("vpd"); expect(controller?.ports[3]?.mode).toBe("auto"); }); it("refreshes token and retries once on auth error", async () => { const calls: CapturedCall[] = []; const responses: HttpResponseLike[] = [ makeResponse({ code: 200, msg: "success", data: { appId: "token-1" } }), makeResponse({ code: 10001, msg: "invalid auth", data: null }), makeResponse({ code: 200, msg: "success", data: { appId: "token-2" } }), makeResponse({ code: 200, msg: "success", data: [] }) ]; const fetchMock: FetchLike = async (input, init) => { calls.push({ input, init }); const response = responses.shift(); if (!response) { throw new Error("no response queued"); } return response; }; const client = new AcInfinityApiClient( "http://example.test", "grower@example.com", "secret", 1000, fetchMock ); const snapshot = await client.fetchSnapshot(); await client.close(); expect(snapshot.controllers).toHaveLength(0); expect(calls).toHaveLength(4); expect(calls[0]?.input).toContain("/api/user/appUserLogin"); expect(calls[1]?.input).toContain("/api/user/devInfoListAll"); expect(calls[2]?.input).toContain("/api/user/appUserLogin"); expect(calls[3]?.input).toContain("/api/user/devInfoListAll"); }); it("throws typed API error on non-200 HTTP response", async () => { const fetchMock: FetchLike = async () => { return makeResponse({ code: 500, msg: "error", data: null }, 500); }; const client = new AcInfinityApiClient( "http://example.test", "grower@example.com", "secret", 1000, fetchMock ); await expect(client.fetchSnapshot()).rejects.toBeInstanceOf(AcInfinityApiError); await client.close(); }); });