From d2912c5e31145ce54a61a1ef8a3870794ec489ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zachary=20Gu=C3=A9not?= Date: Sun, 13 Sep 2026 04:18:51 +0200 Subject: [PATCH] fix(integrations): treat an unstated console update state as unknown Co-Authored-By: Claude Opus 5 --- .../test/unifi-site-manager-api.spec.ts | 4 +- .../unifi-site-manager-integration.spec.ts | 1 + .../test/unifi-site-manager-types.spec.ts | 45 +++++++++++++++++- .../unifi-site-manager-types.ts | 47 +++++++++++++++++-- 4 files changed, 92 insertions(+), 5 deletions(-) diff --git a/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-api.spec.ts b/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-api.spec.ts index 3faa4e553..57b729fe5 100644 --- a/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-api.spec.ts +++ b/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-api.spec.ts @@ -1,5 +1,7 @@ // @vitest-environment node -import { Response, type RequestInit } from "undici"; +/* eslint-disable @typescript-eslint/no-non-null-assertion -- Style des specs du dépôt : voir packages/integrations/test/nextcloud.spec.ts. */ +import type { RequestInit } from "undici"; +import { Response } from "undici"; import { describe, expect, test, vi } from "vitest"; vi.hoisted(() => { diff --git a/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-integration.spec.ts b/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-integration.spec.ts index e65b02754..312a7fa25 100644 --- a/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-integration.spec.ts +++ b/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-integration.spec.ts @@ -1,4 +1,5 @@ // @vitest-environment node +/* eslint-disable @typescript-eslint/no-non-null-assertion -- Style des specs du dépôt : voir packages/integrations/test/nextcloud.spec.ts. */ import { Response } from "undici"; import { beforeEach, describe, expect, test, vi } from "vitest"; diff --git a/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-types.spec.ts b/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-types.spec.ts index 6d009a030..c213cc115 100644 --- a/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-types.spec.ts +++ b/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-types.spec.ts @@ -1,4 +1,5 @@ // @vitest-environment node +/* eslint-disable @typescript-eslint/no-non-null-assertion -- Style des specs du dépôt : voir packages/integrations/test/nextcloud.spec.ts. */ import { readFileSync } from "node:fs"; import { describe, expect, test } from "vitest"; @@ -195,7 +196,14 @@ describe("mapSites", () => { expect(mapped!.consoleUpdateAvailable).toBeNull(); }); - test("reports a console with no pending update as false, not null", () => { + /** + * Ce test affirmait `false`. `deviceState` n'est pas un booléen : le compte + * réel renvoie `"setup"`, `"updateAvailable"`, et Ubiquiti en ajoutera + * d'autres. Tout ce qui n'est pas la chaîne magique tombait donc sur `false`, + * que le widget affiche « à jour » — une affirmation sur le firmware tirée + * d'un état qui n'en parle pas. + */ + test("treats a device state that says nothing about firmware as unknown, not as up to date", () => { const [mapped] = mapSites( [site], [{ id: "host-1", reportedState: { deviceState: "connected" } }], @@ -204,9 +212,44 @@ describe("mapSites", () => { NOW, ); + expect(mapped!.consoleUpdateAvailable).toBeNull(); + }); + + /** Le cloud se prononce : aucune version disponible. C'est le seul « à jour ». */ + test("reports a console as up to date when the cloud states no available version", () => { + const [mapped] = mapSites( + [site], + [{ id: "host-1", reportedState: { deviceState: "setup", firmwareUpdate: { latestAvailableVersion: null } } }], + [], + new Map(), + NOW, + ); + expect(mapped!.consoleUpdateAvailable).toBe(false); }); + test("reports an available version as an update even without a device state", () => { + const [mapped] = mapSites( + [site], + [{ id: "host-1", reportedState: { firmwareUpdate: { latestAvailableVersion: "9.9.9" } } }], + [], + new Map(), + NOW, + ); + + expect(mapped!.consoleUpdateAvailable).toBe(true); + }); + + /** + * Le piège d'origine : `undefined === "updateAvailable"` vaut `false`, donc une + * absence totale d'information s'affichait comme une console à jour. + */ + test("leaves consoleUpdateAvailable null when the host reports neither field", () => { + const [mapped] = mapSites([site], [{ id: "host-1", reportedState: {} }], [], new Map(), NOW); + + expect(mapped!.consoleUpdateAvailable).toBeNull(); + }); + test("yields an empty list for an account with no sites", () => { expect(mapSites([], [], [], new Map(), NOW)).toEqual([]); }); diff --git a/packages/integrations/src/unifi-site-manager/unifi-site-manager-types.ts b/packages/integrations/src/unifi-site-manager/unifi-site-manager-types.ts index 9303af7d9..589e30877 100644 --- a/packages/integrations/src/unifi-site-manager/unifi-site-manager-types.ts +++ b/packages/integrations/src/unifi-site-manager/unifi-site-manager-types.ts @@ -50,7 +50,17 @@ export const siteManagerSiteListSchema = z.looseObject({ data: z.array(siteManag export const siteManagerHostSchema = z.looseObject({ id: z.string(), - reportedState: z.looseObject({ deviceState: z.string().optional() }).optional(), + reportedState: z + .looseObject({ + deviceState: z.string().optional(), + /** + * `.nullable().optional()` et non `.nullish()` : « présent et explicitement + * `null` » — le cloud affirme qu'aucune version n'est disponible — doit + * rester distinguable de « absent », où il ne dit rien. + */ + firmwareUpdate: z.looseObject({ latestAvailableVersion: z.string().nullable().optional() }).optional(), + }) + .optional(), }); export const siteManagerHostListSchema = z.looseObject({ data: z.array(siteManagerHostSchema).default([]) }); @@ -115,7 +125,10 @@ export interface UnifiSiteManagerSite { offlineDeviceCount: number; /** Firmwares d'ÉQUIPEMENTS en attente — distinct de la mise à jour de console. */ pendingUpdateCount: number; - /** Mise à jour de la CONSOLE. `null` si aucun host ne correspond. */ + /** + * Mise à jour de la CONSOLE. `null` quand aucun host ne correspond ou que le + * cloud ne se prononce pas — voir `resolveConsoleUpdate`. + */ consoleUpdateAvailable: boolean | null; /** Toujours HEALTH_WINDOW_HOURS entrées, la plus ancienne en premier. */ health: UnifiSiteHealthSegment[]; @@ -247,6 +260,34 @@ export const buildSdWanIndex = ( return index; }; +/** + * `false` exige un signal positif du cloud. + * + * `deviceState` n'est pas un booléen : le compte réel renvoie `"setup"` sur une + * console et `"updateAvailable"` sur l'autre, et Ubiquiti peut en ajouter + * d'autres. Comparer à la chaîne magique repliait donc tout le reste — `"setup"`, + * une console hors ligne, un champ absent — sur `false`, que le widget affiche + * « à jour » : dire d'un état qui ne parle pas du firmware qu'il n'y a pas de + * mise à jour affirme une mesure qui n'a jamais été faite. + * + * Seul `firmwareUpdate.latestAvailableVersion` autorise `false` : explicitement + * `null` sur la console à jour, porteur d'une version sur celle qui ne l'est pas. + * Faute de l'un ou de l'autre, l'état reste inconnu. + */ +const resolveConsoleUpdate = (host: SiteManagerHost | undefined): boolean | null => { + if (host === undefined) return null; + + const reported = host.reportedState; + if (reported?.deviceState === "updateAvailable") return true; + + // `undefined` (champ ou objet absent) ne vaut pas `null` : seul `null` est une réponse. + const latestAvailableVersion = reported?.firmwareUpdate?.latestAvailableVersion; + if (typeof latestAvailableVersion === "string") return true; + if (latestAvailableVersion === null) return false; + + return null; +}; + export const mapSites = ( sites: SiteManagerSite[], hosts: SiteManagerHost[], @@ -274,7 +315,7 @@ export const mapSites = ( deviceCount: counts?.totalDevice ?? 0, offlineDeviceCount: counts?.offlineDevice ?? 0, pendingUpdateCount: counts?.pendingUpdateDevice ?? 0, - consoleUpdateAvailable: host === undefined ? null : host.reportedState?.deviceState === "updateAvailable", + consoleUpdateAvailable: resolveConsoleUpdate(host), health, summary: summariseHealth(health), latencyMs: latest?.avgLatency ?? null,