fix(integrations): treat an unstated console update state as unknown

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zacharyandClaude Opus 5 committed 2026-09-13 22:21:43 +02:00
1 parent e3e077fcb8
commit d2912c5e31
4 files changed
+92 -5

No files matched your search

@@ -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(() => {
@@ -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";
@@ -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([]);
});
@@ -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,