Add unit tests for agentVersion, proxmoxVersion, resourceSearchMatch, vmwareDisplay utils

This commit is contained in:
rcourtman
2026-07-09 20:40:37 +01:00
parent 7897a9131e
commit a040c7d7a6
4 changed files with 501 additions and 0 deletions
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import {
compareAgentVersions,
formatAgentVersionDisplay,
parseAgentVersion,
} from '@/utils/agentVersion';
describe('agentVersion', () => {
describe('parseAgentVersion', () => {
it.each([
['null', null, null],
['undefined', undefined, null],
['empty string', '', null],
['whitespace only', ' ', null],
])('returns null for %s', (_label, input, expected) => {
expect(parseAgentVersion(input)).toEqual(expected);
});
it.each([
['plain x.y.z', '1.2.3', { major: 1, minor: 2, patch: 3, prerelease: [] }],
['lowercase v prefix', 'v1.2.3', { major: 1, minor: 2, patch: 3, prerelease: [] }],
['uppercase V prefix', 'V1.2.3', { major: 1, minor: 2, patch: 3, prerelease: [] }],
['whitespace padded', ' 1.2.3 ', { major: 1, minor: 2, patch: 3, prerelease: [] }],
['with prerelease', '1.2.3-rc.5', { major: 1, minor: 2, patch: 3, prerelease: ['rc', '5'] }],
['with prerelease + build', '1.2.3-rc.5+git.1', { major: 1, minor: 2, patch: 3, prerelease: ['rc', '5'] }],
['build metadata only', '1.2.3+git.151.gabcdef', { major: 1, minor: 2, patch: 3, prerelease: [] }],
['v + prerelease + build', 'v1.2.3-rc.5+build.2', { major: 1, minor: 2, patch: 3, prerelease: ['rc', '5'] }],
['leading zeros', '01.02.03', { major: 1, minor: 2, patch: 3, prerelease: [] }],
['extra dotted segments (first 3 used)', '1.2.3.4.5', { major: 1, minor: 2, patch: 3, prerelease: [] }],
['trailing dash with empty prerelease', '1.2.3-', { major: 1, minor: 2, patch: 3, prerelease: [] }],
])('parses %s', (_label, input, expected) => {
expect(parseAgentVersion(input)).toEqual(expected);
});
it.each([
['two parts', '1.2'],
['one part', '1'],
['non-numeric major', 'a.b.c'],
['non-numeric patch', '1.2.x'],
['leading dash (negative)', '-1.2.3'],
['only dots', '...'],
])('returns null for malformed %s', (_label, input) => {
expect(parseAgentVersion(input)).toBeNull();
});
it('is lenient about a numeric-looking prefix in a part', () => {
// parseInt('1e2', 10) === 1; this documents current lenient behavior.
expect(parseAgentVersion('1e2.0.0')).toEqual({
major: 1,
minor: 0,
patch: 0,
prerelease: [],
});
});
});
describe('compareAgentVersions', () => {
it('returns null when either side is unparseable', () => {
expect(compareAgentVersions(null, '1.0.0')).toBeNull();
expect(compareAgentVersions('1.0.0', null)).toBeNull();
expect(compareAgentVersions('garbage', '1.0.0')).toBeNull();
expect(compareAgentVersions('1.0.0', '1.0')).toBeNull();
expect(compareAgentVersions(undefined, undefined)).toBeNull();
});
it.each([
['equal stable', '1.0.0', '1.0.0', 0],
['major lower', '1.0.0', '2.0.0', -1],
['major higher', '2.0.0', '1.0.0', 1],
['minor lower', '1.1.0', '1.2.0', -1],
['minor higher', '1.2.0', '1.1.0', 1],
['patch lower', '1.0.1', '1.0.2', -1],
['patch higher', '1.0.2', '1.0.1', 1],
['v prefix ignored', 'v1.2.3', '1.2.3', 0],
])('compares core precedence: %s', (_label, a, b, expected) => {
expect(compareAgentVersions(a, b)).toBe(expected);
});
it.each([
['stable > prerelease', '1.0.0', '1.0.0-rc.1', 1],
['prerelease < stable', '1.0.0-rc.1', '1.0.0', -1],
['numeric prerelease lower', '1.0.0-rc.1', '1.0.0-rc.2', -1],
['numeric prerelease higher', '1.0.0-rc.2', '1.0.0-rc.1', 1],
['equal prerelease', '1.0.0-rc.1', '1.0.0-rc.1', 0],
['shorter prerelease set lower', '1.0.0-alpha', '1.0.0-alpha.1', -1],
['longer prerelease set higher', '1.0.0-alpha.1', '1.0.0-alpha', 1],
['lexical alpha lower', '1.0.0-alpha', '1.0.0-beta', -1],
['lexical alpha higher', '1.0.0-beta', '1.0.0-alpha', 1],
['numeric identifier below alpha', '1.0.0-1', '1.0.0-alpha', -1],
['alpha above numeric identifier', '1.0.0-alpha', '1.0.0-1', 1],
['build metadata ignored', '1.0.0+a', '1.0.0+b', 0],
])('applies semver prerelease rules: %s', (_label, a, b, expected) => {
expect(compareAgentVersions(a, b)).toBe(expected);
});
it('compares real-world agent version ranges in ascending order', () => {
const sorted = ['6.0.0-rc.5', '6.0.0', '6.1.0', '6.1.1'].sort(
(x, y) => (compareAgentVersions(x, y) ?? 0),
);
expect(sorted).toEqual(['6.0.0-rc.5', '6.0.0', '6.1.0', '6.1.1']);
});
});
describe('formatAgentVersionDisplay', () => {
it.each([
['null', null, ''],
['undefined', undefined, ''],
['empty', '', ''],
['malformed (too few parts)', '1.2', ''],
['plain', '1.2.3', 'v1.2.3'],
['lowercase v prefix', 'v1.2.3', 'v1.2.3'],
['uppercase V prefix', 'V1.2.3', 'v1.2.3'],
['prerelease', '1.2.3-rc.5', 'v1.2.3-rc.5'],
['strips build metadata', '1.2.3+git.151', 'v1.2.3'],
['prerelease + build', '1.2.3-rc.5+build', 'v1.2.3-rc.5'],
['trims whitespace', ' 1.2.3 ', 'v1.2.3'],
['normalizes extra segments', '1.2.3.4.5', 'v1.2.3'],
['normalizes leading zeros', '01.02.03', 'v1.2.3'],
])('formats %s', (_label, input, expected) => {
expect(formatAgentVersionDisplay(input)).toBe(expected);
});
});
});
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { formatProxmoxVersion } from '@/utils/proxmoxVersion';
describe('proxmoxVersion', () => {
describe('formatProxmoxVersion', () => {
it.each([
['null', null, ''],
['undefined', undefined, ''],
['empty string', '', ''],
['whitespace only', ' ', ''],
['unknown (lowercase)', 'unknown', ''],
['UNKNOWN (uppercase)', 'UNKNOWN', ''],
['padded unknown', ' unknown ', ''],
['Unknown (mixed case)', 'Unknown', ''],
])('returns empty for %s', (_label, input, expected) => {
expect(formatProxmoxVersion(input)).toBe(expected);
});
describe('pve-manager extraction', () => {
it.each([
['plain', 'pve-manager/8.0.4', '8.0.4'],
['uppercase prefix', 'PVE-Manager/8.0.4', '8.0.4'],
['stops at slash', 'pve-manager/8.0/9', '8.0'],
['stops at space', 'pve-manager/8.0.4 running', '8.0.4'],
['with suffix', 'pve-manager/8.0.4-1', '8.0.4-1'],
])('extracts version for %s', (_label, input, expected) => {
expect(formatProxmoxVersion(input)).toBe(expected);
});
it('falls through when pve-manager has no capturable value', () => {
expect(formatProxmoxVersion('pve-manager/')).toBe('pve-manager/');
});
});
describe('numeric dotted-version fallback', () => {
it.each([
['plain dotted', '8.0.4', '8.0.4'],
['embedded in text', 'version 8.0.4 here', '8.0.4'],
['two segments', '8.0', '8.0'],
['with hyphen suffix', '8.0.4-1', '8.0.4-1'],
['with plus suffix', '8.0.4+build', '8.0.4+build'],
])('extracts %s', (_label, input, expected) => {
expect(formatProxmoxVersion(input)).toBe(expected);
});
});
describe('last-resort passthrough', () => {
it.each([
['bare single number has no dotted version', '8', '8'],
['garbage token', 'garbage', 'garbage'],
['preserves original case', 'Garbage-Text', 'Garbage-Text'],
['number with trailing text', '8x', '8x'],
])('returns input trimmed for %s', (_label, input, expected) => {
expect(formatProxmoxVersion(input)).toBe(expected);
});
it('trims surrounding whitespace on passthrough', () => {
expect(formatProxmoxVersion(' custom-build ')).toBe('custom-build');
});
});
describe('precedence', () => {
it('prefers the pve-manager capture over the numeric fallback', () => {
expect(formatProxmoxVersion('pve-manager/7.4.3 and 99.99.99')).toBe('7.4.3');
});
});
});
});
@@ -0,0 +1,265 @@
import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import { resourceMatchesSearch } from '@/utils/resourceSearchMatch';
const makeResource = (overrides: Partial<Resource> = {}): Resource =>
({
id: 'res-1',
type: 'agent',
name: 'res-1',
displayName: 'Resource One',
platformId: 'res-1',
platformType: 'agent',
sourceType: 'agent',
status: 'online',
lastSeen: Date.now(),
...overrides,
}) as Resource;
describe('resourceMatchesSearch', () => {
describe('empty / nullish terms match everything', () => {
it.each([
['null', null],
['undefined', undefined],
['empty string', ''],
['only spaces', ' '],
['tab + newline', '\t\n'],
])('returns true for %s', (_label, term) => {
expect(resourceMatchesSearch(makeResource(), term)).toBe(true);
});
it('returns true for an empty term even when resource has no candidates', () => {
const empty = makeResource({ id: '', name: '', displayName: '' });
expect(resourceMatchesSearch(empty, '')).toBe(true);
});
});
describe('case-insensitive substring matching on core identity fields', () => {
it.each([
['id', { id: 'prod-web-01' }, 'web'],
['name', { name: 'prod-web-01' }, 'WEB'],
['displayName', { displayName: 'Tower Server' }, 'tower'],
['parentName', { parentName: 'cluster-alpha' }, 'alpha'],
['agent.hostname', { agent: { hostname: 'node-a.local' } }, 'node-a'],
['identity.hostname', { identity: { hostname: 'box.internal' } }, 'box'],
])('matches via %s', (_label, overrides, term) => {
const resource = makeResource(overrides as Partial<Resource>);
expect(resourceMatchesSearch(resource, term)).toBe(true);
});
it('returns false when no candidate contains the term', () => {
expect(resourceMatchesSearch(makeResource({ displayName: 'isolated' }), 'web')).toBe(false);
});
it('matches a partial substring, not just whole values', () => {
expect(resourceMatchesSearch(makeResource({ displayName: 'hyper-tower' }), 'tower')).toBe(
true,
);
});
});
describe('canonicalIdentity fields', () => {
it.each([
['displayName', { canonicalIdentity: { displayName: 'Canonical Box' } }, 'canonical'],
['hostname', { canonicalIdentity: { hostname: 'canon.local' } }, 'canon'],
['primaryId', { canonicalIdentity: { primaryId: 'node:instance-1' } }, 'instance-1'],
])('matches canonicalIdentity.%s', (_label, overrides, term) => {
expect(resourceMatchesSearch(makeResource(overrides as Partial<Resource>), term)).toBe(true);
});
it('matches any alias in canonicalIdentity.aliases', () => {
const resource = makeResource({
canonicalIdentity: { aliases: ['first', 'second-alias', 'third'] },
});
expect(resourceMatchesSearch(resource, 'second-alias')).toBe(true);
expect(resourceMatchesSearch(resource, 'THIRD')).toBe(true);
expect(resourceMatchesSearch(resource, 'nope')).toBe(false);
});
});
describe('docker fields', () => {
it.each([
['hostname', { docker: { hostname: 'docker-host' } }, 'docker-host'],
['image', { docker: { image: 'nginx:latest' } }, 'nginx'],
['imageId', { docker: { imageId: 'sha256:abc123' } }, 'abc123'],
['volumeName', { docker: { volumeName: 'data-vol' } }, 'data-vol'],
['networkId', { docker: { networkId: 'net-99' } }, 'net-99'],
['driver', { docker: { driver: 'overlay2' } }, 'overlay'],
['serviceName', { docker: { serviceName: 'swarm-svc' } }, 'swarm'],
['taskId', { docker: { taskId: 'task-7' } }, 'task-7'],
])('matches docker.%s', (_label, overrides, term) => {
expect(resourceMatchesSearch(makeResource(overrides as Partial<Resource>), term)).toBe(true);
});
it('matches any entry in docker.repoTags', () => {
const resource = makeResource({ docker: { repoTags: ['redis:7', 'postgres:16'] } });
expect(resourceMatchesSearch(resource, 'postgres')).toBe(true);
expect(resourceMatchesSearch(resource, 'redis:7')).toBe(true);
});
it('matches any entry in docker.repoDigests', () => {
const resource = makeResource({
docker: { repoDigests: ['nginx@sha256:aaa', 'redis@sha256:bbb'] },
});
expect(resourceMatchesSearch(resource, 'sha256:bbb')).toBe(true);
});
it('handles empty repoTags / repoDigests arrays without matching', () => {
const resource = makeResource({ docker: { repoTags: [], repoDigests: [] } });
expect(resourceMatchesSearch(resource, 'anything')).toBe(false);
});
});
describe('kubernetes fields', () => {
const k8sBase = {
clusterName: 'prod-cluster',
nodeName: 'k8s-node-1',
namespace: 'kube-system',
resourceKind: 'Deployment',
serviceType: 'ClusterIP',
serviceName: 'api-service',
clusterIp: '10.0.0.5',
storageClass: 'fast-ssd',
provisioner: 'pd.csi.storage',
volumeBindingMode: 'WaitForFirstConsumer',
addressType: 'Endpointslice',
phase: 'Bound',
secretType: 'Opaque',
minAvailable: '2',
maxUnavailable: '25%',
targetKind: 'Deployment',
targetName: 'api-target',
reason: 'BackOff',
involvedName: 'involved-pod',
volumeName: 'pvc-1234',
};
it.each([
['clusterName', 'prod'],
['nodeName', 'node-1'],
['namespace', 'kube'],
['resourceKind', 'deploy'],
['serviceType', 'clusterip'],
['serviceName', 'api'],
['clusterIp', '10.0.0.5'],
['storageClass', 'ssd'],
['provisioner', 'csi'],
['volumeBindingMode', 'first'],
['addressType', 'slice'],
['phase', 'bound'],
['secretType', 'opaque'],
['minAvailable', '2'],
['maxUnavailable', '25%'],
['targetKind', 'deploy'],
['targetName', 'target'],
['reason', 'backoff'],
['involvedName', 'involved'],
['volumeName', 'pvc-1234'],
])('matches kubernetes.%s', (field, term) => {
const resource = makeResource({
kubernetes: { [field]: (k8sBase as Record<string, unknown>)[field] } as never,
});
expect(resourceMatchesSearch(resource, term)).toBe(true);
});
it.each([
['externalIps', ['203.0.113.5', '198.51.100.2'], '198.51.100.2'],
['hosts', ['host-a.example', 'host-b.example'], 'host-b'],
['addresses', ['1.1.1.1', '2.2.2.2'], '2.2.2.2'],
['policyTypes', ['Ingress', 'Egress'], 'egress'],
['parameterKeys', ['fsType', 'mode'], 'fstype'],
['dataKeys', ['config.json', 'data'], 'config'],
['binaryDataKeys', ['bin1', 'bin2'], 'bin2'],
['imagePullSecrets', ['regcred', 'ghcr'], 'ghcr'],
['limitTypes', ['Container', 'Pod'], 'container'],
['metricTypes', ['Resource', 'External'], 'resource'],
['accessModes', ['ReadWriteOnce', 'ReadOnlyMany'], 'readonly'],
])('matches any entry in kubernetes.%s', (field, values, term) => {
const resource = makeResource({ kubernetes: { [field]: values } as never });
expect(resourceMatchesSearch(resource, term)).toBe(true);
});
it('matches object keys from kubernetes.hard', () => {
const resource = makeResource({ kubernetes: { hard: { 'cpu': '4', 'memory': '8Gi' } } });
expect(resourceMatchesSearch(resource, 'memory')).toBe(true);
expect(resourceMatchesSearch(resource, 'cpu')).toBe(true);
});
it('matches object keys from kubernetes.used', () => {
const resource = makeResource({ kubernetes: { used: { 'pods': '10' } } });
expect(resourceMatchesSearch(resource, 'pods')).toBe(true);
});
it('handles empty arrays and empty maps without matching', () => {
const resource = makeResource({
kubernetes: {
externalIps: [],
hosts: [],
addresses: [],
hard: {},
used: {},
} as never,
});
expect(resourceMatchesSearch(resource, 'whatever')).toBe(false);
});
});
describe('platform metadata fields', () => {
it.each([
['vmware.runtimeHostName', { vmware: { runtimeHostName: 'esxi-01' } }, 'esxi'],
['vmware.clusterName', { vmware: { clusterName: 'vc-cluster' } }, 'vc-cluster'],
['vmware.datacenterName', { vmware: { datacenterName: 'DC-East' } }, 'east'],
['proxmox.node', { proxmox: { node: 'pve1' } }, 'pve1'],
['proxmox.nodeName', { proxmox: { nodeName: 'node-a' } }, 'node-a'],
['proxmox.clusterName', { proxmox: { clusterName: 'pmx-cluster' } }, 'pmx'],
])('matches %s', (_label, overrides, term) => {
expect(resourceMatchesSearch(makeResource(overrides as Partial<Resource>), term)).toBe(true);
});
});
describe('tags', () => {
it('matches any tag', () => {
const resource = makeResource({ tags: ['env:prod', 'tier:frontend'] });
expect(resourceMatchesSearch(resource, 'frontend')).toBe(true);
expect(resourceMatchesSearch(resource, 'env:prod')).toBe(true);
});
it('does not match when tags are absent', () => {
expect(resourceMatchesSearch(makeResource(), 'env:prod')).toBe(false);
});
});
describe('whitespace handling and filtering', () => {
it('trims the search term before matching', () => {
expect(
resourceMatchesSearch(makeResource({ displayName: 'tower' }), ' tower '),
).toBe(true);
});
it('trims candidate values before matching', () => {
expect(
resourceMatchesSearch(makeResource({ displayName: ' padded-host ' }), 'padded-host'),
).toBe(true);
});
it('ignores candidates that are empty after trimming', () => {
const resource = makeResource({ id: ' ', name: '', displayName: ' ' });
expect(resourceMatchesSearch(resource, '')).toBe(true);
expect(resourceMatchesSearch(resource, 'anything')).toBe(false);
});
it('does not match a term against whitespace-only candidates', () => {
const resource = makeResource({ displayName: ' ' });
expect(resourceMatchesSearch(resource, 'x')).toBe(false);
});
});
describe('resource with no populated metadata', () => {
it('matches empty term but not real terms', () => {
const resource = makeResource({ id: '', name: '', displayName: '' });
expect(resourceMatchesSearch(resource, '')).toBe(true);
expect(resourceMatchesSearch(resource, 'nonsense')).toBe(false);
});
});
});
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import type { ResourceVMwareMeta } from '@/types/resource';
import { formatVmwareClusterServices } from '@/utils/vmwareDisplay';
describe('vmwareDisplay', () => {
describe('formatVmwareClusterServices', () => {
it('returns empty string when meta is undefined', () => {
expect(formatVmwareClusterServices(undefined)).toBe('');
});
it('returns empty string when neither flag is set', () => {
expect(formatVmwareClusterServices({})).toBe('');
});
it.each([
['HA enabled', { clusterHaEnabled: true }, 'HA enabled'],
['HA disabled', { clusterHaEnabled: false }, 'HA disabled'],
['DRS enabled', { clusterDrsEnabled: true }, 'DRS enabled'],
['DRS disabled', { clusterDrsEnabled: false }, 'DRS disabled'],
])('renders a single flag for %s', (_label, meta, expected) => {
expect(formatVmwareClusterServices(meta as ResourceVMwareMeta)).toBe(expected);
});
it.each([
['both enabled', { clusterHaEnabled: true, clusterDrsEnabled: true }, 'HA enabled · DRS enabled'],
['both disabled', { clusterHaEnabled: false, clusterDrsEnabled: false }, 'HA disabled · DRS disabled'],
['HA enabled DRS disabled', { clusterHaEnabled: true, clusterDrsEnabled: false }, 'HA enabled · DRS disabled'],
['HA disabled DRS enabled', { clusterHaEnabled: false, clusterDrsEnabled: true }, 'HA disabled · DRS enabled'],
])('joins both flags with " · " for %s', (_label, meta, expected) => {
expect(formatVmwareClusterServices(meta as ResourceVMwareMeta)).toBe(expected);
});
it('ignores unrelated fields on the meta object', () => {
expect(
formatVmwareClusterServices({
clusterName: 'vc-1',
clusterHaEnabled: true,
} as ResourceVMwareMeta),
).toBe('HA enabled');
});
});
});