319 lines
9.3 KiB
TypeScript
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();
|
||
|
|
});
|
||
|
|
});
|