typhon/tests/AcInfinityApiClient.test.ts

319 lines
9.3 KiB
TypeScript

import {
AcInfinityApiClient,
AcInfinityApiError,
type FetchLike,
type HttpResponseLike
} from "../src/http/AcInfinityApiClient";
interface CapturedCall {
input: string;
init: {
method: string;
headers: Record<string, string>;
body: URLSearchParams;
signal: AbortSignal;
dispatcher?: unknown;
};
}
function makeResponse(body: unknown, status = 200): HttpResponseLike {
return {
status,
async json(): Promise<unknown> {
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();
});
});