fix(integrations): make client/device counts nullable and test real degradation

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zacharyandClaude Opus 5 committed 2026-09-08 15:21:32 +02:00
1 parent 1026890e33
commit 57286d83d2
4 files changed
+30 -25

No files matched your search

@@ -153,17 +153,31 @@ describe("UnifiOsIntegration getUnifiOsSummaryAsync", () => {
expect(summary.wan.isp?.measuredAt).toEqual(new Date("2026-09-08T09:10:00Z"));
});
test("degrades only the affected block when one local endpoint fails", async () => {
test("degrades only the clients block when the clients endpoint fails", async () => {
mockFetch.mockImplementation(async (url) => {
const raw = String(url);
if (raw.includes("/vpn/servers")) return jsonResponse({ error: "boom" }, 500);
if (raw.includes("/clients")) return jsonResponse({ error: "boom" }, 500);
return routeLocal(raw) ?? jsonResponse({}, 404);
});
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
expect(summary.clients.total).toBe(2);
expect(summary.devices.total).toBe(2);
expect(summary.clients).toBeNull();
expect(summary.devices).toEqual({ total: 2, online: 1, offline: 1, firmwareUpdatable: 1 });
expect(summary.gateway).not.toBeNull();
});
test("degrades only the devices block when the devices endpoint fails", async () => {
mockFetch.mockImplementation(async (url) => {
const raw = String(url);
if (raw.includes("/devices") && !raw.includes("/statistics")) return jsonResponse({ error: "boom" }, 500);
return routeLocal(raw) ?? jsonResponse({}, 404);
});
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
expect(summary.devices).toBeNull();
expect(summary.clients).toEqual({ total: 2, wired: 1, wireless: 1, vpn: 0, guests: 0 });
});
test("keeps clients and devices when the gateway cannot be identified", async () => {
@@ -192,6 +206,6 @@ describe("UnifiOsIntegration getUnifiOsSummaryAsync", () => {
expect(summary.gateway).toBeNull();
expect(summary.wan.throughput).toBeNull();
expect(summary.wan.state).toBe("unknown");
expect(summary.clients.total).toBe(2);
expect(summary.clients).toEqual({ total: 2, wired: 1, wireless: 1, vpn: 0, guests: 0 });
});
});
@@ -11,7 +11,6 @@ import {
unifiOsDeviceStatisticsSchema,
unifiOsInfoSchema,
unifiOsSiteSchema,
unifiOsVpnServerSchema,
} from "./unifi-os-types";
export type UnifiOsFetch = (url: string, init?: RequestInit & { timeout?: number }) => Promise<Response>;
@@ -27,7 +26,6 @@ const PARSE_ERROR_MESSAGE = "Invalid UniFi OS response";
const clientPageSchema = createUnifiOsPageSchema(unifiOsClientSchema);
const devicePageSchema = createUnifiOsPageSchema(unifiOsDeviceSchema);
const sitePageSchema = createUnifiOsPageSchema(unifiOsSiteSchema);
const vpnServerPageSchema = createUnifiOsPageSchema(unifiOsVpnServerSchema);
/**
* Client de l'API d'intégration locale d'UniFi OS.
@@ -70,11 +68,6 @@ export class UnifiOsApi {
);
}
public async getVpnServersAsync(siteId: string): Promise<{ enabled: boolean }[]> {
const page = await this.requestAsync(`/sites/${encodeURIComponent(siteId)}/vpn/servers`, vpnServerPageSchema);
return page.data;
}
/**
* Sous le plafond de 200 clients, une seule page suffit et le comptage se fait
* en mémoire. Au-delà, rapatrier tous les enregistrements pour n'en tirer que
@@ -8,16 +8,9 @@ import type { UnifiOsSummaryIntegration } from "../interfaces/unifi-os-summary/u
import type { UnifiOsFetch } from "./unifi-os-api";
import { UnifiOsApi } from "./unifi-os-api";
import { UnifiSmApi } from "./unifi-sm-api";
import type { UnifiOsDevice, UnifiOsDeviceStatistics, UnifiOsSummary } from "./unifi-os-types";
import type { UnifiOsDeviceStatistics, UnifiOsSummary } from "./unifi-os-types";
import { findGateway, summarizeDevices } from "./unifi-os-types";
const EMPTY_CLIENT_COUNTS = { total: 0, wired: 0, wireless: 0, vpn: 0, guests: 0 } as const;
const EMPTY_DEVICE_COUNTS = { total: 0, online: 0, offline: 0, firmwareUpdatable: 0 } as const;
/** `Promise.allSettled` renvoie `rejected` sans valeur ; on retombe sur un défaut. */
const settledValue = <T>(result: PromiseSettledResult<T>, fallback: T): T =>
result.status === "fulfilled" ? result.value : fallback;
export class UnifiOsIntegration extends Integration implements UnifiOsSummaryIntegration {
public async getUnifiOsSummaryAsync(): Promise<UnifiOsSummary> {
return await this.buildSummaryAsync(fetchWithTrustedCertificatesAsync as unknown as UnifiOsFetch);
@@ -59,8 +52,10 @@ export class UnifiOsIntegration extends Integration implements UnifiOsSummaryInt
api.getDevicesAsync(site.id),
]);
const clients = settledValue(clientsResult, { ...EMPTY_CLIENT_COUNTS });
const devices = settledValue<UnifiOsDevice[]>(devicesResult, []);
// `null` marks a failed call so the UI never mistakes it for a real zero
// count (a site with genuinely no clients or devices).
const clients = clientsResult.status === "fulfilled" ? clientsResult.value : null;
const devices = devicesResult.status === "fulfilled" ? devicesResult.value : [];
const gatewayDevice = findGateway(devices);
// Les statistiques dépendent du gateway : sans gateway identifié, il n'y a
@@ -87,7 +82,7 @@ export class UnifiOsIntegration extends Integration implements UnifiOsSummaryInt
isp,
},
clients,
devices: devicesResult.status === "fulfilled" ? summarizeDevices(devices) : { ...EMPTY_DEVICE_COUNTS },
devices: devicesResult.status === "fulfilled" ? summarizeDevices(devices) : null,
gateway:
gatewayDevice === null
? null
@@ -148,8 +148,11 @@ export interface UnifiOsSummary {
throughput: { txBps: number; rxBps: number } | null;
isp: UnifiOsIspMetrics | null;
};
clients: UnifiOsClientCounts;
devices: UnifiOsDeviceCounts;
/** `null` when the call failed — never a zero count, which the UI could not
* tell apart from a site with genuinely no clients. */
clients: UnifiOsClientCounts | null;
/** `null` when the call failed, for the same reason. */
devices: UnifiOsDeviceCounts | null;
gateway: UnifiOsGateway | null;
}