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")); 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) => { mockFetch.mockImplementation(async (url) => {
const raw = String(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); return routeLocal(raw) ?? jsonResponse({}, 404);
}); });
const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync(); const summary = await createIntegration(localOnlySecrets).getUnifiOsSummaryAsync();
expect(summary.clients.total).toBe(2); expect(summary.clients).toBeNull();
expect(summary.devices.total).toBe(2); 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 () => { 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.gateway).toBeNull();
expect(summary.wan.throughput).toBeNull(); expect(summary.wan.throughput).toBeNull();
expect(summary.wan.state).toBe("unknown"); 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, unifiOsDeviceStatisticsSchema,
unifiOsInfoSchema, unifiOsInfoSchema,
unifiOsSiteSchema, unifiOsSiteSchema,
unifiOsVpnServerSchema,
} from "./unifi-os-types"; } from "./unifi-os-types";
export type UnifiOsFetch = (url: string, init?: RequestInit & { timeout?: number }) => Promise<Response>; 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 clientPageSchema = createUnifiOsPageSchema(unifiOsClientSchema);
const devicePageSchema = createUnifiOsPageSchema(unifiOsDeviceSchema); const devicePageSchema = createUnifiOsPageSchema(unifiOsDeviceSchema);
const sitePageSchema = createUnifiOsPageSchema(unifiOsSiteSchema); const sitePageSchema = createUnifiOsPageSchema(unifiOsSiteSchema);
const vpnServerPageSchema = createUnifiOsPageSchema(unifiOsVpnServerSchema);
/** /**
* Client de l'API d'intégration locale d'UniFi OS. * 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 * 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 * 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 type { UnifiOsFetch } from "./unifi-os-api";
import { UnifiOsApi } from "./unifi-os-api"; import { UnifiOsApi } from "./unifi-os-api";
import { UnifiSmApi } from "./unifi-sm-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"; 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 { export class UnifiOsIntegration extends Integration implements UnifiOsSummaryIntegration {
public async getUnifiOsSummaryAsync(): Promise<UnifiOsSummary> { public async getUnifiOsSummaryAsync(): Promise<UnifiOsSummary> {
return await this.buildSummaryAsync(fetchWithTrustedCertificatesAsync as unknown as UnifiOsFetch); return await this.buildSummaryAsync(fetchWithTrustedCertificatesAsync as unknown as UnifiOsFetch);
@@ -59,8 +52,10 @@ export class UnifiOsIntegration extends Integration implements UnifiOsSummaryInt
api.getDevicesAsync(site.id), api.getDevicesAsync(site.id),
]); ]);
const clients = settledValue(clientsResult, { ...EMPTY_CLIENT_COUNTS }); // `null` marks a failed call so the UI never mistakes it for a real zero
const devices = settledValue<UnifiOsDevice[]>(devicesResult, []); // 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); const gatewayDevice = findGateway(devices);
// Les statistiques dépendent du gateway : sans gateway identifié, il n'y a // 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, isp,
}, },
clients, clients,
devices: devicesResult.status === "fulfilled" ? summarizeDevices(devices) : { ...EMPTY_DEVICE_COUNTS }, devices: devicesResult.status === "fulfilled" ? summarizeDevices(devices) : null,
gateway: gateway:
gatewayDevice === null gatewayDevice === null
? null ? null
@@ -148,8 +148,11 @@ export interface UnifiOsSummary {
throughput: { txBps: number; rxBps: number } | null; throughput: { txBps: number; rxBps: number } | null;
isp: UnifiOsIspMetrics | null; isp: UnifiOsIspMetrics | null;
}; };
clients: UnifiOsClientCounts; /** `null` when the call failed — never a zero count, which the UI could not
devices: UnifiOsDeviceCounts; * 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; gateway: UnifiOsGateway | null;
} }