Add unit tests for alert override identity, threshold mutation models, patrol autonomy

This commit is contained in:
rcourtman
2026-07-09 21:46:11 +01:00
parent 70161e0079
commit 9706c4fade
4 changed files with 1128 additions and 0 deletions
@@ -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);
});
});
});
@@ -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<string, RawOverrideConfig> = {
'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<string, RawOverrideConfig> = {
'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<string, RawOverrideConfig> = {
'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<string, RawOverrideConfig> = {
'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) });
});
});
});
@@ -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<string, number>;
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<string, number>;
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<string, number>;
const result = stripStateKeys(input);
expect(result).not.toBe(input);
expect((input as Record<string, unknown>).disabled).toBe(1);
});
it('handles an empty object', () => {
expect(stripStateKeys({})).toEqual({});
});
});
});
@@ -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> = {},
): LicenseRuntimeCapabilityBlock => ({
key: PATROL_AUTONOMY_FEATURE_KEY,
reason: PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON,
...overrides,
});
const runtime = (
overrides: Partial<LicenseRuntimeIdentity> = {},
): 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');
});
});
});
});