From 33b287f515e0d582121f79001898ceb57846cca1 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 9 Jul 2026 20:42:58 +0100 Subject: [PATCH] Add unit tests for alertThresholdDefaults, storageSources, storageSummaryCache utils --- .../__tests__/alertThresholdDefaults.test.ts | 244 +++++++++++++ .../utils/__tests__/storageSources.test.ts | 331 ++++++++++++++++++ .../__tests__/storageSummaryCache.test.ts | 209 +++++++++++ 3 files changed, 784 insertions(+) create mode 100644 frontend-modern/src/utils/__tests__/alertThresholdDefaults.test.ts create mode 100644 frontend-modern/src/utils/__tests__/storageSources.test.ts create mode 100644 frontend-modern/src/utils/__tests__/storageSummaryCache.test.ts diff --git a/frontend-modern/src/utils/__tests__/alertThresholdDefaults.test.ts b/frontend-modern/src/utils/__tests__/alertThresholdDefaults.test.ts new file mode 100644 index 0000000000..1aca31000b --- /dev/null +++ b/frontend-modern/src/utils/__tests__/alertThresholdDefaults.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from 'vitest'; +import { + FACTORY_AGENT_DEFAULTS, + FACTORY_BACKUP_DEFAULTS, + FACTORY_DISK_TEMP_BY_TYPE, + FACTORY_DOCKER_DEFAULTS, + FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY, + FACTORY_DOCKER_STATE_SEVERITY, + FACTORY_DOCKER_STATE_SEVERITY as DOCKER_STATE_SEVERITY, + FACTORY_GUEST_DEFAULTS, + FACTORY_KUBERNETES_DEFAULTS, + FACTORY_NODE_DEFAULTS, + FACTORY_PBS_DEFAULTS, + FACTORY_SNAPSHOT_DEFAULTS, + FACTORY_STORAGE_DEFAULT, + FACTORY_TRUENAS_DEFAULTS, + FACTORY_TRUENAS_DISK_DEFAULTS, + FACTORY_VMWARE_DEFAULTS, +} from '@/utils/alertThresholdDefaults'; +import type { BackupAlertConfig, SnapshotAlertConfig } from '@/types/alerts'; + +describe('alertThresholdDefaults', () => { + describe('FACTORY_GUEST_DEFAULTS', () => { + it('exposes the canonical guest metric thresholds', () => { + expect(FACTORY_GUEST_DEFAULTS).toEqual({ + cpu: 80, + memory: 85, + disk: 90, + diskRead: -1, + diskWrite: -1, + networkIn: -1, + networkOut: -1, + }); + }); + + it('disables throughput metrics with the -1 sentinel', () => { + for (const key of ['diskRead', 'diskWrite', 'networkIn', 'networkOut'] as const) { + expect(FACTORY_GUEST_DEFAULTS[key]).toBe(-1); + } + }); + }); + + describe('FACTORY_NODE_DEFAULTS', () => { + it('exposes cpu/memory/disk thresholds plus temperature', () => { + expect(FACTORY_NODE_DEFAULTS).toEqual({ + cpu: 80, + memory: 85, + disk: 90, + temperature: 80, + }); + }); + }); + + describe('FACTORY_PBS_DEFAULTS', () => { + it('limits PBS defaults to cpu and memory only', () => { + expect(FACTORY_PBS_DEFAULTS).toEqual({ cpu: 80, memory: 85 }); + expect(Object.keys(FACTORY_PBS_DEFAULTS).sort()).toEqual(['cpu', 'memory']); + }); + }); + + describe('FACTORY_KUBERNETES_DEFAULTS', () => { + it('mirrors the guest default shape for kubernetes workloads', () => { + expect(FACTORY_KUBERNETES_DEFAULTS).toEqual(FACTORY_GUEST_DEFAULTS); + expect(FACTORY_KUBERNETES_DEFAULTS).toEqual({ + cpu: 80, + memory: 85, + disk: 90, + diskRead: -1, + diskWrite: -1, + networkIn: -1, + networkOut: -1, + }); + }); + }); + + describe('FACTORY_TRUENAS_DEFAULTS', () => { + it('exposes truenas thresholds including usage and temperature', () => { + expect(FACTORY_TRUENAS_DEFAULTS).toEqual({ + cpu: 80, + memory: 85, + disk: 85, + usage: 85, + temperature: 80, + diskRead: -1, + diskWrite: -1, + networkIn: -1, + networkOut: -1, + }); + }); + + it('uses a tighter disk threshold than the guest default', () => { + expect(FACTORY_TRUENAS_DEFAULTS.disk).toBe(85); + expect(FACTORY_TRUENAS_DEFAULTS.disk).toBeLessThan(FACTORY_GUEST_DEFAULTS.disk); + }); + }); + + describe('FACTORY_TRUENAS_DISK_DEFAULTS', () => { + it('only carries a disk temperature trigger', () => { + expect(FACTORY_TRUENAS_DISK_DEFAULTS).toEqual({ temperature: 55 }); + expect(Object.keys(FACTORY_TRUENAS_DISK_DEFAULTS)).toEqual(['temperature']); + }); + }); + + describe('FACTORY_VMWARE_DEFAULTS', () => { + it('exposes vmware thresholds including usage and disabled throughput', () => { + expect(FACTORY_VMWARE_DEFAULTS).toEqual({ + cpu: 80, + memory: 85, + disk: 90, + usage: 85, + diskRead: -1, + diskWrite: -1, + networkIn: -1, + networkOut: -1, + }); + }); + }); + + describe('FACTORY_AGENT_DEFAULTS', () => { + it('exposes agent host thresholds including disk temperature', () => { + expect(FACTORY_AGENT_DEFAULTS).toEqual({ + cpu: 80, + memory: 85, + disk: 90, + diskTemperature: 55, + }); + }); + }); + + describe('FACTORY_DISK_TEMP_BY_TYPE', () => { + it.each([ + ['nvme', 70], + ['sas', 65], + ['sata', 55], + ])('maps %s disk type to its seeded trigger temperature', (type, expected) => { + expect(FACTORY_DISK_TEMP_BY_TYPE[type]).toBe(expected); + }); + + it('contains exactly the nvme/sas/sata entries', () => { + expect(FACTORY_DISK_TEMP_BY_TYPE).toEqual({ + nvme: 70, + sas: 65, + sata: 55, + }); + }); + + it('orders temperatures nvme > sas > sata', () => { + expect(FACTORY_DISK_TEMP_BY_TYPE.nvme).toBeGreaterThan(FACTORY_DISK_TEMP_BY_TYPE.sas); + expect(FACTORY_DISK_TEMP_BY_TYPE.sas).toBeGreaterThan(FACTORY_DISK_TEMP_BY_TYPE.sata); + }); + }); + + describe('FACTORY_DOCKER_DEFAULTS', () => { + it('exposes the docker container and service gap thresholds', () => { + expect(FACTORY_DOCKER_DEFAULTS).toEqual({ + cpu: 80, + memory: 85, + disk: 85, + restartCount: 3, + restartWindow: 300, + memoryWarnPct: 90, + memoryCriticalPct: 95, + serviceWarnGapPercent: 10, + serviceCriticalGapPercent: 50, + }); + }); + + it('keeps memory critical above memory warn', () => { + expect(FACTORY_DOCKER_DEFAULTS.memoryCriticalPct).toBeGreaterThan( + FACTORY_DOCKER_DEFAULTS.memoryWarnPct, + ); + }); + + it('keeps service critical gap above service warn gap', () => { + expect(FACTORY_DOCKER_DEFAULTS.serviceCriticalGapPercent).toBeGreaterThan( + FACTORY_DOCKER_DEFAULTS.serviceWarnGapPercent, + ); + }); + }); + + describe('docker state constants', () => { + it('leaves connectivity disabled by default', () => { + expect(FACTORY_DOCKER_STATE_DISABLE_CONNECTIVITY).toBe(false); + }); + + it('defaults the docker state severity to warning', () => { + expect(FACTORY_DOCKER_STATE_SEVERITY).toBe('warning'); + // Type-level guard: only 'warning' | 'critical' is allowed. + const _severity: 'warning' | 'critical' = DOCKER_STATE_SEVERITY; + expect(_severity).toBe('warning'); + }); + }); + + describe('FACTORY_STORAGE_DEFAULT', () => { + it('exposes the shared storage usage default', () => { + expect(FACTORY_STORAGE_DEFAULT).toBe(85); + }); + }); + + describe('FACTORY_SNAPSHOT_DEFAULTS', () => { + it('starts disabled with warning/critical day thresholds', () => { + expect(FACTORY_SNAPSHOT_DEFAULTS).toEqual({ + enabled: false, + warningDays: 30, + criticalDays: 45, + }); + }); + + it('conforms to the SnapshotAlertConfig contract', () => { + const config: SnapshotAlertConfig = FACTORY_SNAPSHOT_DEFAULTS; + expect(config.criticalDays).toBeGreaterThan(config.warningDays); + }); + }); + + describe('FACTORY_BACKUP_DEFAULTS', () => { + it('starts disabled with day/freshness/orphan defaults and an empty ignore list', () => { + expect(FACTORY_BACKUP_DEFAULTS).toEqual({ + enabled: false, + warningDays: 7, + criticalDays: 14, + freshHours: 24, + staleHours: 72, + alertOrphaned: true, + ignoreVMIDs: [], + }); + }); + + it('conforms to the BackupAlertConfig contract', () => { + const config: BackupAlertConfig = FACTORY_BACKUP_DEFAULTS; + expect(config.criticalDays).toBeGreaterThan(config.warningDays); + }); + + it('uses staleHours greater than freshHours', () => { + expect(FACTORY_BACKUP_DEFAULTS.staleHours).toBeGreaterThan( + FACTORY_BACKUP_DEFAULTS.freshHours ?? 0, + ); + }); + + it('starts with an empty (mutable) ignore list', () => { + expect(Array.isArray(FACTORY_BACKUP_DEFAULTS.ignoreVMIDs)).toBe(true); + expect(FACTORY_BACKUP_DEFAULTS.ignoreVMIDs).toHaveLength(0); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/storageSources.test.ts b/frontend-modern/src/utils/__tests__/storageSources.test.ts new file mode 100644 index 0000000000..c29517a0a0 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/storageSources.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it } from 'vitest'; +import type { Storage } from '@/types/api'; +import { + buildStorageSourceOptionsFromKeys, + getStorageSourceOption, + normalizeStorageSourceKey, + orderStorageSourceKeys, + resolveStorageSourceKey, + type StorageSourceOption, +} from '@/utils/storageSources'; + +const makeStorage = (overrides: Partial>): Storage => + ({ + id: 'storage-1', + name: 'storage-1', + node: 'node-1', + instance: 'instance-1', + type: 'dir', + status: 'active', + total: 100, + used: 10, + free: 90, + usage: 10, + content: 'images', + shared: false, + enabled: true, + active: true, + ...overrides, + }) as Storage; + +describe('storageSources', () => { + describe('normalizeStorageSourceKey', () => { + it.each([ + ['k8s', 'kubernetes'], + ['pve', 'proxmox-pve'], + ['pbs', 'proxmox-pbs'], + ['pmg', 'proxmox-pmg'], + ['vmware', 'vmware-vsphere'], + ['hyper-v', 'microsoft-hyperv'], + ['truenas', 'truenas'], + ['agent', 'agent'], + ['docker', 'docker'], + ['kubernetes', 'kubernetes'], + ])('canonicalizes known platform token %s to %s', (input, expected) => { + expect(normalizeStorageSourceKey(input)).toBe(expected); + }); + + it.each([ + ['ceph', 'ceph'], + ['cephfs', 'ceph'], + ['rbd', 'ceph'], + ['CephFS', 'ceph'], + ['RBD', 'ceph'], + ])('collapses the ceph family %s to ceph', (input, expected) => { + expect(normalizeStorageSourceKey(input)).toBe(expected); + }); + + it('preserves the all token', () => { + expect(normalizeStorageSourceKey('all')).toBe('all'); + expect(normalizeStorageSourceKey(' ALL ')).toBe('all'); + expect(normalizeStorageSourceKey('All')).toBe('all'); + }); + + it.each([ + [''], + [' '], + [undefined], + [null], + ['!!!'], + ['@@@@'], + ])('returns an empty string for empty or punctuation-only input %s', (input) => { + expect(normalizeStorageSourceKey(input as string | null | undefined)).toBe(''); + }); + + it('slugifies mixed-case and whitespace into a canonical slug', () => { + expect(normalizeStorageSourceKey('TrueNAS')).toBe('truenas'); + expect(normalizeStorageSourceKey('Proxmox PBS')).toBe('proxmox-pbs'); + expect(normalizeStorageSourceKey(' CEPH ')).toBe('ceph'); + }); + + it('passes unknown slugs through slugified', () => { + expect(normalizeStorageSourceKey('Ceph FS')).toBe('ceph-fs'); + expect(normalizeStorageSourceKey('foo bar')).toBe('foo-bar'); + expect(normalizeStorageSourceKey('weird/thing!!')).toBe('weird-thing'); + }); + }); + + describe('resolveStorageSourceKey', () => { + it.each([ + ['truenas', 'zfs', 'truenas'], + ['vmware-vsphere', 'vsan', 'vmware-vsphere'], + ['proxmox-pbs', 'lvm', 'proxmox-pbs'], + ['proxmox-pmg', '', 'proxmox-pmg'], + ['kubernetes', 'csi', 'kubernetes'], + ['microsoft-hyperv', '', 'microsoft-hyperv'], + ])( + 'prefers the canonical platform tag %s over on-disk type %s -> %s', + (platform, type, expected) => { + expect(resolveStorageSourceKey(makeStorage({ platform, type }))).toBe(expected); + }, + ); + + it('canonicalizes a raw vmware platform alias before resolving', () => { + expect(resolveStorageSourceKey(makeStorage({ platform: 'vmware', type: 'vmfs' }))).toBe( + 'vmware-vsphere', + ); + }); + + it.each([ + ['dir'], + ['nfs'], + ['cifs'], + ['lvm'], + ['lvmthin'], + ['zfspool'], + ['zfs'], + ['iscsi'], + ['glusterfs'], + ['btrfs'], + ])('resolves proxmox storage type %s to proxmox-pve', (type) => { + expect(resolveStorageSourceKey(makeStorage({ platform: '', type }))).toBe('proxmox-pve'); + }); + + it.each([[''], ['storage']])( + 'falls back to proxmox-pve for empty/generic type %s', + (type) => { + expect(resolveStorageSourceKey(makeStorage({ platform: '', type }))).toBe('proxmox-pve'); + }, + ); + + it.each([['ceph'], ['cephfs'], ['rbd']])('resolves ceph type %s to ceph', (type) => { + expect(resolveStorageSourceKey(makeStorage({ platform: '', type }))).toBe('ceph'); + }); + + it.each([['proxmox-pbs'], ['ceph'], ['kubernetes'], ['proxmox-pmg']])( + 'returns recognized canonical type %s directly', + (type) => { + expect(resolveStorageSourceKey(makeStorage({ platform: '', type }))).toBe(type); + }, + ); + + it.each([['csi-driver'], ['my-k8s-pv'], ['kubernetes-csi'], ['foo-csi-bar']])( + 'classifies kubernetes-flavored type %s as kubernetes', + (type) => { + expect(resolveStorageSourceKey(makeStorage({ platform: '', type }))).toBe('kubernetes'); + }, + ); + + it('returns an unknown on-disk type slugified as-is', () => { + expect(resolveStorageSourceKey(makeStorage({ platform: '', type: 'random-thing' }))).toBe( + 'random-thing', + ); + }); + + it('returns proxmox-pve when neither platform nor type resolve to anything', () => { + expect(resolveStorageSourceKey(makeStorage({ platform: '', type: '' }))).toBe('proxmox-pve'); + }); + + it('ignores a proxmox-pve platform tag and resolves by type instead (current behavior)', () => { + // proxmox-pve is intentionally NOT in the platform-wins list, so the + // on-disk type still drives the result. + expect(resolveStorageSourceKey(makeStorage({ platform: 'proxmox-pve', type: 'ceph' }))).toBe( + 'ceph', + ); + expect(resolveStorageSourceKey(makeStorage({ platform: 'proxmox-pve', type: 'dir' }))).toBe( + 'proxmox-pve', + ); + }); + + it('ignores an agent platform tag and resolves by type instead (current behavior)', () => { + expect(resolveStorageSourceKey(makeStorage({ platform: 'agent', type: 'dir' }))).toBe( + 'proxmox-pve', + ); + }); + + it('keeps a truenas platform tag even when the on-disk type is empty', () => { + expect(resolveStorageSourceKey(makeStorage({ platform: 'truenas', type: '' }))).toBe( + 'truenas', + ); + }); + }); + + describe('getStorageSourceOption', () => { + it('returns the canonical all option for the all token', () => { + expect(getStorageSourceOption('all')).toEqual({ + key: 'all', + label: 'All sources', + tone: 'slate', + }); + expect(getStorageSourceOption('ALL')).toEqual({ + key: 'all', + label: 'All sources', + tone: 'slate', + }); + }); + + it('does not treat undefined as the all token (current behavior)', () => { + // Only the literal 'all' slug maps to the all option; undefined + // normalizes to an empty key and falls through to the generic branch. + expect(getStorageSourceOption(undefined)).toEqual({ + key: '', + label: 'Unknown', + tone: 'slate', + }); + expect(getStorageSourceOption(null)).toEqual({ + key: '', + label: 'Unknown', + tone: 'slate', + }); + }); + + it.each([ + ['ceph', 'Ceph', 'teal'], + ['proxmox-pve', 'PVE', 'orange'], + ['proxmox-pbs', 'PBS', 'indigo'], + ['proxmox-pmg', 'PMG', 'rose'], + ['truenas', 'TrueNAS', 'blue'], + ['kubernetes', 'K8s', 'cyan'], + ])('builds a labeled, toned option for canonical key %s', (key, label, tone) => { + expect(getStorageSourceOption(key)).toEqual({ key, label, tone }); + }); + + it('uses the vSphere label and falls back to the slate tone for vmware-vsphere', () => { + // vmware-vsphere has no entry in the storage source tone map, so it + // falls back to slate while keeping its canonical platform label. + expect(getStorageSourceOption('vmware-vsphere')).toEqual({ + key: 'vmware-vsphere', + label: 'vSphere', + tone: 'slate', + }); + }); + + it('title-cases the label for an unknown key and uses the slate tone', () => { + expect(getStorageSourceOption('foo-bar')).toEqual({ + key: 'foo-bar', + label: 'Foo Bar', + tone: 'slate', + }); + }); + + it('returns the Unknown label for an empty key', () => { + const option = getStorageSourceOption(''); + expect(option.key).toBe(''); + expect(option.label).toBe('Unknown'); + expect(option.tone).toBe('slate'); + }); + + it('every option key/tone pair is a valid StorageSourceOption', () => { + const option = getStorageSourceOption('ceph') as StorageSourceOption; + expect(option.tone).toBe('teal'); + expect(typeof option.label).toBe('string'); + }); + }); + + describe('orderStorageSourceKeys', () => { + it('orders canonical source keys by the preferred source ordering', () => { + expect( + orderStorageSourceKeys(['truenas', 'proxmox-pve', 'ceph', 'kubernetes', 'proxmox-pbs']), + ).toEqual(['proxmox-pve', 'proxmox-pbs', 'ceph', 'truenas', 'kubernetes']); + }); + + it('returns an empty array for empty input', () => { + expect(orderStorageSourceKeys([])).toEqual([]); + expect(orderStorageSourceKeys(new Set())).toEqual([]); + }); + + it('dedupes and normalizes keys before ordering', () => { + expect(orderStorageSourceKeys(['pbs', 'proxmox-pbs', 'PBS'])).toEqual(['proxmox-pbs']); + }); + + it('places the all token after canonical keys (all is not in the source order)', () => { + expect(orderStorageSourceKeys(['all', 'ceph', 'proxmox-pve'])).toEqual([ + 'proxmox-pve', + 'ceph', + 'all', + ]); + }); + + it('sorts unknown keys after canonical keys using locale-aware ordering', () => { + expect(orderStorageSourceKeys(['zeta', 'proxmox-pve', 'alpha', 'ceph'])).toEqual([ + 'proxmox-pve', + 'ceph', + 'alpha', + 'zeta', + ]); + }); + + it('keeps a single canonical key as-is', () => { + expect(orderStorageSourceKeys(['truenas'])).toEqual(['truenas']); + }); + }); + + describe('buildStorageSourceOptionsFromKeys', () => { + const ALL_OPTION: StorageSourceOption = { + key: 'all', + label: 'All sources', + tone: 'slate', + }; + + it('returns only the all option for empty input', () => { + expect(buildStorageSourceOptionsFromKeys([])).toEqual([ALL_OPTION]); + }); + + it('prepends the all option and orders the remaining canonical keys', () => { + expect(buildStorageSourceOptionsFromKeys(['ceph', 'proxmox-pve', 'truenas'])).toEqual([ + ALL_OPTION, + { key: 'proxmox-pve', label: 'PVE', tone: 'orange' }, + { key: 'ceph', label: 'Ceph', tone: 'teal' }, + { key: 'truenas', label: 'TrueNAS', tone: 'blue' }, + ]); + }); + + it('strips an explicit all key from the input before building options', () => { + expect(buildStorageSourceOptionsFromKeys(['all', 'truenas'])).toEqual([ + ALL_OPTION, + { key: 'truenas', label: 'TrueNAS', tone: 'blue' }, + ]); + }); + + it('dedupes repeated keys', () => { + const options = buildStorageSourceOptionsFromKeys(['ceph', 'ceph', 'ceph']); + expect(options).toEqual([ALL_OPTION, { key: 'ceph', label: 'Ceph', tone: 'teal' }]); + }); + + it('first option is always the canonical all option', () => { + const options = buildStorageSourceOptionsFromKeys(['kubernetes', 'proxmox-pbs']); + expect(options[0]).toEqual(ALL_OPTION); + expect(options).toHaveLength(3); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/storageSummaryCache.test.ts b/frontend-modern/src/utils/__tests__/storageSummaryCache.test.ts new file mode 100644 index 0000000000..d2d5204e43 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/storageSummaryCache.test.ts @@ -0,0 +1,209 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { StorageSummaryChartsResponse, TimeRange } from '@/api/charts'; +import { eventBus } from '@/stores/events'; +import { getOrgID, setOrgID } from '@/utils/apiClient'; +import { + fetchStorageSummaryAndCache, + readStorageSummaryCache, +} from '@/utils/storageSummaryCache'; + +const mockGetStorageSummaryCharts = vi.fn(); + +vi.mock('@/api/charts', async () => { + const actual = await vi.importActual('@/api/charts'); + return { + ...actual, + ChartsAPI: { + ...actual.ChartsAPI, + getStorageSummaryCharts: (...args: unknown[]) => mockGetStorageSummaryCharts(...args), + }, + }; +}); + +const makeResponse = (seed = 1): StorageSummaryChartsResponse => ({ + pools: { + [`pool-${seed}`]: { + name: `pool-${seed}`, + usage: [{ timestamp: seed, value: 50 }], + used: [{ timestamp: seed, value: 5 }], + avail: [{ timestamp: seed, value: 5 }], + }, + }, + disks: {}, + stats: { oldestDataTimestamp: seed }, +}); + +describe('storageSummaryCache', () => { + beforeEach(() => { + mockGetStorageSummaryCharts.mockReset(); + localStorage.clear(); + setOrgID('default'); + // The module exposes no test-only reset helper, so rely on its own + // sanctioned clearing path (the org_switched subscriber) to give every + // test a clean in-memory cache and an empty in-flight table. + eventBus.emit('org_switched', 'default'); + }); + + describe('readStorageSummaryCache', () => { + it('returns null on a cache miss before any fetch', () => { + expect(readStorageSummaryCache('1h')).toBeNull(); + expect(readStorageSummaryCache('1h', 'node-1')).toBeNull(); + }); + + it('returns the cached response after a successful fetch', async () => { + const response = makeResponse(7); + mockGetStorageSummaryCharts.mockResolvedValueOnce(response); + + await fetchStorageSummaryAndCache('1h'); + + expect(readStorageSummaryCache('1h')).toBe(response); + }); + }); + + describe('fetchStorageSummaryAndCache', () => { + it('calls ChartsAPI with the range and nodeId option and caches the result', async () => { + const response = makeResponse(); + mockGetStorageSummaryCharts.mockResolvedValueOnce(response); + + const result = await fetchStorageSummaryAndCache('1h', { nodeId: 'node-1' }); + + expect(result).toBe(response); + expect(mockGetStorageSummaryCharts).toHaveBeenCalledTimes(1); + expect(mockGetStorageSummaryCharts).toHaveBeenCalledWith('1h', undefined, { + nodeId: 'node-1', + }); + expect(readStorageSummaryCache('1h', 'node-1')).toBe(response); + }); + + it('passes an undefined nodeId when no node scope is supplied', async () => { + mockGetStorageSummaryCharts.mockResolvedValueOnce(makeResponse()); + + await fetchStorageSummaryAndCache('24h'); + + expect(mockGetStorageSummaryCharts).toHaveBeenCalledWith('24h', undefined, { + nodeId: undefined, + }); + }); + + it('deduplicates concurrent requests for the same range and node', async () => { + let resolveFetch: ((value: StorageSummaryChartsResponse) => void) | undefined; + const response = makeResponse(42); + mockGetStorageSummaryCharts.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const first = fetchStorageSummaryAndCache('1h'); + const second = fetchStorageSummaryAndCache('1h'); + + // Two concurrent callers share a single in-flight request. + expect(mockGetStorageSummaryCharts).toHaveBeenCalledTimes(1); + + resolveFetch?.(response); + const [firstResult, secondResult] = await Promise.all([first, second]); + + expect(firstResult).toBe(response); + expect(secondResult).toBe(response); + expect(readStorageSummaryCache('1h')).toBe(response); + }); + + it('fetches separately for distinct ranges', async () => { + mockGetStorageSummaryCharts.mockImplementation((_range: TimeRange) => + Promise.resolve(makeResponse(_range === '1h' ? 1 : 24)), + ); + + await fetchStorageSummaryAndCache('1h'); + await fetchStorageSummaryAndCache('24h'); + + expect(mockGetStorageSummaryCharts).toHaveBeenCalledTimes(2); + expect(readStorageSummaryCache('1h')).not.toBeNull(); + expect(readStorageSummaryCache('24h')).not.toBeNull(); + expect(readStorageSummaryCache('1h')).not.toEqual(readStorageSummaryCache('24h')); + }); + + it('fetches separately per node scope', async () => { + mockGetStorageSummaryCharts.mockImplementation((_range: TimeRange, _signal: unknown, options?: { nodeId?: string }) => + Promise.resolve(makeResponse(options?.nodeId ? 10 : 20)), + ); + + await fetchStorageSummaryAndCache('1h', { nodeId: 'node-1' }); + await fetchStorageSummaryAndCache('1h'); + + expect(mockGetStorageSummaryCharts).toHaveBeenCalledTimes(2); + expect(readStorageSummaryCache('1h', 'node-1')).not.toBeNull(); + expect(readStorageSummaryCache('1h')).not.toBeNull(); + // node-scoped and global entries are distinct. + expect(readStorageSummaryCache('1h', 'node-1')).not.toBe(readStorageSummaryCache('1h')); + }); + + it('does not poison the cache when the request rejects', async () => { + mockGetStorageSummaryCharts.mockRejectedValueOnce(new Error('boom')); + + await expect(fetchStorageSummaryAndCache('1h')).rejects.toThrow('boom'); + + expect(readStorageSummaryCache('1h')).toBeNull(); + }); + + it('clears the in-flight entry on rejection so the next call refetches', async () => { + mockGetStorageSummaryCharts.mockRejectedValueOnce(new Error('boom')); + await expect(fetchStorageSummaryAndCache('1h')).rejects.toThrow('boom'); + + const response = makeResponse(99); + mockGetStorageSummaryCharts.mockResolvedValueOnce(response); + + const result = await fetchStorageSummaryAndCache('1h'); + + expect(mockGetStorageSummaryCharts).toHaveBeenCalledTimes(2); + expect(result).toBe(response); + expect(readStorageSummaryCache('1h')).toBe(response); + }); + + it('isolates cache entries per org scope', async () => { + mockGetStorageSummaryCharts.mockResolvedValue(makeResponse(1)); + + setOrgID('org-a'); + await fetchStorageSummaryAndCache('1h'); + expect(getOrgID()).toBe('org-a'); + expect(readStorageSummaryCache('1h')).not.toBeNull(); + + // Switching orgs (without emitting org_switched) must not surface the + // other org's cached entry. + setOrgID('org-b'); + expect(readStorageSummaryCache('1h')).toBeNull(); + + // Switching back to the original org still finds its entry. + setOrgID('org-a'); + expect(readStorageSummaryCache('1h')).not.toBeNull(); + + expect(mockGetStorageSummaryCharts).toHaveBeenCalledTimes(1); + }); + + it('clears the in-memory cache when the org_switched event fires', async () => { + mockGetStorageSummaryCharts.mockResolvedValueOnce(makeResponse(5)); + + await fetchStorageSummaryAndCache('1h'); + expect(readStorageSummaryCache('1h')).not.toBeNull(); + + eventBus.emit('org_switched', 'org-b'); + + expect(readStorageSummaryCache('1h')).toBeNull(); + }); + + it('clears in-flight dedupe state on org switch so a fresh fetch is issued', async () => { + const first = makeResponse(1); + const second = makeResponse(2); + mockGetStorageSummaryCharts.mockResolvedValueOnce(first); + await fetchStorageSummaryAndCache('1h'); + + eventBus.emit('org_switched', 'default'); + + mockGetStorageSummaryCharts.mockResolvedValueOnce(second); + await fetchStorageSummaryAndCache('1h'); + + expect(mockGetStorageSummaryCharts).toHaveBeenCalledTimes(2); + expect(readStorageSummaryCache('1h')).toBe(second); + }); + }); +});