diff --git a/frontend-modern/src/features/alerts/__tests__/guestOverrideIdentity.test.ts b/frontend-modern/src/features/alerts/__tests__/guestOverrideIdentity.test.ts new file mode 100644 index 0000000000..34d34df5c3 --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/guestOverrideIdentity.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'vitest'; + +import { + getGuestOverrideIdentity, + guestOverrideIdCandidates, + guestOverrideStorageId, + normalizeGuestOverrideKey, +} from '../guestOverrideIdentity'; + +describe('guestOverrideIdentity', () => { + describe('getGuestOverrideIdentity', () => { + it('returns undefined when no identity can be derived', () => { + expect(getGuestOverrideIdentity({})).toBeUndefined(); + expect(getGuestOverrideIdentity({ id: 'res-1' })).toBeUndefined(); + expect(getGuestOverrideIdentity({ node: 'pve' })).toBeUndefined(); + expect(getGuestOverrideIdentity({ instance: 'cluster-a' })).toBeUndefined(); + }); + + it('derives identity from direct resource fields', () => { + expect( + getGuestOverrideIdentity({ + id: 'res-1', + instance: 'cluster-a', + node: 'node-1', + vmid: 100, + }), + ).toEqual({ + id: 'res-1', + instance: 'cluster-a', + node: 'node-1', + vmid: 100, + }); + }); + + it('parses a string vmid into a positive integer', () => { + expect( + getGuestOverrideIdentity({ instance: 'cluster-a', node: 'node-1', vmid: '100' }), + ).toEqual({ id: undefined, instance: 'cluster-a', node: 'node-1', vmid: 100 }); + }); + + it('trims whitespace on string components', () => { + expect( + getGuestOverrideIdentity({ + id: ' res-1 ', + instance: ' cluster-a ', + node: ' node-1 ', + vmid: ' 100 ', + }), + ).toEqual({ id: 'res-1', instance: 'cluster-a', node: 'node-1', vmid: 100 }); + }); + + it('rejects node values that contain a slash and falls through to the next source', () => { + expect( + getGuestOverrideIdentity({ + node: 'cluster/node', + proxmox: { node: 'node-1' }, + instance: 'cluster-a', + vmid: 100, + }), + ).toEqual({ id: undefined, instance: 'cluster-a', node: 'node-1', vmid: 100 }); + }); + + it.each([ + ['zero number', 0], + ['negative number', -5], + ['float number', 1.5], + ['NaN', Number.NaN], + ['zero string', '0'], + ['negative string', '-1'], + ['float string', '1.5'], + ['non-numeric string', 'abc'], + ['empty string', ''], + ])('rejects an invalid vmid (%s)', (_label, vmid) => { + expect( + getGuestOverrideIdentity({ instance: 'cluster-a', node: 'node-1', vmid }), + ).toBeUndefined(); + }); + + it.each([ + ['Infinity', Number.POSITIVE_INFINITY], + ['negative Infinity', Number.NEGATIVE_INFINITY], + ])('rejects an infinite vmid (%s)', (_label, vmid) => { + expect( + getGuestOverrideIdentity({ instance: 'cluster-a', node: 'node-1', vmid }), + ).toBeUndefined(); + }); + + it('resolves node and vmid from the direct proxmox block when top-level fields are absent', () => { + expect( + getGuestOverrideIdentity({ + instance: 'cluster-a', + proxmox: { node: 'node-1', vmid: 100 }, + }), + ).toEqual({ id: undefined, instance: 'cluster-a', node: 'node-1', vmid: 100 }); + }); + + it('resolves node from proxmox.nodeName as a fallback', () => { + expect( + getGuestOverrideIdentity({ + instance: 'cluster-a', + proxmox: { nodeName: 'node-1', vmid: 100 }, + }), + ).toEqual({ id: undefined, instance: 'cluster-a', node: 'node-1', vmid: 100 }); + }); + + it('resolves identity from platformData.proxmox when proxmox is absent', () => { + expect( + getGuestOverrideIdentity({ + platformData: { proxmox: { instance: 'cluster-a', node: 'node-1', vmid: 100 } }, + }), + ).toEqual({ id: undefined, instance: 'cluster-a', node: 'node-1', vmid: 100 }); + }); + + it('resolves identity from platformData directly when no proxmox blocks exist', () => { + expect( + getGuestOverrideIdentity({ + platformData: { instance: 'cluster-a', node: 'node-1', vmid: 100 }, + }), + ).toEqual({ id: undefined, instance: 'cluster-a', node: 'node-1', vmid: 100 }); + }); + + it('prefers direct resource.node over nested sources', () => { + expect( + getGuestOverrideIdentity({ + node: 'direct-node', + proxmox: { node: 'proxmox-node', nodeName: 'proxmox-node-name' }, + platformData: { proxmox: { node: 'platform-node' }, node: 'platform-data-node' }, + instance: 'cluster-a', + vmid: 100, + }), + ).toEqual({ id: undefined, instance: 'cluster-a', node: 'direct-node', vmid: 100 }); + }); + + it('prefers proxmox.node over proxmox.nodeName and platform sources', () => { + expect( + getGuestOverrideIdentity({ + proxmox: { node: 'proxmox-node', nodeName: 'proxmox-node-name' }, + platformData: { proxmox: { node: 'platform-node' }, node: 'platform-data-node' }, + instance: 'cluster-a', + vmid: 100, + }), + ).toEqual({ id: undefined, instance: 'cluster-a', node: 'proxmox-node', vmid: 100 }); + }); + + it('prefers platformData.proxmox.node over platformData.node', () => { + expect( + getGuestOverrideIdentity({ + platformData: { proxmox: { node: 'platform-proxmox-node' }, node: 'platform-data-node' }, + instance: 'cluster-a', + vmid: 100, + }), + ).toEqual({ id: undefined, instance: 'cluster-a', node: 'platform-proxmox-node', vmid: 100 }); + }); + + it('falls instance back to the resolved node when no instance source is available', () => { + expect( + getGuestOverrideIdentity({ node: 'pve', vmid: 100 }), + ).toEqual({ id: undefined, instance: 'pve', node: 'pve', vmid: 100 }); + }); + + it('ignores non-object proxmox and platformData values', () => { + expect( + getGuestOverrideIdentity({ + proxmox: 'not-an-object', + platformData: null, + instance: 'cluster-a', + node: 'node-1', + vmid: 100, + }), + ).toEqual({ id: undefined, instance: 'cluster-a', node: 'node-1', vmid: 100 }); + }); + + it('returns undefined id when resource.id is blank', () => { + expect( + getGuestOverrideIdentity({ id: ' ', instance: 'cluster-a', node: 'node-1', vmid: 100 }), + ).toEqual({ id: undefined, instance: 'cluster-a', node: 'node-1', vmid: 100 }); + }); + }); + + describe('guestOverrideStorageId', () => { + it('returns the trimmed resource id when no identity can be derived', () => { + expect(guestOverrideStorageId({ id: 'res-1' })).toBe('res-1'); + expect(guestOverrideStorageId({ id: ' res-1 ' })).toBe('res-1'); + }); + + it('returns an empty string when no identity and no usable id exist', () => { + expect(guestOverrideStorageId({})).toBe(''); + expect(guestOverrideStorageId({ id: ' ' })).toBe(''); + expect(guestOverrideStorageId({ id: undefined })).toBe(''); + }); + + it('uses the stable guest:instance:vmid key for cluster guests (instance !== node)', () => { + expect( + guestOverrideStorageId({ instance: 'cluster-a', node: 'node-1', vmid: 100 }), + ).toBe('guest:cluster-a:100'); + }); + + it('uses the canonical instance:node:vmid key for standalone guests (instance === node)', () => { + expect(guestOverrideStorageId({ instance: 'pve', node: 'pve', vmid: 100 })).toBe( + 'pve:pve:100', + ); + }); + + it('ignores resource.id when a guest identity is available', () => { + expect( + guestOverrideStorageId({ + id: 'res-1', + instance: 'cluster-a', + node: 'node-1', + vmid: 100, + }), + ).toBe('guest:cluster-a:100'); + }); + }); + + describe('guestOverrideIdCandidates', () => { + it('returns only the resource id when no identity can be derived', () => { + expect(guestOverrideIdCandidates({ id: 'res-1' })).toEqual(['res-1']); + expect(guestOverrideIdCandidates({ id: ' res-1 ' })).toEqual(['res-1']); + }); + + it('returns an empty array when no identity and no usable id exist', () => { + expect(guestOverrideIdCandidates({})).toEqual([]); + expect(guestOverrideIdCandidates({ id: ' ' })).toEqual([]); + }); + + it('builds the full candidate set for cluster guests including legacy keys', () => { + expect( + guestOverrideIdCandidates({ + id: 'res-1', + instance: 'cluster-a', + node: 'node-1', + vmid: 100, + }), + ).toEqual([ + 'guest:cluster-a:100', + 'cluster-a:node-1:100', + 'res-1', + 'cluster-a-100', + 'cluster-a-node-1-100', + ]); + }); + + it('omits the legacy cluster key for standalone guests and dedupes stable/canonical', () => { + expect( + guestOverrideIdCandidates({ id: 'res-1', instance: 'pve', node: 'pve', vmid: 100 }), + ).toEqual(['pve:pve:100', 'res-1', 'pve-100']); + }); + + it('omits the id candidate when resource.id is missing or blank', () => { + expect( + guestOverrideIdCandidates({ instance: 'cluster-a', node: 'node-1', vmid: 100 }), + ).toEqual([ + 'guest:cluster-a:100', + 'cluster-a:node-1:100', + 'cluster-a-100', + 'cluster-a-node-1-100', + ]); + expect(guestOverrideIdCandidates({ id: ' ', instance: 'pve', node: 'pve', vmid: 100 })).toEqual([ + 'pve:pve:100', + 'pve-100', + ]); + }); + }); + + describe('normalizeGuestOverrideKey', () => { + it.each([ + ['canonical cluster key', 'cluster-a:node-1:100', 'guest:cluster-a:100'], + ['stable guest key', 'guest:cluster-a:100', 'guest:cluster-a:100'], + ['canonical standalone key', 'pve:pve:100', 'pve:pve:100'], + ['whitespace-padded canonical', ' cluster-a:node-1:100 ', 'guest:cluster-a:100'], + ['whitespace-padded stable', ' guest:cluster-a:100 ', 'guest:cluster-a:100'], + ['too few parts', 'cluster-a:100', 'cluster-a:100'], + ['too many parts', 'cluster-a:node-1:100:extra', 'cluster-a:node-1:100:extra'], + ['zero vmid', 'cluster-a:node-1:0', 'cluster-a:node-1:0'], + ['negative vmid', 'cluster-a:node-1:-1', 'cluster-a:node-1:-1'], + ['node with slash', 'cluster-a:node/1:100', 'cluster-a:node/1:100'], + ['capital-G Guest prefix', 'Guest:cluster-a:100', 'Guest:cluster-a:100'], + ['empty string', '', ''], + ['only whitespace', ' ', ''], + ])('normalizes %s to the expected form', (_label, input, expected) => { + expect(normalizeGuestOverrideKey(input)).toBe(expected); + }); + + it('is idempotent for an already-stable cluster key', () => { + const stable = normalizeGuestOverrideKey('cluster-a:node-1:100'); + expect(normalizeGuestOverrideKey(stable)).toBe(stable); + }); + }); +}); diff --git a/frontend-modern/src/features/alerts/thresholds/__tests__/guestThresholdOverrideMutationModel.test.ts b/frontend-modern/src/features/alerts/thresholds/__tests__/guestThresholdOverrideMutationModel.test.ts new file mode 100644 index 0000000000..3e126fcace --- /dev/null +++ b/frontend-modern/src/features/alerts/thresholds/__tests__/guestThresholdOverrideMutationModel.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, it } from 'vitest'; + +import type { RawOverrideConfig } from '@/types/alerts'; + +import type { Override } from '../types'; +import { + findOverrideForResource, + findRawOverrideConfigForResource, + getOverridePersistenceIdentity, + stripOverrideCandidates, + stripRawOverrideCandidates, +} from '../guestThresholdOverrideMutationModel'; + +const override = (id: string): Override => + ({ + id, + name: id, + type: 'guest', + thresholds: {}, + }) as Override; + +const makeRawConfig = (trigger: number): RawOverrideConfig => + ({ cpu: { trigger, clear: trigger - 5 } }) as RawOverrideConfig; + +describe('guestThresholdOverrideMutationModel', () => { + describe('getOverridePersistenceIdentity', () => { + it('returns an empty identity for a missing resource', () => { + expect(getOverridePersistenceIdentity(undefined)).toEqual({ + candidateIds: [], + storageId: '', + }); + }); + + it('uses the exact id for non-guest resources', () => { + expect( + getOverridePersistenceIdentity({ id: 'agent-1', type: 'agent' }), + ).toEqual({ candidateIds: ['agent-1'], storageId: 'agent-1' }); + }); + + it('returns empty candidates for a non-guest resource with a blank id', () => { + expect(getOverridePersistenceIdentity({ id: '', type: 'agent' })).toEqual({ + candidateIds: [], + storageId: '', + }); + }); + + it('builds the full guest candidate set and stable storage id for cluster guests', () => { + const identity = getOverridePersistenceIdentity({ + id: 'res-1', + type: 'guest', + instance: 'cluster-a', + node: 'node-1', + vmid: 100, + }); + + expect(identity.storageId).toBe('guest:cluster-a:100'); + expect(identity.candidateIds).toEqual([ + 'guest:cluster-a:100', + 'cluster-a:node-1:100', + 'res-1', + 'cluster-a-100', + 'cluster-a-node-1-100', + ]); + }); + + it('uses the canonical storage id for standalone guests', () => { + const identity = getOverridePersistenceIdentity({ + id: 'res-1', + type: 'guest', + instance: 'pve', + node: 'pve', + vmid: 100, + }); + + expect(identity.storageId).toBe('pve:pve:100'); + expect(identity.candidateIds).toEqual(['pve:pve:100', 'res-1', 'pve-100']); + }); + + it('falls back to the resource id when no guest identity can be derived', () => { + expect(getOverridePersistenceIdentity({ id: 'res-1', type: 'guest' })).toEqual({ + candidateIds: ['res-1'], + storageId: 'res-1', + }); + }); + + it('falls back to a blank-id singleton candidate when no identity and no id exist', () => { + expect(getOverridePersistenceIdentity({ id: '', type: 'guest' })).toEqual({ + candidateIds: [''], + storageId: '', + }); + }); + }); + + describe('findOverrideForResource', () => { + const overrides = [ + override('guest:cluster-a:100'), + override('cluster-a-node-1-100'), + override('agent-1'), + ]; + + it('returns undefined for a missing resource', () => { + expect(findOverrideForResource(overrides, undefined)).toBeUndefined(); + }); + + it('matches by exact id for non-guest resources', () => { + expect(findOverrideForResource(overrides, { id: 'agent-1', type: 'agent' })).toEqual( + override('agent-1'), + ); + }); + + it('matches the first override against any guest candidate id', () => { + expect( + findOverrideForResource(overrides, { + id: 'res-1', + type: 'guest', + instance: 'cluster-a', + node: 'node-1', + vmid: 100, + }), + ).toEqual(override('guest:cluster-a:100')); + }); + + it('matches a legacy cluster candidate id', () => { + const legacyOnly = [override('cluster-a-node-1-100')]; + expect( + findOverrideForResource(legacyOnly, { + id: 'res-1', + type: 'guest', + instance: 'cluster-a', + node: 'node-1', + vmid: 100, + }), + ).toEqual(override('cluster-a-node-1-100')); + }); + + it('returns undefined when no candidate matches', () => { + expect( + findOverrideForResource(overrides, { + id: 'res-1', + type: 'guest', + instance: 'other', + node: 'node-9', + vmid: 999, + }), + ).toBeUndefined(); + }); + + it('returns undefined when candidateIds is empty and overrides is non-empty', () => { + expect( + findOverrideForResource(overrides, { id: '', type: 'agent' }), + ).toBeUndefined(); + }); + }); + + describe('findRawOverrideConfigForResource', () => { + const rawConfig: Record = { + 'guest:cluster-a:100': { cpu: { trigger: 90, clear: 85 } } as RawOverrideConfig, + 'cluster-a-node-1-100': { cpu: { trigger: 70, clear: 65 } } as RawOverrideConfig, + 'agent-1': { cpu: { trigger: 50, clear: 45 } } as RawOverrideConfig, + }; + + it('returns undefined for a missing resource', () => { + expect(findRawOverrideConfigForResource(rawConfig, undefined)).toBeUndefined(); + }); + + it('matches by exact id for non-guest resources', () => { + expect( + findRawOverrideConfigForResource(rawConfig, { id: 'agent-1', type: 'agent' }), + ).toEqual({ cpu: { trigger: 50, clear: 45 } }); + }); + + it('returns the first truthy raw config across guest candidate ids in priority order', () => { + expect( + findRawOverrideConfigForResource(rawConfig, { + id: 'res-1', + type: 'guest', + instance: 'cluster-a', + node: 'node-1', + vmid: 100, + }), + ).toEqual({ cpu: { trigger: 90, clear: 85 } }); + }); + + it('falls back to a later candidate id when earlier ones are absent', () => { + const onlyLegacy: Record = { + 'cluster-a-node-1-100': { cpu: { trigger: 70, clear: 65 } } as RawOverrideConfig, + }; + expect( + findRawOverrideConfigForResource(onlyLegacy, { + id: 'res-1', + type: 'guest', + instance: 'cluster-a', + node: 'node-1', + vmid: 100, + }), + ).toEqual({ cpu: { trigger: 70, clear: 65 } }); + }); + + it('returns undefined when no candidate key has a config', () => { + expect( + findRawOverrideConfigForResource(rawConfig, { + id: 'res-1', + type: 'guest', + instance: 'other', + node: 'node-9', + vmid: 999, + }), + ).toBeUndefined(); + }); + + it('skips falsy values and returns the next truthy candidate', () => { + const withGap: Record = { + 'guest:cluster-a:100': undefined as unknown as RawOverrideConfig, + 'cluster-a:node-1:100': { cpu: { trigger: 80, clear: 75 } } as RawOverrideConfig, + }; + expect( + findRawOverrideConfigForResource(withGap, { + id: 'res-1', + type: 'guest', + instance: 'cluster-a', + node: 'node-1', + vmid: 100, + }), + ).toEqual({ cpu: { trigger: 80, clear: 75 } }); + }); + }); + + describe('stripOverrideCandidates', () => { + const overrides = [ + override('guest:cluster-a:100'), + override('cluster-a:node-1:100'), + override('res-1'), + override('cluster-a-100'), + override('cluster-a-node-1-100'), + override('agent-1'), + ]; + + it('returns the same array reference when there are no candidate ids', () => { + const result = stripOverrideCandidates(overrides, undefined); + expect(result).toBe(overrides); + }); + + it('returns the same array reference for a non-guest resource with a blank id', () => { + const result = stripOverrideCandidates(overrides, { id: '', type: 'agent' }); + expect(result).toBe(overrides); + }); + + it('removes only the exact id override for non-guest resources', () => { + const result = stripOverrideCandidates(overrides, { id: 'agent-1', type: 'agent' }); + expect(result.map((o) => o.id)).not.toContain('agent-1'); + expect(result.map((o) => o.id)).toEqual([ + 'guest:cluster-a:100', + 'cluster-a:node-1:100', + 'res-1', + 'cluster-a-100', + 'cluster-a-node-1-100', + ]); + }); + + it('removes every guest candidate override for a cluster guest', () => { + const result = stripOverrideCandidates(overrides, { + id: 'res-1', + type: 'guest', + instance: 'cluster-a', + node: 'node-1', + vmid: 100, + }); + expect(result.map((o) => o.id)).toEqual(['agent-1']); + }); + + it('does not mutate the input overrides array', () => { + const snapshot = overrides.map((o) => o.id); + stripOverrideCandidates(overrides, { + id: 'res-1', + type: 'guest', + instance: 'cluster-a', + node: 'node-1', + vmid: 100, + }); + expect(overrides.map((o) => o.id)).toEqual(snapshot); + }); + }); + + describe('stripRawOverrideCandidates', () => { + const base: Record = { + 'guest:cluster-a:100': makeRawConfig(90), + 'cluster-a:node-1:100': makeRawConfig(80), + 'cluster-a-100': makeRawConfig(70), + 'cluster-a-node-1-100': makeRawConfig(60), + 'agent-1': makeRawConfig(50), + }; + + it('returns a new object with no candidates removed for a missing resource', () => { + const result = stripRawOverrideCandidates(base, undefined); + expect(result).not.toBe(base); + expect(result).toEqual(base); + }); + + it('removes only the exact id key for non-guest resources', () => { + const result = stripRawOverrideCandidates(base, { id: 'agent-1', type: 'agent' }); + expect(Object.keys(result).sort()).toEqual([ + 'cluster-a-100', + 'cluster-a-node-1-100', + 'cluster-a:node-1:100', + 'guest:cluster-a:100', + ]); + }); + + it('removes every guest candidate key for a cluster guest', () => { + const result = stripRawOverrideCandidates(base, { + id: 'res-1', + type: 'guest', + instance: 'cluster-a', + node: 'node-1', + vmid: 100, + }); + expect(Object.keys(result)).toEqual(['agent-1']); + }); + + it('does not mutate the input config object', () => { + const snapshot = { ...base }; + stripRawOverrideCandidates(base, { + id: 'res-1', + type: 'guest', + instance: 'cluster-a', + node: 'node-1', + vmid: 100, + }); + expect(base).toEqual(snapshot); + }); + + it('is a no-op on candidate keys that are not present in the config', () => { + const result = stripRawOverrideCandidates( + { 'agent-1': makeRawConfig(50) }, + { + id: 'res-1', + type: 'guest', + instance: 'cluster-a', + node: 'node-1', + vmid: 100, + }, + ); + expect(result).toEqual({ 'agent-1': makeRawConfig(50) }); + }); + }); +}); diff --git a/frontend-modern/src/features/alerts/thresholds/__tests__/thresholdsOverrideMutationModel.test.ts b/frontend-modern/src/features/alerts/thresholds/__tests__/thresholdsOverrideMutationModel.test.ts new file mode 100644 index 0000000000..e7736e69a6 --- /dev/null +++ b/frontend-modern/src/features/alerts/thresholds/__tests__/thresholdsOverrideMutationModel.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; + +import type { RawOverrideConfig } from '@/types/alerts'; + +import type { Override } from '../types'; +import { + stripStateKeys, + upsertOverride, + withThresholdEntries, +} from '../thresholdsOverrideMutationModel'; + +const makeOverride = (id: string, thresholds: Override['thresholds'] = {}): Override => + ({ + id, + name: id, + type: 'guest', + thresholds, + }) as Override; + +describe('thresholdsOverrideMutationModel', () => { + describe('upsertOverride', () => { + it('appends a new override when no matching id exists', () => { + const existing = [makeOverride('a'), makeOverride('b')]; + const next = upsertOverride(existing, makeOverride('c')); + + expect(next.map((o) => o.id)).toEqual(['a', 'b', 'c']); + }); + + it('replaces an existing override with the same id in place', () => { + const existing = [makeOverride('a'), makeOverride('b'), makeOverride('c')]; + const replacement = makeOverride('b', { cpu: 90 }); + const next = upsertOverride(existing, replacement); + + expect(next.map((o) => o.id)).toEqual(['a', 'b', 'c']); + expect(next[1]).toBe(replacement); + }); + + it('replaces only the first matching id', () => { + const dup = makeOverride('b', { cpu: 1 }); + const existing = [makeOverride('a'), dup, makeOverride('b', { cpu: 2 })]; + const replacement = makeOverride('b', { cpu: 90 }); + const next = upsertOverride(existing, replacement); + + expect(next[1]).toBe(replacement); + expect(next[2]).toBe(existing[2]); + }); + + it('appends to an empty list', () => { + const next = upsertOverride([], makeOverride('a')); + expect(next.map((o) => o.id)).toEqual(['a']); + }); + + it('does not mutate the input array', () => { + const existing = [makeOverride('a')]; + upsertOverride(existing, makeOverride('b')); + expect(existing.map((o) => o.id)).toEqual(['a']); + }); + + it('preserves the order of unrelated overrides when replacing', () => { + const existing = [ + makeOverride('a'), + makeOverride('b'), + makeOverride('c'), + makeOverride('d'), + ]; + const next = upsertOverride(existing, makeOverride('c', { cpu: 90 })); + expect(next.map((o) => o.id)).toEqual(['a', 'b', 'c', 'd']); + expect(next[2].thresholds).toEqual({ cpu: 90 }); + }); + }); + + describe('withThresholdEntries', () => { + it('adds hysteresis entries for each provided threshold', () => { + const result = withThresholdEntries({}, { cpu: 90, memory: 80 }); + expect(result).toEqual({ + cpu: { trigger: 90, clear: 85 }, + memory: { trigger: 80, clear: 75 }, + }); + }); + + it('clamps the clear value to zero when the trigger is below the margin', () => { + expect(withThresholdEntries({}, { cpu: 3 })).toEqual({ + cpu: { trigger: 3, clear: 0 }, + }); + }); + + it('clamps the clear value to zero when the trigger is exactly the margin', () => { + expect(withThresholdEntries({}, { cpu: 5 })).toEqual({ + cpu: { trigger: 5, clear: 0 }, + }); + }); + + it('keeps the clear value at zero for a zero trigger', () => { + expect(withThresholdEntries({}, { cpu: 0 })).toEqual({ + cpu: { trigger: 0, clear: 0 }, + }); + }); + + it('clamps the clear value to zero for a negative trigger (max(0, ...))', () => { + expect(withThresholdEntries({}, { cpu: -10 })).toEqual({ + cpu: { trigger: -10, clear: 0 }, + }); + }); + + it.each([ + ['undefined', undefined], + ['null', null], + ])('skips %s threshold values', (_label, value) => { + expect(withThresholdEntries({}, { cpu: value as number | undefined, memory: 80 })).toEqual({ + memory: { trigger: 80, clear: 75 }, + }); + }); + + it('preserves existing entries on the raw config and overwrites metrics by name', () => { + const base = { + cpu: { trigger: 50, clear: 45 }, + note: 'keep me', + } as RawOverrideConfig; + const result = withThresholdEntries(base, { cpu: 90, disk: 70 }); + + expect(result).toEqual({ + cpu: { trigger: 90, clear: 85 }, + disk: { trigger: 70, clear: 65 }, + note: 'keep me', + }); + }); + + it('does not mutate the input raw config', () => { + const base = { cpu: { trigger: 50, clear: 45 } } as RawOverrideConfig; + const snapshot = { ...base }; + withThresholdEntries(base, { cpu: 90 }); + expect(base).toEqual(snapshot); + }); + + it('returns an empty config for an empty thresholds map', () => { + expect(withThresholdEntries({}, {})).toEqual({}); + }); + }); + + describe('stripStateKeys', () => { + it('removes disabled, disableConnectivity, and poweredOffSeverity', () => { + const input = { + cpu: 90, + memory: 80, + disabled: true, + disableConnectivity: true, + poweredOffSeverity: 'critical', + } as unknown as Record; + expect(stripStateKeys(input)).toEqual({ cpu: 90, memory: 80 }); + }); + + it('is a no-op when no state keys are present', () => { + const input = { cpu: 90, memory: 80 } as Record; + expect(stripStateKeys(input)).toEqual({ cpu: 90, memory: 80 }); + }); + + it('returns a new object and does not mutate the input', () => { + const input = { cpu: 90, disabled: 1 } as unknown as Record; + const result = stripStateKeys(input); + expect(result).not.toBe(input); + expect((input as Record).disabled).toBe(1); + }); + + it('handles an empty object', () => { + expect(stripStateKeys({})).toEqual({}); + }); + }); +}); diff --git a/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts new file mode 100644 index 0000000000..7b96139ef1 --- /dev/null +++ b/frontend-modern/src/features/patrol/__tests__/patrolAutonomyAvailability.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it } from 'vitest'; + +import type { LicenseRuntimeCapabilityBlock, LicenseRuntimeIdentity } from '@/api/license'; + +import { + PATROL_AUTONOMY_FEATURE_KEY, + PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON, + getPatrolAutonomyAvailabilityPresentation, +} from '../patrolAutonomyAvailability'; +import type { UpgradeDestination } from '@/utils/upgradeNavigation'; + +const planDestination: UpgradeDestination = { + href: '/plans', + external: false, + hardNavigation: false, + newTab: false, + preserveOpener: false, +}; + +const externalDestination = (href: string): UpgradeDestination => ({ + href, + external: true, + hardNavigation: true, + newTab: true, + preserveOpener: false, +}); + +const internalDestination = (href: string): UpgradeDestination => ({ + href, + external: false, + hardNavigation: false, + newTab: false, + preserveOpener: false, +}); + +const runtimeBlock = ( + overrides: Partial = {}, +): LicenseRuntimeCapabilityBlock => ({ + key: PATROL_AUTONOMY_FEATURE_KEY, + reason: PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON, + ...overrides, +}); + +const runtime = ( + overrides: Partial = {}, +): LicenseRuntimeIdentity => ({ + build: 'community-1', + label: 'Community runtime', + ...overrides, +}); + +describe('patrolAutonomyAvailability', () => { + describe('exported constants', () => { + it('exposes the ai_autofix feature key', () => { + expect(PATROL_AUTONOMY_FEATURE_KEY).toBe('ai_autofix'); + }); + + it('exposes the paid_runtime_required reason', () => { + expect(PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON).toBe('paid_runtime_required'); + }); + }); + + describe('getPatrolAutonomyAvailabilityPresentation', () => { + it('is available when autoFix is not locked, ignoring every other field', () => { + const result = getPatrolAutonomyAvailabilityPresentation({ + autoFixLocked: false, + runtimeCapabilityBlock: runtimeBlock(), + runtime: runtime(), + commercialSurfacesHidden: true, + planUpgradeDestination: planDestination, + }); + + expect(result).toEqual({ + kind: 'available', + locked: false, + title: 'Patrol mode available', + body: 'Choose the mode for this install.', + }); + }); + + it('is available when autoFixLocked is falsy', () => { + const result = getPatrolAutonomyAvailabilityPresentation({ + autoFixLocked: undefined as unknown as boolean, + planUpgradeDestination: planDestination, + }); + expect(result.kind).toBe('available'); + expect(result.locked).toBe(false); + }); + + it.each([ + [ + 'runtime_locked beats commercialSurfacesHidden', + { + autoFixLocked: true, + runtimeCapabilityBlock: runtimeBlock(), + runtime: runtime(), + commercialSurfacesHidden: true, + upgradePromptsHidden: true, + planUpgradeDestination: planDestination, + }, + 'runtime_locked', + ], + [ + 'plan_locked when commercialSurfacesHidden and no runtime block', + { + autoFixLocked: true, + commercialSurfacesHidden: true, + planUpgradeDestination: planDestination, + }, + 'plan_locked', + ], + [ + 'plan_locked by default when locked but no block and surfaces visible', + { + autoFixLocked: true, + planUpgradeDestination: planDestination, + }, + 'plan_locked', + ], + ])('resolves kind precedence: %s', (_label, input, expectedKind) => { + expect(getPatrolAutonomyAvailabilityPresentation(input).kind).toBe(expectedKind); + }); + + describe('runtime_locked presentation', () => { + it('builds the runtime-locked surface with the runtime label in the body', () => { + const result = getPatrolAutonomyAvailabilityPresentation({ + autoFixLocked: true, + runtimeCapabilityBlock: runtimeBlock({ action_url: 'https://pro.example/downloads' }), + runtime: runtime({ label: 'Community runtime' }), + planUpgradeDestination: planDestination, + }); + + expect(result).toEqual({ + kind: 'runtime_locked', + locked: true, + title: 'Pulse Pro runtime required', + body: 'This install is running Community runtime. Install the Pulse Pro runtime to use Patrol modes.', + actionLabel: 'Open Pro downloads', + destination: externalDestination('https://pro.example/downloads'), + }); + }); + + it('uses the runtime download_url when the block has no action_url', () => { + const result = getPatrolAutonomyAvailabilityPresentation({ + autoFixLocked: true, + runtimeCapabilityBlock: runtimeBlock(), + runtime: runtime({ download_url: '/downloads/pro' }), + planUpgradeDestination: planDestination, + }); + + expect(result.actionLabel).toBe('Open Pro downloads'); + expect(result.destination).toEqual(internalDestination('/downloads/pro')); + }); + + it('prefers the block action_url over the runtime download_url', () => { + const result = getPatrolAutonomyAvailabilityPresentation({ + autoFixLocked: true, + runtimeCapabilityBlock: runtimeBlock({ action_url: 'https://block.example/dl' }), + runtime: runtime({ download_url: 'https://runtime.example/dl' }), + planUpgradeDestination: planDestination, + }); + + expect(result.destination).toEqual(externalDestination('https://block.example/dl')); + }); + + it('trims whitespace on the action_url before resolving', () => { + const result = getPatrolAutonomyAvailabilityPresentation({ + autoFixLocked: true, + runtimeCapabilityBlock: runtimeBlock({ action_url: ' https://pro.example/downloads ' }), + runtime: runtime(), + planUpgradeDestination: planDestination, + }); + + expect(result.destination).toEqual(externalDestination('https://pro.example/downloads')); + }); + + it('falls back to the plan upgrade destination when no runtime download source exists', () => { + const result = getPatrolAutonomyAvailabilityPresentation({ + autoFixLocked: true, + runtimeCapabilityBlock: runtimeBlock(), + runtime: runtime(), + planUpgradeDestination: planDestination, + }); + + expect(result.destination).toBe(planDestination); + }); + + it('falls back to the plan destination when action_url and download_url are blank', () => { + const result = getPatrolAutonomyAvailabilityPresentation({ + autoFixLocked: true, + runtimeCapabilityBlock: runtimeBlock({ action_url: ' ' }), + runtime: runtime({ download_url: ' ' }), + planUpgradeDestination: planDestination, + }); + + expect(result.destination).toBe(planDestination); + }); + + it('omits the action label and destination when upgrade prompts are hidden', () => { + const result = getPatrolAutonomyAvailabilityPresentation({ + autoFixLocked: true, + upgradePromptsHidden: true, + runtimeCapabilityBlock: runtimeBlock({ action_url: 'https://pro.example/downloads' }), + runtime: runtime(), + planUpgradeDestination: planDestination, + }); + + expect(result).toEqual({ + kind: 'runtime_locked', + locked: true, + title: 'Pulse Pro runtime required', + body: 'This install is running Community runtime. Install the Pulse Pro runtime to use Patrol modes.', + }); + expect(result).not.toHaveProperty('actionLabel'); + expect(result).not.toHaveProperty('destination'); + }); + + it('falls back to the "this runtime" label when the runtime label is blank', () => { + const result = getPatrolAutonomyAvailabilityPresentation({ + autoFixLocked: true, + upgradePromptsHidden: true, + runtimeCapabilityBlock: runtimeBlock(), + runtime: runtime({ label: ' ' }), + planUpgradeDestination: planDestination, + }); + + expect(result.body).toBe( + 'This install is running this runtime. Install the Pulse Pro runtime to use Patrol modes.', + ); + }); + + it('falls back to the "this runtime" label when runtime is undefined', () => { + const result = getPatrolAutonomyAvailabilityPresentation({ + autoFixLocked: true, + upgradePromptsHidden: true, + runtimeCapabilityBlock: runtimeBlock(), + planUpgradeDestination: planDestination, + }); + + expect(result.body).toBe( + 'This install is running this runtime. Install the Pulse Pro runtime to use Patrol modes.', + ); + }); + + it('trims the runtime label before interpolating it', () => { + const result = getPatrolAutonomyAvailabilityPresentation({ + autoFixLocked: true, + upgradePromptsHidden: true, + runtimeCapabilityBlock: runtimeBlock(), + runtime: runtime({ label: ' Pulse Pro ' }), + planUpgradeDestination: planDestination, + }); + + expect(result.body).toBe( + 'This install is running Pulse Pro. Install the Pulse Pro runtime to use Patrol modes.', + ); + }); + + it('does not treat a non-matching capability reason as runtime locked', () => { + const result = getPatrolAutonomyAvailabilityPresentation({ + autoFixLocked: true, + runtimeCapabilityBlock: runtimeBlock({ reason: 'some_other_reason' }), + runtime: runtime(), + planUpgradeDestination: planDestination, + }); + + expect(result.kind).toBe('plan_locked'); + expect(result.actionLabel).toBe('Plans & Billing'); + }); + }); + + describe('plan_locked presentation', () => { + it('shows the Plans & Billing action when surfaces are visible', () => { + const result = getPatrolAutonomyAvailabilityPresentation({ + autoFixLocked: true, + planUpgradeDestination: planDestination, + }); + + expect(result).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Watch only', + body: 'This install watches infrastructure and shows issues.', + actionLabel: 'Plans & Billing', + destination: planDestination, + }); + }); + + it('omits the action when upgrade prompts are hidden', () => { + const result = getPatrolAutonomyAvailabilityPresentation({ + autoFixLocked: true, + upgradePromptsHidden: true, + planUpgradeDestination: planDestination, + }); + + expect(result).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Watch only', + body: 'This install watches infrastructure and shows issues.', + }); + expect(result).not.toHaveProperty('actionLabel'); + expect(result).not.toHaveProperty('destination'); + }); + + it('omits the action when commercial surfaces are hidden, regardless of upgrade prompt visibility', () => { + const withPromptsVisible = getPatrolAutonomyAvailabilityPresentation({ + autoFixLocked: true, + commercialSurfacesHidden: true, + planUpgradeDestination: planDestination, + }); + + expect(withPromptsVisible).toEqual({ + kind: 'plan_locked', + locked: true, + title: 'Watch only', + body: 'This install watches infrastructure and shows issues.', + }); + expect(withPromptsVisible).not.toHaveProperty('actionLabel'); + expect(withPromptsVisible).not.toHaveProperty('destination'); + }); + }); + }); +});