fix(integrations): treat an unstated console update state as unknown
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ca3e798a30
commit
1c317987ff
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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user