From a040c7d7a6b4ae2258a71087d3d72ebd774e131d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 9 Jul 2026 20:40:37 +0100 Subject: [PATCH] Add unit tests for agentVersion, proxmoxVersion, resourceSearchMatch, vmwareDisplay utils --- .../src/utils/__tests__/agentVersion.test.ts | 124 ++++++++ .../utils/__tests__/proxmoxVersion.test.ts | 69 +++++ .../__tests__/resourceSearchMatch.test.ts | 265 ++++++++++++++++++ .../src/utils/__tests__/vmwareDisplay.test.ts | 43 +++ 4 files changed, 501 insertions(+) create mode 100644 frontend-modern/src/utils/__tests__/agentVersion.test.ts create mode 100644 frontend-modern/src/utils/__tests__/proxmoxVersion.test.ts create mode 100644 frontend-modern/src/utils/__tests__/resourceSearchMatch.test.ts create mode 100644 frontend-modern/src/utils/__tests__/vmwareDisplay.test.ts diff --git a/frontend-modern/src/utils/__tests__/agentVersion.test.ts b/frontend-modern/src/utils/__tests__/agentVersion.test.ts new file mode 100644 index 0000000000..aa21ff37c1 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/agentVersion.test.ts @@ -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); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/proxmoxVersion.test.ts b/frontend-modern/src/utils/__tests__/proxmoxVersion.test.ts new file mode 100644 index 0000000000..ae83bcc391 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/proxmoxVersion.test.ts @@ -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'); + }); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/resourceSearchMatch.test.ts b/frontend-modern/src/utils/__tests__/resourceSearchMatch.test.ts new file mode 100644 index 0000000000..7c15102298 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/resourceSearchMatch.test.ts @@ -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 => + ({ + 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); + 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), 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), 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)[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), 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); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/vmwareDisplay.test.ts b/frontend-modern/src/utils/__tests__/vmwareDisplay.test.ts new file mode 100644 index 0000000000..f1a6dd1f3a --- /dev/null +++ b/frontend-modern/src/utils/__tests__/vmwareDisplay.test.ts @@ -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'); + }); + }); +});