mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 12:15:42 +00:00
Add unit tests for alertThresholdDefaults, storageSources, storageSummaryCache utils
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user