Add unit tests for alertThresholdDefaults, storageSources, storageSummaryCache utils

This commit is contained in:
rcourtman
2026-07-09 20:42:58 +01:00
parent a040c7d7a6
commit 33b287f515
3 changed files with 784 additions and 0 deletions
@@ -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);
});
});
});
@@ -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<Pick<Storage, 'platform' | 'type'>>): 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<string>())).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);
});
});
});
@@ -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<typeof import('@/api/charts')>('@/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<StorageSummaryChartsResponse>((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);
});
});
});