mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
test(frontend): branch-coverage tests for 28 pure model/presentation modules
This commit is contained in:
+472
@@ -0,0 +1,472 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
appendVisibleTextBeforeAssistantOutputArtifacts,
|
||||
createAssistantOutputArtifactStreamState,
|
||||
flushPendingAssistantOutputText,
|
||||
stripAssistantOutputArtifacts,
|
||||
} from '../assistantOutputHygiene';
|
||||
|
||||
// Branch-coverage companion to assistantOutputHygiene.test.ts. Every target
|
||||
// function below is module-private, so each branch is driven through the public
|
||||
// entry points (stripAssistantOutputArtifacts / appendVisibleTextBefore… /
|
||||
// flushPendingAssistantOutputText) and asserted against concrete shapes.
|
||||
|
||||
const freshState = () => createAssistantOutputArtifactStreamState();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatInternalIdentifier — reached via normalizeAssistantVisibleInternalIdentifiers
|
||||
// when an identifier is NOT in VISIBLE_INTERNAL_TOOL_IDENTIFIER_LABELS.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatInternalIdentifier branch coverage', () => {
|
||||
it('strips a pulse_ prefix and turns underscores into spaces', () => {
|
||||
// pulse_get_nodes is not in the labels map -> formatInternalIdentifier.
|
||||
expect(stripAssistantOutputArtifacts('Use pulse_get_nodes now.')).toEqual({
|
||||
text: 'Use get nodes now.',
|
||||
stripped: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('strips a patrol_ prefix leaving a single word with no underscores', () => {
|
||||
expect(stripAssistantOutputArtifacts('Run patrol_scan.')).toEqual({
|
||||
text: 'Run scan.',
|
||||
stripped: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('strips a patrol_ prefix and keeps internal underscores as spaces', () => {
|
||||
expect(stripAssistantOutputArtifacts('Start patrol_network_scan.')).toEqual({
|
||||
text: 'Start network scan.',
|
||||
stripped: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeAssistantVisibleInternalIdentifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('normalizeAssistantVisibleInternalIdentifiers branch coverage', () => {
|
||||
it('returns empty input untouched via the early guard', () => {
|
||||
// stripAssistantOutputArtifacts('') -> idx<0 -> normalize('') -> ''.
|
||||
expect(stripAssistantOutputArtifacts('')).toEqual({ text: '', stripped: false });
|
||||
});
|
||||
|
||||
it('leaves prose without any internal identifier untouched', () => {
|
||||
expect(stripAssistantOutputArtifacts('Just regular prose.')).toEqual({
|
||||
text: 'Just regular prose.',
|
||||
stripped: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('substitutes a mapped label (patrol_collect -> collection)', () => {
|
||||
expect(stripAssistantOutputArtifacts('The patrol_collect ran.')).toEqual({
|
||||
text: 'The collection ran.',
|
||||
stripped: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('consumes surrounding backticks and a trailing label word, handling multiple matches', () => {
|
||||
// The regex swallows the optional backticks and the trailing
|
||||
// (tool|command|query|call) word, replacing the whole match with the label.
|
||||
expect(
|
||||
stripAssistantOutputArtifacts('`pulse_read` tool and run_command query'),
|
||||
).toEqual({
|
||||
text: 'read command and command',
|
||||
stripped: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// appendVisibleTextBeforeAssistantOutputArtifacts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('appendVisibleTextBeforeAssistantOutputArtifacts branch coverage', () => {
|
||||
it('short-circuits when both content and pendingText are empty', () => {
|
||||
expect(appendVisibleTextBeforeAssistantOutputArtifacts(freshState(), '')).toEqual({
|
||||
text: '',
|
||||
stripped: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits previousVisibleText + replacementText when prior text is suppressed at an artifact', () => {
|
||||
const state = freshState();
|
||||
// First delta establishes non-empty visibleText.
|
||||
expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, 'Hello.')).toEqual({
|
||||
text: 'Hello.',
|
||||
stripped: false,
|
||||
});
|
||||
// Second delta is a compacted prelude (0 whitespace, >=16 letters) glued to
|
||||
// a pulse_read leak. shouldSuppress is true AND previousVisibleText is set.
|
||||
// The '\n' separator before pulse_read is required: findFunctionToolCallLeak
|
||||
// only matches a tool name preceded by a non-word character.
|
||||
expect(
|
||||
appendVisibleTextBeforeAssistantOutputArtifacts(
|
||||
state,
|
||||
'Illcheckthedevicenodesinsidethecontainertoanswerthat\npulse_read(target_host="x")',
|
||||
),
|
||||
).toEqual({
|
||||
text: '',
|
||||
stripped: true,
|
||||
previousVisibleText: 'Hello.',
|
||||
replacementText: '',
|
||||
});
|
||||
expect(state.visibleText).toBe('');
|
||||
expect(state.rawVisibleText).toBe('');
|
||||
expect(state.pendingText).toBe('');
|
||||
});
|
||||
|
||||
it('returns a non-empty visibleDelta when new safe text grows beyond what was shown', () => {
|
||||
const state = freshState();
|
||||
expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, 'Hello.')).toEqual({
|
||||
text: 'Hello.',
|
||||
stripped: false,
|
||||
});
|
||||
// The artifact appears after additional visible prose, so normalizedSafeText
|
||||
// is longer than existingVisibleText -> visibleDelta is the new slice.
|
||||
expect(
|
||||
appendVisibleTextBeforeAssistantOutputArtifacts(
|
||||
state,
|
||||
' World.\npulse_read(target_host="x")',
|
||||
),
|
||||
).toEqual({ text: ' World.', stripped: true });
|
||||
});
|
||||
|
||||
it('returns an empty visibleDelta when the re-sliced safe text does not grow', () => {
|
||||
const state = freshState();
|
||||
expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, 'Done.')).toEqual({
|
||||
text: 'Done.',
|
||||
stripped: false,
|
||||
});
|
||||
// safeText re-slices back to 'Done.' which is no longer than what was shown.
|
||||
expect(
|
||||
appendVisibleTextBeforeAssistantOutputArtifacts(
|
||||
state,
|
||||
'\npulse_read(target_host="x")',
|
||||
),
|
||||
).toEqual({ text: '', stripped: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// flushPendingAssistantOutputText
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('flushPendingAssistantOutputText branch coverage', () => {
|
||||
it('returns an empty string when there is no pending text', () => {
|
||||
expect(flushPendingAssistantOutputText(freshState())).toBe('');
|
||||
});
|
||||
|
||||
it('releases a held partial-reasoning token (not suppressible) and updates state', () => {
|
||||
const state = freshState();
|
||||
// 'Think' is held as a potential reasoning prelude (prefix of "thinking").
|
||||
expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, 'Think')).toEqual({
|
||||
text: '',
|
||||
stripped: false,
|
||||
});
|
||||
// On flush it is NOT a complete reasoning heading -> not suppressed.
|
||||
expect(flushPendingAssistantOutputText(state)).toBe('Think');
|
||||
expect(state.visibleText).toBe('Think');
|
||||
expect(state.rawVisibleText).toBe('Think');
|
||||
expect(state.pendingText).toBe('');
|
||||
});
|
||||
|
||||
it('suppresses a held compacted prelude on flush', () => {
|
||||
const state = freshState();
|
||||
expect(
|
||||
appendVisibleTextBeforeAssistantOutputArtifacts(
|
||||
state,
|
||||
'Thisisbadmodelspacingbutitistheactualanswerbecauseitneverturnsintoatoolcall.',
|
||||
),
|
||||
).toEqual({ text: '', stripped: false });
|
||||
expect(flushPendingAssistantOutputText(state)).toBe('');
|
||||
expect(state.visibleText).toBe('');
|
||||
});
|
||||
|
||||
it('suppresses a held complete reasoning heading on flush', () => {
|
||||
const state = freshState();
|
||||
// 'Thinking' alone is a complete heading -> suppressible on flush.
|
||||
expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, 'Thinking')).toEqual({
|
||||
text: '',
|
||||
stripped: false,
|
||||
});
|
||||
expect(flushPendingAssistantOutputText(state)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isContentChannelReasoningPrelude
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isContentChannelReasoningPrelude branch coverage', () => {
|
||||
it('returns false for empty content (reached via an empty visible prefix)', () => {
|
||||
// visiblePrefix trims to '' -> shouldSuppress false -> normalize('') shown.
|
||||
expect(stripAssistantOutputArtifacts('pulse_read(target_host="x")')).toEqual({
|
||||
text: '',
|
||||
stripped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns false when no reasoning heading is present', () => {
|
||||
expect(stripAssistantOutputArtifacts('Just prose here\npulse_read()')).toEqual({
|
||||
text: 'Just prose here',
|
||||
stripped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns true for a bare heading with no body', () => {
|
||||
expect(stripAssistantOutputArtifacts('Thinking\npulse_read()')).toEqual({
|
||||
text: '',
|
||||
stripped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns true when the body contains a reasoning cue', () => {
|
||||
// cue "let me" -> internalReasoningCueRe matches -> suppress.
|
||||
expect(
|
||||
stripAssistantOutputArtifacts('Thoughts\nlet me count the entries first\npulse_read()'),
|
||||
).toEqual({ text: '', stripped: true });
|
||||
});
|
||||
|
||||
it('returns true for a cue-less body with >= 8 words', () => {
|
||||
expect(
|
||||
stripAssistantOutputArtifacts(
|
||||
'Thinking\nthe quick brown fox jumps over the lazy dog now\npulse_read()',
|
||||
),
|
||||
).toEqual({ text: '', stripped: true });
|
||||
});
|
||||
|
||||
it('returns false for a cue-less body with < 8 words', () => {
|
||||
expect(stripAssistantOutputArtifacts('Thinking\nshort body here\npulse_read()')).toEqual({
|
||||
text: 'Thinking\nshort body here',
|
||||
stripped: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isPotentialContentChannelReasoningPrelude — reached via the append hold path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isPotentialContentChannelReasoningPrelude branch coverage', () => {
|
||||
it('holds a short prefix of "thinking" (partial-prefix true arm)', () => {
|
||||
const state = freshState();
|
||||
expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, 'Think')).toEqual({
|
||||
text: '',
|
||||
stripped: false,
|
||||
});
|
||||
expect(state.pendingText).toBe('Think');
|
||||
});
|
||||
|
||||
it('holds the full "thinking" token via the heading arm (length is not < 8)', () => {
|
||||
const state = freshState();
|
||||
expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, 'Thinking')).toEqual({
|
||||
text: '',
|
||||
stripped: false,
|
||||
});
|
||||
expect(state.pendingText).toBe('Thinking');
|
||||
});
|
||||
|
||||
it('holds a "reasoning" heading via the heading arm', () => {
|
||||
const state = freshState();
|
||||
expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, 'Reasoning')).toEqual({
|
||||
text: '',
|
||||
stripped: false,
|
||||
});
|
||||
expect(state.pendingText).toBe('Reasoning');
|
||||
});
|
||||
|
||||
it('does not hold a short non-prefix token (partial-prefix false + heading false)', () => {
|
||||
const state = freshState();
|
||||
// 'xyz' is < 8 chars but not a prefix of "thinking" and not a heading.
|
||||
expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, 'xyz')).toEqual({
|
||||
text: 'xyz',
|
||||
stripped: false,
|
||||
});
|
||||
expect(state.pendingText).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isCompactedToolPrelude
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isCompactedToolPrelude branch coverage', () => {
|
||||
it('returns false for letters < 16 (short prose before a leak)', () => {
|
||||
expect(stripAssistantOutputArtifacts('Hi.pulse_read()')).toEqual({
|
||||
text: 'Hi.',
|
||||
stripped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns true for >= 16 letters with zero whitespace', () => {
|
||||
expect(
|
||||
stripAssistantOutputArtifacts(
|
||||
'Illcheckthedevicenodesinsidethecontainertoanswerthat\npulse_read()',
|
||||
),
|
||||
).toEqual({ text: '', stripped: true });
|
||||
});
|
||||
|
||||
it('returns true for >= 48 letters with exactly one whitespace', () => {
|
||||
// 43 letters + 1 space + 5 letters = 48 letters, 1 whitespace.
|
||||
expect(
|
||||
stripAssistantOutputArtifacts(
|
||||
'thequickbrownfoxjumpsoverlazydogandrunsfast abcde\npulse_read()',
|
||||
),
|
||||
).toEqual({ text: '', stripped: true });
|
||||
});
|
||||
|
||||
it('returns false for 16-47 letters with exactly one whitespace', () => {
|
||||
// 20 letters + 1 space + 1 letter = 21 letters, 1 whitespace.
|
||||
expect(
|
||||
stripAssistantOutputArtifacts('aaaaaaaaaaaaaaaaaaaa b\npulse_read()'),
|
||||
).toEqual({ text: 'aaaaaaaaaaaaaaaaaaaa b', stripped: true });
|
||||
});
|
||||
|
||||
it('returns false when whitespace >= 2 even with many letters', () => {
|
||||
expect(
|
||||
stripAssistantOutputArtifacts('alpha beta gamma delta epsilon zeta\npulse_read()'),
|
||||
).toEqual({ text: 'alpha beta gamma delta epsilon zeta', stripped: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// splitTrailingPotentialToolNamePrefix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('splitTrailingPotentialToolNamePrefix branch coverage', () => {
|
||||
it('holds nothing when the trailing run is not a known tool prefix', () => {
|
||||
// 'world123' is tool-name characters but not a pulse/patrol prefix.
|
||||
expect(appendVisibleTextBeforeAssistantOutputArtifacts(freshState(), 'hello world123')).toEqual({
|
||||
text: 'hello world123',
|
||||
stripped: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a leading backtick attached to a held pulse_ prefix', () => {
|
||||
const state = freshState();
|
||||
expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, 'see `pulse_')).toEqual({
|
||||
text: 'see ',
|
||||
stripped: false,
|
||||
});
|
||||
expect(state.pendingText).toBe('`pulse_');
|
||||
});
|
||||
|
||||
it('holds a known patrol_ partial prefix without a backtick', () => {
|
||||
const state = freshState();
|
||||
expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, 'go pat')).toEqual({
|
||||
text: 'go ',
|
||||
stripped: false,
|
||||
});
|
||||
expect(state.pendingText).toBe('pat');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isKnownAssistantToolNamePrefix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isKnownAssistantToolNamePrefix branch coverage', () => {
|
||||
it('matches a complete pulse_ tool name via the pulse_ regex arm', () => {
|
||||
const state = freshState();
|
||||
// 'pulse_read' (no parens) is a known prefix -> held, then normalized on flush.
|
||||
expect(appendVisibleTextBeforeAssistantOutputArtifacts(state, 'use pulse_read')).toEqual({
|
||||
text: 'use ',
|
||||
stripped: false,
|
||||
});
|
||||
expect(state.pendingText).toBe('pulse_read');
|
||||
expect(flushPendingAssistantOutputText(state)).toBe('read command');
|
||||
});
|
||||
|
||||
it('matches a complete patrol_ tool name via the patrol_ regex arm', () => {
|
||||
const state = freshState();
|
||||
expect(
|
||||
appendVisibleTextBeforeAssistantOutputArtifacts(state, 'try patrol_remediate'),
|
||||
).toEqual({
|
||||
text: 'try ',
|
||||
stripped: false,
|
||||
});
|
||||
expect(state.pendingText).toBe('patrol_remediate');
|
||||
// patrol_remediate IS in the labels map -> "remediation" on flush.
|
||||
expect(flushPendingAssistantOutputText(state)).toBe('remediation');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// assistantOutputArtifactIndex
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('assistantOutputArtifactIndex branch coverage', () => {
|
||||
it('returns -1 for plain text with no artifacts', () => {
|
||||
expect(stripAssistantOutputArtifacts('plain text only')).toEqual({
|
||||
text: 'plain text only',
|
||||
stripped: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('detects a minimax:tool_call leak', () => {
|
||||
// minimaxToolCallLeakRe matches at start of the second line.
|
||||
expect(stripAssistantOutputArtifacts('Hello\nminimax:tool_call foo')).toEqual({
|
||||
text: 'Hello',
|
||||
stripped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the earliest marker when a later-scanned raw marker appears first', () => {
|
||||
// '<|DSML|' is scanned before '<tool_call' but appears later in the string.
|
||||
// record() must lower `first` from the <|DSML| index down to <tool_call's 2.
|
||||
expect(stripAssistantOutputArtifacts('Hi<tool_call></tool_call><|DSML|')).toEqual({
|
||||
text: 'Hi',
|
||||
stripped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('detects a fenced ```json tool-call leak', () => {
|
||||
expect(stripAssistantOutputArtifacts('```json\n{"name":"pulse_exec","a":1}\n```')).toEqual({
|
||||
text: '',
|
||||
stripped: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findJSONToolCallLeak
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('findJSONToolCallLeak branch coverage', () => {
|
||||
it('skips a JSON object whose name is not tool-like and finds no other artifact', () => {
|
||||
// 'helper' fails isAssistantToolLikeName -> loop continues -> returns -1.
|
||||
expect(stripAssistantOutputArtifacts('{"name":"helper","x":1}')).toEqual({
|
||||
text: '{"name":"helper","x":1}',
|
||||
stripped: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the index of a tool-like JSON leak at the start of the content', () => {
|
||||
expect(
|
||||
stripAssistantOutputArtifacts('{"name":"pulse_query","parameters":{"action":"list"}}'),
|
||||
).toEqual({ text: '', stripped: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findFunctionToolCallLeak
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('findFunctionToolCallLeak branch coverage', () => {
|
||||
it('reports the tool name position, not the preceding non-word character', () => {
|
||||
// 'pulse_read' is preceded by a space; the leak index must point at 'p'.
|
||||
expect(stripAssistantOutputArtifacts('Run pulse_read(target_host="x") now')).toEqual({
|
||||
text: 'Run',
|
||||
stripped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips a non-tool-like function call so no artifact is reported', () => {
|
||||
expect(stripAssistantOutputArtifacts('Call helper(target="x") in the example.')).toEqual({
|
||||
text: 'Call helper(target="x") in the example.',
|
||||
stripped: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
+634
@@ -0,0 +1,634 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
alertResourceSupportsMetric,
|
||||
flattenAlertResourceTableResources,
|
||||
getAlertResourceColumnHeaderTooltip,
|
||||
getAlertResourceColumnKind,
|
||||
getAlertResourceEnabledDefault,
|
||||
getAlertResourceLabel,
|
||||
getAlertResourceMetricBounds,
|
||||
getAlertResourceMetricDelayOverride,
|
||||
getAlertResourceMetricDisplayValue,
|
||||
getAlertResourceMetricStep,
|
||||
hasAlertResourceTableRows,
|
||||
hasCustomAlertResourceGlobalDefaults,
|
||||
normalizeAlertResourceMetricKey,
|
||||
type AlertResourceTableResourceLike,
|
||||
type AlertResourceThresholdMap,
|
||||
} from '../alertResourceTableModel';
|
||||
|
||||
function makeResource(
|
||||
overrides: Partial<AlertResourceTableResourceLike> = {},
|
||||
): AlertResourceTableResourceLike {
|
||||
return {
|
||||
id: 'res-1',
|
||||
name: 'Test VM',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('flattenAlertResourceTableResources', () => {
|
||||
it('returns flattened grouped resources when groupedResources is provided', () => {
|
||||
const a = makeResource({ id: 'a', name: 'A' });
|
||||
const b = makeResource({ id: 'b', name: 'B' });
|
||||
const c = makeResource({ id: 'c', name: 'C' });
|
||||
expect(flattenAlertResourceTableResources(undefined, { nodes: [a, b], agents: [c] })).toEqual([
|
||||
a,
|
||||
b,
|
||||
c,
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns the resources array when groupedResources is undefined', () => {
|
||||
const a = makeResource({ id: 'a', name: 'A' });
|
||||
const b = makeResource({ id: 'b', name: 'B' });
|
||||
expect(flattenAlertResourceTableResources([a, b])).toEqual([a, b]);
|
||||
});
|
||||
|
||||
it('returns an empty array when both arguments are undefined', () => {
|
||||
expect(flattenAlertResourceTableResources()).toEqual([]);
|
||||
});
|
||||
|
||||
it('prefers groupedResources even when resources is also provided', () => {
|
||||
const a = makeResource({ id: 'a', name: 'A' });
|
||||
const b = makeResource({ id: 'b', name: 'B' });
|
||||
expect(flattenAlertResourceTableResources([b], { group: [a] })).toEqual([a]);
|
||||
});
|
||||
|
||||
it('returns an empty array for an empty groupedResources object (truthy but no values)', () => {
|
||||
expect(flattenAlertResourceTableResources(undefined, {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAlertResourceTableRows', () => {
|
||||
it('returns true when resources has entries (flatten length > 0)', () => {
|
||||
expect(hasAlertResourceTableRows([makeResource({ id: 'a', name: 'A' })])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when groupedResources has keys even if all groups are empty arrays', () => {
|
||||
expect(hasAlertResourceTableRows(undefined, { group: [] })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when only globalDefaults with a numeric value is provided', () => {
|
||||
expect(hasAlertResourceTableRows(undefined, undefined, { cpu: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when nothing is provided', () => {
|
||||
expect(hasAlertResourceTableRows()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when resources is empty, groupedResources is undefined, and globalDefaults is undefined', () => {
|
||||
expect(hasAlertResourceTableRows([], undefined, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for an empty-object globalDefaults because Boolean({}) is truthy', () => {
|
||||
expect(hasAlertResourceTableRows(undefined, undefined, {})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when groupedResources is non-empty and globalDefaults is also set', () => {
|
||||
expect(hasAlertResourceTableRows(undefined, { g: [makeResource()] }, { cpu: 80 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasCustomAlertResourceGlobalDefaults', () => {
|
||||
it('returns false when globalDefaults is undefined', () => {
|
||||
expect(hasCustomAlertResourceGlobalDefaults(undefined, { cpu: 80 })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when factoryDefaults is undefined', () => {
|
||||
expect(hasCustomAlertResourceGlobalDefaults({ cpu: 80 }, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when both are undefined', () => {
|
||||
expect(hasCustomAlertResourceGlobalDefaults(undefined, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when all factory keys match the global values exactly', () => {
|
||||
expect(
|
||||
hasCustomAlertResourceGlobalDefaults({ cpu: 80, memory: 90 }, { cpu: 80, memory: 90 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when at least one factory key differs in global', () => {
|
||||
expect(
|
||||
hasCustomAlertResourceGlobalDefaults({ cpu: 75, memory: 90 }, { cpu: 80, memory: 90 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when a factory key is absent from globalDefaults (current is undefined)', () => {
|
||||
expect(
|
||||
hasCustomAlertResourceGlobalDefaults({ memory: 90 }, { cpu: 80, memory: 90 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when exactly one of many keys differs', () => {
|
||||
expect(
|
||||
hasCustomAlertResourceGlobalDefaults(
|
||||
{ cpu: 80, memory: 90, disk: 95 },
|
||||
{ cpu: 80, memory: 90, disk: 85 },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a global value of 0 as defined and custom when factory is non-zero', () => {
|
||||
expect(hasCustomAlertResourceGlobalDefaults({ cpu: 0 }, { cpu: 80 })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when global value equals factory value of 0', () => {
|
||||
expect(hasCustomAlertResourceGlobalDefaults({ cpu: 0 }, { cpu: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeAlertResourceMetricKey', () => {
|
||||
describe('Map direct hits', () => {
|
||||
it.each([
|
||||
['cpu %', 'cpu'],
|
||||
['memory %', 'memory'],
|
||||
['disk %', 'disk'],
|
||||
['disk r mb/s', 'diskRead'],
|
||||
['disk w mb/s', 'diskWrite'],
|
||||
['net in mb/s', 'networkIn'],
|
||||
['net out mb/s', 'networkOut'],
|
||||
['usage %', 'usage'],
|
||||
['temp °c', 'temperature'],
|
||||
['temperature °c', 'temperature'],
|
||||
['temperature', 'temperature'],
|
||||
['restart count', 'restartCount'],
|
||||
['restart window', 'restartWindow'],
|
||||
['restart window (s)', 'restartWindow'],
|
||||
['memory warn %', 'memoryWarnPct'],
|
||||
['memory critical %', 'memoryCriticalPct'],
|
||||
['warning size (gib)', 'warningSizeGiB'],
|
||||
['critical size (gib)', 'criticalSizeGiB'],
|
||||
['disk temp °c', 'diskTemperature'],
|
||||
['backup', 'backup'],
|
||||
['snapshot', 'snapshot'],
|
||||
])('maps %s -> %s', (input, expected) => {
|
||||
expect(normalizeAlertResourceMetricKey(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trim and lowercase normalization before Map lookup', () => {
|
||||
it('trims surrounding whitespace before lookup', () => {
|
||||
expect(normalizeAlertResourceMetricKey(' cpu % ')).toBe('cpu');
|
||||
});
|
||||
|
||||
it('lowercases before lookup', () => {
|
||||
expect(normalizeAlertResourceMetricKey('CPU %')).toBe('cpu');
|
||||
});
|
||||
|
||||
it('handles mixed case and surrounding whitespace together', () => {
|
||||
expect(normalizeAlertResourceMetricKey(' Memory % ')).toBe('memory');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replace fallback chain (unmapped inputs)', () => {
|
||||
it('strips " %" suffix', () => {
|
||||
expect(normalizeAlertResourceMetricKey('foo %')).toBe('foo');
|
||||
});
|
||||
|
||||
it('strips " °c" suffix', () => {
|
||||
expect(normalizeAlertResourceMetricKey('foo °c')).toBe('foo');
|
||||
});
|
||||
|
||||
it('strips " mb/s" suffix', () => {
|
||||
expect(normalizeAlertResourceMetricKey('foo mb/s')).toBe('foo');
|
||||
});
|
||||
|
||||
it('maps "disk r" to "diskRead" via replace (not in Map)', () => {
|
||||
expect(normalizeAlertResourceMetricKey('disk r')).toBe('diskRead');
|
||||
});
|
||||
|
||||
it('maps "disk w" to "diskWrite" via replace', () => {
|
||||
expect(normalizeAlertResourceMetricKey('disk w')).toBe('diskWrite');
|
||||
});
|
||||
|
||||
it('maps "net in" to "networkIn" via replace', () => {
|
||||
expect(normalizeAlertResourceMetricKey('net in')).toBe('networkIn');
|
||||
});
|
||||
|
||||
it('maps "net out" to "networkOut" via replace', () => {
|
||||
expect(normalizeAlertResourceMetricKey('net out')).toBe('networkOut');
|
||||
});
|
||||
|
||||
it('returns the trimmed lowercased key unchanged when no pattern matches', () => {
|
||||
expect(normalizeAlertResourceMetricKey('Foobar')).toBe('foobar');
|
||||
});
|
||||
|
||||
it('chains multiple replaces: "disk r %" -> "diskRead"', () => {
|
||||
expect(normalizeAlertResourceMetricKey('disk r %')).toBe('diskRead');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertResourceMetricBounds', () => {
|
||||
it.each([
|
||||
['temperature', { min: -1, max: 150 }],
|
||||
['diskTemperature', { min: -1, max: 150 }],
|
||||
['diskRead', { min: -1, max: 10000 }],
|
||||
['diskWrite', { min: -1, max: 10000 }],
|
||||
['networkIn', { min: -1, max: 10000 }],
|
||||
['networkOut', { min: -1, max: 10000 }],
|
||||
['cpu', { min: -1, max: 100 }],
|
||||
['memory', { min: -1, max: 100 }],
|
||||
['disk', { min: -1, max: 100 }],
|
||||
['usage', { min: -1, max: 100 }],
|
||||
['memoryWarnPct', { min: -1, max: 100 }],
|
||||
['memoryCriticalPct', { min: -1, max: 100 }],
|
||||
['warningSizeGiB', { min: -1, max: 100000 }],
|
||||
['criticalSizeGiB', { min: -1, max: 100000 }],
|
||||
['restartCount', { min: -1, max: 50 }],
|
||||
['restartWindow', { min: -1, max: 86400 }],
|
||||
['unknownMetric', { min: -1, max: 10000 }],
|
||||
])('returns correct bounds for %s', (metric, expected) => {
|
||||
expect(getAlertResourceMetricBounds(metric)).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertResourceMetricStep', () => {
|
||||
it.each([
|
||||
['diskRead', 'any'],
|
||||
['diskWrite', 'any'],
|
||||
['networkIn', 'any'],
|
||||
['networkOut', 'any'],
|
||||
['warningSizeGiB', 'any'],
|
||||
['criticalSizeGiB', 'any'],
|
||||
['cpu', 1],
|
||||
['temperature', 1],
|
||||
['unknownMetric', 1],
|
||||
])('returns correct step for %s', (metric, expected) => {
|
||||
expect(getAlertResourceMetricStep(metric)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertResourceEnabledDefault', () => {
|
||||
it.each([
|
||||
['diskRead', 100],
|
||||
['diskWrite', 100],
|
||||
['networkIn', 100],
|
||||
['networkOut', 100],
|
||||
['temperature', 80],
|
||||
['diskTemperature', 55],
|
||||
['restartCount', 3],
|
||||
['restartWindow', 300],
|
||||
['memoryWarnPct', 90],
|
||||
['memoryCriticalPct', 95],
|
||||
['cpu', 80],
|
||||
['memory', 80],
|
||||
['unknownMetric', 80],
|
||||
])('returns correct default for %s', (metric, expected) => {
|
||||
expect(getAlertResourceEnabledDefault(metric)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertResourceMetricDelayOverride', () => {
|
||||
it('returns undefined when metricDelaySeconds is undefined', () => {
|
||||
expect(getAlertResourceMetricDelayOverride(undefined, 'cpu')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the value for a normalized (trimmed+lowercased) key match', () => {
|
||||
expect(getAlertResourceMetricDelayOverride({ cpu: 30 }, 'CPU')).toBe(30);
|
||||
});
|
||||
|
||||
it('returns the value when the metric is already normalized', () => {
|
||||
expect(getAlertResourceMetricDelayOverride({ cpu: 30 }, 'cpu')).toBe(30);
|
||||
});
|
||||
|
||||
it('falls back to the original metric key when normalized lookup misses', () => {
|
||||
expect(getAlertResourceMetricDelayOverride({ CPU: 45 } as Record<string, number>, 'CPU')).toBe(
|
||||
45,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined when neither normalized nor original key matches', () => {
|
||||
expect(getAlertResourceMetricDelayOverride({ foo: 30 }, 'cpu')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the looked-up value is NaN (not finite)', () => {
|
||||
expect(getAlertResourceMetricDelayOverride({ cpu: NaN }, 'cpu')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the looked-up value is Infinity (not finite)', () => {
|
||||
expect(getAlertResourceMetricDelayOverride({ cpu: Infinity }, 'cpu')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the value is not a number type', () => {
|
||||
const corrupted = { cpu: '30' } as unknown as Record<string, number>;
|
||||
expect(getAlertResourceMetricDelayOverride(corrupted, 'cpu')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 0 for a valid finite value of 0', () => {
|
||||
expect(getAlertResourceMetricDelayOverride({ cpu: 0 }, 'cpu')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns a negative finite value as-is', () => {
|
||||
expect(getAlertResourceMetricDelayOverride({ cpu: -5 }, 'cpu')).toBe(-5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertResourceColumnHeaderTooltip', () => {
|
||||
it('returns the tooltip for an exact column match', () => {
|
||||
expect(getAlertResourceColumnHeaderTooltip('cpu %')).toBe(
|
||||
'Percent CPU utilization allowed before an alert fires.',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the tooltip via normalized lookup when exact misses (case/whitespace)', () => {
|
||||
expect(getAlertResourceColumnHeaderTooltip(' CPU % ')).toBe(
|
||||
'Percent CPU utilization allowed before an alert fires.',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined when no tooltip exists for the column', () => {
|
||||
expect(getAlertResourceColumnHeaderTooltip('nonexistent column')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the mail-queue tooltip for a known queue column', () => {
|
||||
expect(getAlertResourceColumnHeaderTooltip('queue warn')).toBe(
|
||||
'Early warning when total mail queue exceeds this message count.',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the snapshot-size tooltip via normalized lookup (mixed case)', () => {
|
||||
expect(getAlertResourceColumnHeaderTooltip('Warning Size (GiB)')).toBe(
|
||||
'Total snapshot size in GiB that raises a warning.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertResourceColumnKind', () => {
|
||||
it('returns "badge" for "backup"', () => {
|
||||
expect(getAlertResourceColumnKind('backup')).toBe('badge');
|
||||
});
|
||||
|
||||
it('returns "badge" for "snapshot"', () => {
|
||||
expect(getAlertResourceColumnKind('snapshot')).toBe('badge');
|
||||
});
|
||||
|
||||
it('returns "badge" for "Backup" (case-insensitive via normalize)', () => {
|
||||
expect(getAlertResourceColumnKind('Backup')).toBe('badge');
|
||||
});
|
||||
|
||||
it('returns "numeric-value" for a standard numeric metric', () => {
|
||||
expect(getAlertResourceColumnKind('cpu %')).toBe('numeric-value');
|
||||
});
|
||||
|
||||
it('returns "numeric-value" for an unmapped column', () => {
|
||||
expect(getAlertResourceColumnKind('unknown')).toBe('numeric-value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('alertResourceSupportsMetric', () => {
|
||||
it('returns true when resourceType is undefined (any metric)', () => {
|
||||
expect(alertResourceSupportsMetric(undefined, 'cpu')).toBe(true);
|
||||
expect(alertResourceSupportsMetric(undefined, 'temperature')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for an empty-string resourceType', () => {
|
||||
expect(alertResourceSupportsMetric('', 'cpu')).toBe(true);
|
||||
});
|
||||
|
||||
describe('node / agent — throughput blocked', () => {
|
||||
it('returns false for all throughput metrics on node', () => {
|
||||
expect(alertResourceSupportsMetric('node', 'diskRead')).toBe(false);
|
||||
expect(alertResourceSupportsMetric('node', 'diskWrite')).toBe(false);
|
||||
expect(alertResourceSupportsMetric('node', 'networkIn')).toBe(false);
|
||||
expect(alertResourceSupportsMetric('node', 'networkOut')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for cpu on node (falls through to default)', () => {
|
||||
expect(alertResourceSupportsMetric('node', 'cpu')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for throughput on agent', () => {
|
||||
expect(alertResourceSupportsMetric('agent', 'networkIn')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for memory on agent', () => {
|
||||
expect(alertResourceSupportsMetric('agent', 'memory')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pbs — cpu/memory only', () => {
|
||||
it('supports cpu and memory', () => {
|
||||
expect(alertResourceSupportsMetric('pbs', 'cpu')).toBe(true);
|
||||
expect(alertResourceSupportsMetric('pbs', 'memory')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects disk and temperature', () => {
|
||||
expect(alertResourceSupportsMetric('pbs', 'disk')).toBe(false);
|
||||
expect(alertResourceSupportsMetric('pbs', 'temperature')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage — usage only', () => {
|
||||
it('supports usage', () => {
|
||||
expect(alertResourceSupportsMetric('storage', 'usage')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects cpu', () => {
|
||||
expect(alertResourceSupportsMetric('storage', 'cpu')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kubernetesNamespace — nothing supported', () => {
|
||||
it('returns false for all metrics', () => {
|
||||
expect(alertResourceSupportsMetric('kubernetesNamespace', 'cpu')).toBe(false);
|
||||
expect(alertResourceSupportsMetric('kubernetesNamespace', 'memory')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kubernetes cluster / deployment / pod', () => {
|
||||
it('supports cpu/disk/throughput on kubernetesCluster', () => {
|
||||
expect(alertResourceSupportsMetric('kubernetesCluster', 'cpu')).toBe(true);
|
||||
expect(alertResourceSupportsMetric('kubernetesCluster', 'disk')).toBe(true);
|
||||
expect(alertResourceSupportsMetric('kubernetesCluster', 'diskRead')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects temperature on kubernetesCluster', () => {
|
||||
expect(alertResourceSupportsMetric('kubernetesCluster', 'temperature')).toBe(false);
|
||||
});
|
||||
|
||||
it('supports networkIn on kubernetesDeployment', () => {
|
||||
expect(alertResourceSupportsMetric('kubernetesDeployment', 'networkIn')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects restartCount on kubernetesDeployment', () => {
|
||||
expect(alertResourceSupportsMetric('kubernetesDeployment', 'restartCount')).toBe(false);
|
||||
});
|
||||
|
||||
it('supports disk on kubernetesPod', () => {
|
||||
expect(alertResourceSupportsMetric('kubernetesPod', 'disk')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects usage on kubernetesPod', () => {
|
||||
expect(alertResourceSupportsMetric('kubernetesPod', 'usage')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kubernetesNode — cpu/memory/disk only', () => {
|
||||
it('supports cpu/memory/disk', () => {
|
||||
expect(alertResourceSupportsMetric('kubernetesNode', 'cpu')).toBe(true);
|
||||
expect(alertResourceSupportsMetric('kubernetesNode', 'memory')).toBe(true);
|
||||
expect(alertResourceSupportsMetric('kubernetesNode', 'disk')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects diskRead', () => {
|
||||
expect(alertResourceSupportsMetric('kubernetesNode', 'diskRead')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truenasSystem — extended set including temperature', () => {
|
||||
it('supports temperature and cpu', () => {
|
||||
expect(alertResourceSupportsMetric('truenasSystem', 'temperature')).toBe(true);
|
||||
expect(alertResourceSupportsMetric('truenasSystem', 'cpu')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects restartCount', () => {
|
||||
expect(alertResourceSupportsMetric('truenasSystem', 'restartCount')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truenasPool / truenasDataset — usage only', () => {
|
||||
it('supports usage on truenasPool', () => {
|
||||
expect(alertResourceSupportsMetric('truenasPool', 'usage')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects cpu on truenasPool', () => {
|
||||
expect(alertResourceSupportsMetric('truenasPool', 'cpu')).toBe(false);
|
||||
});
|
||||
|
||||
it('supports usage on truenasDataset', () => {
|
||||
expect(alertResourceSupportsMetric('truenasDataset', 'usage')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects temperature on truenasDataset', () => {
|
||||
expect(alertResourceSupportsMetric('truenasDataset', 'temperature')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truenasDisk — temperature only', () => {
|
||||
it('supports temperature', () => {
|
||||
expect(alertResourceSupportsMetric('truenasDisk', 'temperature')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects cpu', () => {
|
||||
expect(alertResourceSupportsMetric('truenasDisk', 'cpu')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vmwareHost — no disk', () => {
|
||||
it('supports cpu/memory/throughput', () => {
|
||||
expect(alertResourceSupportsMetric('vmwareHost', 'cpu')).toBe(true);
|
||||
expect(alertResourceSupportsMetric('vmwareHost', 'memory')).toBe(true);
|
||||
expect(alertResourceSupportsMetric('vmwareHost', 'diskRead')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects disk', () => {
|
||||
expect(alertResourceSupportsMetric('vmwareHost', 'disk')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vmwareVm — disk + throughput', () => {
|
||||
it('supports disk and networkOut', () => {
|
||||
expect(alertResourceSupportsMetric('vmwareVm', 'disk')).toBe(true);
|
||||
expect(alertResourceSupportsMetric('vmwareVm', 'networkOut')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects temperature', () => {
|
||||
expect(alertResourceSupportsMetric('vmwareVm', 'temperature')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vmwareDatastore — usage only', () => {
|
||||
it('supports usage', () => {
|
||||
expect(alertResourceSupportsMetric('vmwareDatastore', 'usage')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects cpu', () => {
|
||||
expect(alertResourceSupportsMetric('vmwareDatastore', 'cpu')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vmwareNetwork — nothing supported', () => {
|
||||
it('returns false for any metric', () => {
|
||||
expect(alertResourceSupportsMetric('vmwareNetwork', 'cpu')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dockerContainer — restart + memory-warn set', () => {
|
||||
it('supports restart and memory-warn metrics', () => {
|
||||
expect(alertResourceSupportsMetric('dockerContainer', 'restartCount')).toBe(true);
|
||||
expect(alertResourceSupportsMetric('dockerContainer', 'restartWindow')).toBe(true);
|
||||
expect(alertResourceSupportsMetric('dockerContainer', 'memoryWarnPct')).toBe(true);
|
||||
expect(alertResourceSupportsMetric('dockerContainer', 'memoryCriticalPct')).toBe(true);
|
||||
expect(alertResourceSupportsMetric('dockerContainer', 'cpu')).toBe(true);
|
||||
expect(alertResourceSupportsMetric('dockerContainer', 'memory')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects temperature and disk', () => {
|
||||
expect(alertResourceSupportsMetric('dockerContainer', 'temperature')).toBe(false);
|
||||
expect(alertResourceSupportsMetric('dockerContainer', 'disk')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown resource type — default true', () => {
|
||||
it('returns true for any metric', () => {
|
||||
expect(alertResourceSupportsMetric('mysteryType', 'cpu')).toBe(true);
|
||||
expect(alertResourceSupportsMetric('mysteryType', 'anything')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertResourceLabel', () => {
|
||||
it('returns the displayName when present', () => {
|
||||
const resource = makeResource({ displayName: 'Prod Web Server' });
|
||||
expect(getAlertResourceLabel(resource)).toBe('Prod Web Server');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from displayName', () => {
|
||||
const resource = makeResource({ displayName: ' Prod Web Server ' });
|
||||
expect(getAlertResourceLabel(resource)).toBe('Prod Web Server');
|
||||
});
|
||||
|
||||
it('falls back to name when displayName is absent', () => {
|
||||
const resource = makeResource({ name: 'web-01' });
|
||||
expect(getAlertResourceLabel(resource)).toBe('web-01');
|
||||
});
|
||||
|
||||
it('falls through whitespace-only displayName to the name', () => {
|
||||
const resource = makeResource({ name: 'node-7', displayName: ' ' });
|
||||
expect(getAlertResourceLabel(resource)).toBe('node-7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAlertMetricNumber (exercised via getAlertResourceMetricDisplayValue)', () => {
|
||||
it('passes a negative number through the typeof-number branch unchanged', () => {
|
||||
const resource = makeResource({ thresholds: { cpu: -5 } });
|
||||
expect(getAlertResourceMetricDisplayValue(resource, 'cpu')).toBe(-5);
|
||||
});
|
||||
|
||||
it('passes a float through the typeof-number branch unchanged', () => {
|
||||
const resource = makeResource({ thresholds: { cpu: 42.5 } });
|
||||
expect(getAlertResourceMetricDisplayValue(resource, 'cpu')).toBe(42.5);
|
||||
});
|
||||
|
||||
it('parses a whitespace-padded numeric string via Number() (finite branch -> number)', () => {
|
||||
const thresholds = { cpu: ' 42 ' } as unknown as AlertResourceThresholdMap;
|
||||
const resource = makeResource({ thresholds });
|
||||
expect(getAlertResourceMetricDisplayValue(resource, 'cpu')).toBe(42);
|
||||
});
|
||||
|
||||
it('returns 0 fallback for null (null branch -> undefined -> defaults -> 0)', () => {
|
||||
const thresholds = { cpu: null } as unknown as AlertResourceThresholdMap;
|
||||
const resource = makeResource({ thresholds });
|
||||
expect(getAlertResourceMetricDisplayValue(resource, 'cpu')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 fallback for a non-numeric string (Number() -> NaN -> undefined -> 0)', () => {
|
||||
const thresholds = { cpu: 'nope' } as unknown as AlertResourceThresholdMap;
|
||||
const resource = makeResource({ thresholds });
|
||||
expect(getAlertResourceMetricDisplayValue(resource, 'cpu')).toBe(0);
|
||||
});
|
||||
});
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildAccessSummary,
|
||||
buildHostDetailCards,
|
||||
buildHostDetailSummary,
|
||||
buildKubernetesCapabilityBadges,
|
||||
buildRelatedLinks,
|
||||
buildSourceHealthSummary,
|
||||
buildSourceSummary,
|
||||
hasRuntimeOperationalContext,
|
||||
} from '@/components/Infrastructure/resourceDetailDrawerOperationalModel';
|
||||
import type { PlatformData } from '@/components/Infrastructure/resourceDetailMappers';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
// Mirrors the private badge-class constants in resourceDetailDrawerOperationalModel.ts
|
||||
// so the assertions here stay brittle to drift in those exact Tailwind tokens.
|
||||
const SUPPORTED_BADGE_CLASS =
|
||||
'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-400';
|
||||
const UNSUPPORTED_BADGE_CLASS =
|
||||
'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-surface-alt text-muted';
|
||||
|
||||
type SourceStatusMap = NonNullable<PlatformData['sourceStatus']>;
|
||||
|
||||
const baseResource = (overrides: Partial<Resource>): Resource => ({
|
||||
id: 'resource-1',
|
||||
type: 'agent',
|
||||
name: 'host-1',
|
||||
displayName: 'Host 1',
|
||||
platformId: 'host-1',
|
||||
platformType: 'agent',
|
||||
sourceType: 'hybrid',
|
||||
status: 'online',
|
||||
lastSeen: Date.now(),
|
||||
platformData: { sources: ['agent'] },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('buildKubernetesCapabilityBadges branch coverage', () => {
|
||||
it('returns an empty array when capabilities are undefined', () => {
|
||||
expect(buildKubernetesCapabilityBadges(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when capabilities is an empty object (all flags undefined)', () => {
|
||||
// Every supported flag is undefined (falsy) AND podDiskIo is undefined,
|
||||
// so the only branch that fires is the "unsupported" one.
|
||||
expect(buildKubernetesCapabilityBadges({})).toEqual([
|
||||
{
|
||||
label: 'Pod Disk I/O Unsupported',
|
||||
classes: UNSUPPORTED_BADGE_CLASS,
|
||||
title:
|
||||
'Pod disk read/write throughput is not collected by the Kubernetes integration path today.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits only the Pod Network badge when podNetwork is the sole supported flag and podDiskIo is true', () => {
|
||||
// Drives the false-arm of nodeCpuMemory/nodeTelemetry/podCpuMemory/podEphemeralDisk,
|
||||
// the true-arm of podNetwork, and the true-arm of podDiskIo (which suppresses
|
||||
// the "unsupported" badge).
|
||||
expect(
|
||||
buildKubernetesCapabilityBadges({
|
||||
podNetwork: true,
|
||||
podDiskIo: true,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
label: 'Pod Network',
|
||||
classes: SUPPORTED_BADGE_CLASS,
|
||||
title: 'Pod network throughput is available.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits node telemetry and pod ephemeral disk badges together without the unsupported badge', () => {
|
||||
expect(
|
||||
buildKubernetesCapabilityBadges({
|
||||
nodeTelemetry: true,
|
||||
podEphemeralDisk: true,
|
||||
podDiskIo: true,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
label: 'Node Telemetry (Agent)',
|
||||
classes: SUPPORTED_BADGE_CLASS,
|
||||
title:
|
||||
'Linked Pulse agent provides node uptime, temperature, disk, network, and disk I/O.',
|
||||
},
|
||||
{
|
||||
label: 'Pod Ephemeral Disk',
|
||||
classes: SUPPORTED_BADGE_CLASS,
|
||||
title: 'Pod ephemeral storage usage is available.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSourceHealthSummary branch coverage', () => {
|
||||
it('counts an unrecognized status as unhealthy and returns the red summary', () => {
|
||||
// 'offline' matches neither the healthy nor degraded token sets, hitting the
|
||||
// else branch (unhealthy += 1) and the unhealthy > 0 return arm.
|
||||
expect(buildSourceHealthSummary({ agent: { status: 'offline' } })).toEqual({
|
||||
label: '1/1 unhealthy',
|
||||
className: 'text-red-600 dark:text-red-400',
|
||||
title: 'agent:offline',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the unhealthy summary when both unhealthy and warning counts are positive', () => {
|
||||
expect(
|
||||
buildSourceHealthSummary({
|
||||
agent: { status: 'offline' },
|
||||
docker: { status: 'degraded' },
|
||||
}),
|
||||
).toEqual({
|
||||
label: '1/2 unhealthy',
|
||||
className: 'text-red-600 dark:text-red-400',
|
||||
title: 'agent:offline • docker:degraded',
|
||||
});
|
||||
});
|
||||
|
||||
it('counts multiple unhealthy entries in the label numerator', () => {
|
||||
expect(
|
||||
buildSourceHealthSummary({
|
||||
a: { status: 'offline' },
|
||||
b: { status: 'down' },
|
||||
}),
|
||||
).toEqual({
|
||||
label: '2/2 unhealthy',
|
||||
className: 'text-red-600 dark:text-red-400',
|
||||
title: 'a:offline • b:down',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes healthy/degraded tokens via trim().toLowerCase() before matching', () => {
|
||||
// ' ONLINE ' normalizes to 'online' and hits the healthy continue branch.
|
||||
expect(buildSourceHealthSummary({ a: { status: ' ONLINE ' } })).toBeNull();
|
||||
// 'Warning' (mixed case) normalizes to 'warning' and hits the degraded branch.
|
||||
expect(buildSourceHealthSummary({ a: { status: 'Warning' } })).toEqual({
|
||||
label: '1/1 degraded',
|
||||
className: 'text-amber-600 dark:text-amber-400',
|
||||
title: 'a:warning',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an empty/whitespace status as "unknown" and counts it unhealthy', () => {
|
||||
// Exercises the `|| ''` fallback in `(status?.status || '')`.
|
||||
expect(buildSourceHealthSummary({ a: { status: ' ' } })).toEqual({
|
||||
label: '1/1 unhealthy',
|
||||
className: 'text-red-600 dark:text-red-400',
|
||||
title: 'a:unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('hits the optional-chain short-circuit when an entry value is nullish', () => {
|
||||
// `status?.status` short-circuits only when status itself is null/undefined;
|
||||
// the cast is required because the declared value type is non-nullable.
|
||||
const broken = { agent: null } as unknown as SourceStatusMap;
|
||||
expect(buildSourceHealthSummary(broken)).toEqual({
|
||||
label: '1/1 unhealthy',
|
||||
className: 'text-red-600 dark:text-red-400',
|
||||
title: 'agent:unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when every entry normalizes to a healthy token', () => {
|
||||
expect(
|
||||
buildSourceHealthSummary({
|
||||
a: { status: 'running' },
|
||||
b: { status: 'connected' },
|
||||
c: { status: 'ok' },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSourceSummary branch coverage', () => {
|
||||
it('delegates to buildSourceHealthSummary and surfaces the red unhealthy summary', () => {
|
||||
expect(buildSourceSummary(['agent'], { agent: { status: 'offline' } })).toEqual({
|
||||
label: '1/1 unhealthy',
|
||||
className: 'text-red-600 dark:text-red-400',
|
||||
title: 'agent:offline',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when buildSourceHealthSummary returns null (the fallthrough arm)', () => {
|
||||
expect(buildSourceSummary(['agent'], { agent: { status: 'online' } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHostDetailCards branch coverage', () => {
|
||||
it('returns an empty array when neither proxmox node nor agent details are present', () => {
|
||||
expect(
|
||||
buildHostDetailCards({
|
||||
hasProxmoxNode: false,
|
||||
hasAgentDetails: false,
|
||||
networkInterfaceCount: 0,
|
||||
diskCount: 0,
|
||||
raidCount: 0,
|
||||
temperatureRowCount: 0,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits only system/hardware/storage when only the proxmox node flag is set', () => {
|
||||
// The agent-section counts must be ignored entirely when hasAgentDetails is false.
|
||||
expect(
|
||||
buildHostDetailCards({
|
||||
hasProxmoxNode: true,
|
||||
hasAgentDetails: false,
|
||||
networkInterfaceCount: 9,
|
||||
diskCount: 9,
|
||||
raidCount: 9,
|
||||
temperatureRowCount: 9,
|
||||
}),
|
||||
).toEqual(['system', 'hardware', 'storage']);
|
||||
});
|
||||
|
||||
it('emits system/hardware plus every optional agent section when all counts are positive', () => {
|
||||
expect(
|
||||
buildHostDetailCards({
|
||||
hasProxmoxNode: false,
|
||||
hasAgentDetails: true,
|
||||
networkInterfaceCount: 1,
|
||||
diskCount: 1,
|
||||
raidCount: 1,
|
||||
temperatureRowCount: 1,
|
||||
}),
|
||||
).toEqual(['system', 'hardware', 'network', 'disks', 'raid', 'temperatures']);
|
||||
});
|
||||
|
||||
it('omits every optional section when agent detail counts are zero (each > 0 branch falsy)', () => {
|
||||
expect(
|
||||
buildHostDetailCards({
|
||||
hasProxmoxNode: false,
|
||||
hasAgentDetails: true,
|
||||
networkInterfaceCount: 0,
|
||||
diskCount: 0,
|
||||
raidCount: 0,
|
||||
temperatureRowCount: 0,
|
||||
}),
|
||||
).toEqual(['system', 'hardware']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHostDetailSummary branch coverage', () => {
|
||||
it('returns null for an empty card list', () => {
|
||||
expect(buildHostDetailSummary([])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the single category label verbatim when there is exactly one card', () => {
|
||||
expect(buildHostDetailSummary(['disks'])).toBe('Disks');
|
||||
});
|
||||
|
||||
it('joins two categories with "and"', () => {
|
||||
expect(buildHostDetailSummary(['system', 'network'])).toBe('System and Network');
|
||||
});
|
||||
|
||||
it('joins three or more categories with Oxford comma', () => {
|
||||
expect(buildHostDetailSummary(['system', 'hardware', 'network'])).toBe(
|
||||
'System, Hardware, and Network',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through an unknown card name via the ?? fallback', () => {
|
||||
expect(buildHostDetailSummary(['frobnicator'])).toBe('frobnicator');
|
||||
});
|
||||
|
||||
it('dedupes repeated card names before joining', () => {
|
||||
expect(buildHostDetailSummary(['system', 'system', 'hardware'])).toBe(
|
||||
'System and Hardware',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAccessSummary branch coverage', () => {
|
||||
it('pluralizes "links" when there is more than one link and no web interface', () => {
|
||||
const links = [
|
||||
{ href: '/a', label: 'A', compactLabel: 'A', ariaLabel: 'A' },
|
||||
{ href: '/b', label: 'B', compactLabel: 'B', ariaLabel: 'B' },
|
||||
];
|
||||
expect(buildAccessSummary({ hasWebInterface: false, links })).toBe('2 links');
|
||||
});
|
||||
|
||||
it('joins web interface and plural links with the " · " separator', () => {
|
||||
const links = [
|
||||
{ href: '/a', label: 'A', compactLabel: 'A', ariaLabel: 'A' },
|
||||
{ href: '/b', label: 'B', compactLabel: 'B', ariaLabel: 'B' },
|
||||
];
|
||||
expect(buildAccessSummary({ hasWebInterface: true, links })).toBe(
|
||||
'Web interface · 2 links',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the bare link count when web interface is absent and exactly one link is present', () => {
|
||||
const links = [{ href: '/a', label: 'A', compactLabel: 'A', ariaLabel: 'A' }];
|
||||
expect(buildAccessSummary({ hasWebInterface: false, links })).toBe('1 link');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRelatedLinks branch coverage', () => {
|
||||
it('returns the PMG thresholds link for a pmg resource', () => {
|
||||
// buildServiceDetailLinks produces a single link for type==='pmg';
|
||||
// the seen-set dedup then admits it (first occurrence -> true arm).
|
||||
const resource = baseResource({
|
||||
type: 'pmg',
|
||||
platformType: 'proxmox-pmg',
|
||||
name: 'mail',
|
||||
displayName: 'Mail Gateway',
|
||||
platformData: { sources: ['proxmox-pmg'] },
|
||||
});
|
||||
expect(buildRelatedLinks(resource, 'Mail Gateway')).toEqual([
|
||||
{
|
||||
href: '/alerts/thresholds/mail-gateway',
|
||||
label: 'Open PMG thresholds',
|
||||
compactLabel: 'Thresholds',
|
||||
ariaLabel: 'Open PMG thresholds for Mail Gateway',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasRuntimeOperationalContext branch coverage', () => {
|
||||
it('returns true when the badge list is non-empty', () => {
|
||||
expect(
|
||||
hasRuntimeOperationalContext([
|
||||
{
|
||||
label: 'K8s Node CPU/Memory',
|
||||
classes: SUPPORTED_BADGE_CLASS,
|
||||
title: 'Node CPU and memory metrics are available.',
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
+601
@@ -0,0 +1,601 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { APITokenRecord } from '@/api/security';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { DOCKER_REPORT_SCOPE } from '@/constants/apiScopes';
|
||||
import {
|
||||
API_TOKEN_PULSE_INTELLIGENCE_AGENT_PRESET_ID,
|
||||
agentActionIdForResource,
|
||||
buildAgentTokenUsage,
|
||||
buildDockerTokenUsage,
|
||||
dockerActionIdForResource,
|
||||
getAPITokenDialogName,
|
||||
getAPITokenHint,
|
||||
getAPITokenScopePresets,
|
||||
hasAgentScopeResource,
|
||||
matchesScopePreset,
|
||||
tokenRevokedAtForResource,
|
||||
} from '../apiTokenManagerModel';
|
||||
|
||||
// ---- Fixtures ---------------------------------------------------------------
|
||||
// Mirrors the sibling APITokenManager.test.tsx fixture builders so the private
|
||||
// helpers under test (readPlatformData / readNestedPlatformField /
|
||||
// readPlatformNumber / normalizePresetScopes / appendUsageEntry) are driven
|
||||
// through the single exported orchestrators that compose them.
|
||||
|
||||
const makeResource = (overrides: Partial<Resource> = {}): Resource => ({
|
||||
id: 'resource-1',
|
||||
type: 'agent',
|
||||
name: 'Resource One',
|
||||
displayName: 'Resource One',
|
||||
platformId: 'agent-1',
|
||||
platformType: 'agent',
|
||||
sourceType: 'agent',
|
||||
status: 'online',
|
||||
lastSeen: Date.now(),
|
||||
tags: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeToken = (overrides: Partial<APITokenRecord> = {}): APITokenRecord => ({
|
||||
id: 'token-1',
|
||||
name: 'Runtime token',
|
||||
prefix: 'pulse',
|
||||
suffix: '1234',
|
||||
createdAt: '2026-03-12T10:00:00.000Z',
|
||||
lastUsedAt: '2026-03-12T11:00:00.000Z',
|
||||
scopes: [DOCKER_REPORT_SCOPE],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ---- readPlatformNumber (private) via tokenRevokedAtForResource -------------
|
||||
//
|
||||
// readPlatformNumber is module-private and only reachable through
|
||||
// tokenRevokedAtForResource. Placing `tokenRevokedAt` at the TOP level of
|
||||
// platformData makes readNestedPlatformField return it verbatim (the
|
||||
// `field in platformData` arm), so readPlatformNumber receives the raw value
|
||||
// and every typeof/Number.isFinite arm is exercised.
|
||||
|
||||
describe('readPlatformNumber (via tokenRevokedAtForResource)', () => {
|
||||
it('returns a finite number verbatim', () => {
|
||||
expect(
|
||||
tokenRevokedAtForResource(
|
||||
makeResource({ platformData: { tokenRevokedAt: 1_700_000_000_000 } }),
|
||||
),
|
||||
).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it('returns 0 for a finite zero (not treated as missing)', () => {
|
||||
expect(
|
||||
tokenRevokedAtForResource(makeResource({ platformData: { tokenRevokedAt: 0 } })),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('returns a negative finite number verbatim', () => {
|
||||
expect(
|
||||
tokenRevokedAtForResource(makeResource({ platformData: { tokenRevokedAt: -42 } })),
|
||||
).toBe(-42);
|
||||
});
|
||||
|
||||
it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])(
|
||||
'returns undefined for a number that is not finite (%s)',
|
||||
(value) => {
|
||||
expect(
|
||||
tokenRevokedAtForResource(makeResource({ platformData: { tokenRevokedAt: value } })),
|
||||
).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['1700000000000', '', [], { ms: 1 }, true, null])(
|
||||
'returns undefined for a non-number value (%s)',
|
||||
(value) => {
|
||||
expect(
|
||||
tokenRevokedAtForResource(
|
||||
makeResource({ platformData: { tokenRevokedAt: value } as Record<string, unknown> }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---- readPlatformData (private) via tokenRevokedAtForResource ---------------
|
||||
//
|
||||
// readPlatformData is module-private; its two arms (platformData absent ->
|
||||
// undefined; present -> unwrap-and-return) are both hit through
|
||||
// tokenRevokedAtForResource.
|
||||
|
||||
describe('readPlatformData (via tokenRevokedAtForResource)', () => {
|
||||
it('returns undefined when platformData is absent (no platformData key)', () => {
|
||||
const resource = makeResource();
|
||||
expect(resource.platformData).toBeUndefined();
|
||||
expect(tokenRevokedAtForResource(resource)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('unwraps a populated platformData record so nested fields are reachable', () => {
|
||||
expect(
|
||||
tokenRevokedAtForResource(
|
||||
makeResource({ platformData: { tokenRevokedAt: 555, unrelated: true } }),
|
||||
),
|
||||
).toBe(555);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- readNestedPlatformField (private) via tokenRevokedAtForResource --------
|
||||
//
|
||||
// readNestedPlatformField is module-private; every arm is driven via
|
||||
// tokenRevokedAtForResource by relocating `tokenRevokedAt` through the
|
||||
// platformData graph (top-level -> agent -> docker -> absent).
|
||||
|
||||
describe('readNestedPlatformField (via tokenRevokedAtForResource)', () => {
|
||||
it('returns the field directly from platformData when present at the top level', () => {
|
||||
expect(
|
||||
tokenRevokedAtForResource(makeResource({ platformData: { tokenRevokedAt: 111 } })),
|
||||
).toBe(111);
|
||||
});
|
||||
|
||||
it('falls through to platformData.agent.<field> when not at the top level', () => {
|
||||
expect(
|
||||
tokenRevokedAtForResource(
|
||||
makeResource({ platformData: { agent: { tokenRevokedAt: 222 } } }),
|
||||
),
|
||||
).toBe(222);
|
||||
});
|
||||
|
||||
it('falls through to platformData.docker.<field> when neither top-level nor agent has it', () => {
|
||||
expect(
|
||||
tokenRevokedAtForResource(
|
||||
makeResource({ platformData: { docker: { tokenRevokedAt: 333 } } }),
|
||||
),
|
||||
).toBe(333);
|
||||
});
|
||||
|
||||
it('skips a non-object agent value and still resolves from docker', () => {
|
||||
// agent is a truthy string, so `typeof agent === 'object'` is false and the
|
||||
// agent branch is skipped; docker carries the field.
|
||||
expect(
|
||||
tokenRevokedAtForResource(
|
||||
makeResource({
|
||||
platformData: { agent: 'not-an-object', docker: { tokenRevokedAt: 444 } },
|
||||
}),
|
||||
),
|
||||
).toBe(444);
|
||||
});
|
||||
|
||||
it('returns undefined when agent is an object without the field and docker is absent', () => {
|
||||
expect(
|
||||
tokenRevokedAtForResource(
|
||||
makeResource({ platformData: { agent: { unrelated: true } } }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when docker is a non-object value (object guard skips it)', () => {
|
||||
expect(
|
||||
tokenRevokedAtForResource(makeResource({ platformData: { docker: 'oops' } })),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the field is nowhere in the platformData graph', () => {
|
||||
expect(
|
||||
tokenRevokedAtForResource(
|
||||
makeResource({
|
||||
platformData: { agent: { other: 1 }, docker: { other: 2 } },
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when platformData itself is undefined', () => {
|
||||
expect(tokenRevokedAtForResource(makeResource({ platformData: undefined }))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- tokenRevokedAtForResource (exported composition) ----------------------
|
||||
|
||||
describe('tokenRevokedAtForResource', () => {
|
||||
it('composes the three private readers to surface a revokedAt timestamp', () => {
|
||||
expect(
|
||||
tokenRevokedAtForResource(
|
||||
makeResource({
|
||||
platformData: { docker: { tokenId: 'tok', tokenRevokedAt: 9_999 } },
|
||||
}),
|
||||
),
|
||||
).toBe(9_999);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- hasAgentScopeResource --------------------------------------------------
|
||||
|
||||
describe('hasAgentScopeResource', () => {
|
||||
it('returns false for a docker-host even when it carries an agent facet (early return)', () => {
|
||||
const dockerHostWithAgent = makeResource({
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
agent: { agentId: 'would-otherwise-match' },
|
||||
});
|
||||
expect(hasAgentScopeResource(dockerHostWithAgent)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['agent', 'pbs', 'pmg'] as const)(
|
||||
'returns true for the canonical agent-scoped type %s',
|
||||
(type) => {
|
||||
expect(hasAgentScopeResource(makeResource({ type }))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('returns true for a non-canonical type that carries an agent facet', () => {
|
||||
// 'vm' is not agent/pbs/pmg/docker-host, but resourceHasAgentFacet is true
|
||||
// because resource.agent is set.
|
||||
expect(
|
||||
hasAgentScopeResource(
|
||||
makeResource({ type: 'vm', platformType: 'proxmox-pve', agent: { agentId: 'vm-agent' } }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a non-canonical type with no agent facet', () => {
|
||||
expect(
|
||||
hasAgentScopeResource(
|
||||
makeResource({ type: 'storage', platformType: 'proxmox-pve', agent: undefined }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getAPITokenHint --------------------------------------------------------
|
||||
|
||||
describe('getAPITokenHint', () => {
|
||||
it('returns the em-dash placeholder for null', () => {
|
||||
expect(getAPITokenHint(null)).toBe('—');
|
||||
});
|
||||
|
||||
it('returns the em-dash placeholder for undefined', () => {
|
||||
expect(getAPITokenHint(undefined)).toBe('—');
|
||||
});
|
||||
|
||||
it('combines prefix and suffix with an ellipsis when both are present', () => {
|
||||
expect(getAPITokenHint(makeToken({ prefix: 'pulse', suffix: '4321' }))).toBe('pulse…4321');
|
||||
});
|
||||
|
||||
it('uses only the prefix when the suffix is blank', () => {
|
||||
expect(getAPITokenHint(makeToken({ prefix: 'pulse', suffix: '' }))).toBe('pulse…');
|
||||
});
|
||||
|
||||
it('falls back to the em-dash when only a suffix is present (prefix is falsy)', () => {
|
||||
expect(getAPITokenHint(makeToken({ prefix: '', suffix: '9999' }))).toBe('—');
|
||||
});
|
||||
|
||||
it('falls back to the em-dash when neither prefix nor suffix is present', () => {
|
||||
expect(getAPITokenHint(makeToken({ prefix: '', suffix: '' }))).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getAPITokenDialogName --------------------------------------------------
|
||||
|
||||
describe('getAPITokenDialogName', () => {
|
||||
it('returns the trimmed name when the name has non-whitespace content', () => {
|
||||
expect(getAPITokenDialogName(makeToken({ name: ' Container automation ' }))).toBe(
|
||||
'Container automation',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls through when the name is only whitespace (trim() is empty -> falsy)', () => {
|
||||
expect(getAPITokenDialogName(makeToken({ name: ' ', prefix: 'pulse', suffix: '1234' }))).toBe(
|
||||
'pulse…1234',
|
||||
);
|
||||
});
|
||||
|
||||
it('exercises the optional-chain arm when name is undefined at runtime', () => {
|
||||
expect(
|
||||
getAPITokenDialogName(
|
||||
makeToken({ name: undefined as unknown as string, prefix: 'pulse', suffix: '' }),
|
||||
),
|
||||
).toBe('pulse…');
|
||||
});
|
||||
|
||||
it('combines prefix and suffix when the name is blank and both are set', () => {
|
||||
expect(getAPITokenDialogName(makeToken({ name: '', prefix: 'pl', suffix: 'xy' }))).toBe('pl…xy');
|
||||
});
|
||||
|
||||
it('returns "untitled token" when name, prefix, and suffix are all blank', () => {
|
||||
expect(getAPITokenDialogName(makeToken({ name: '', prefix: '', suffix: '' }))).toBe(
|
||||
'untitled token',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- normalizePresetScopes (private) via getAPITokenScopePresets -----------
|
||||
//
|
||||
// normalizePresetScopes is module-private; its only caller is
|
||||
// getAPITokenScopePresets, which feeds the normalized result to the Pulse
|
||||
// Intelligence preset (and gates that preset on length > 0). Asserting that
|
||||
// preset's `scopes` and presence exercises every normalizePresetScopes branch:
|
||||
// trim(), filter(Boolean), Set dedup, and the scopes ?? [] fallback.
|
||||
|
||||
describe('normalizePresetScopes (via getAPITokenScopePresets)', () => {
|
||||
const PULSE_PRESET_ID = API_TOKEN_PULSE_INTELLIGENCE_AGENT_PRESET_ID;
|
||||
|
||||
it('adds no Pulse Intelligence preset when the required-scopes arg defaults to []', () => {
|
||||
const presets = getAPITokenScopePresets();
|
||||
expect(presets.find((preset) => preset.id === PULSE_PRESET_ID)).toBeUndefined();
|
||||
expect(presets).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('adds no Pulse Intelligence preset when every provided scope is blank/whitespace', () => {
|
||||
const presets = getAPITokenScopePresets(['', ' ', '\t']);
|
||||
expect(presets.find((preset) => preset.id === PULSE_PRESET_ID)).toBeUndefined();
|
||||
expect(presets).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('trims, deduplicates, and drops empties, preserving first-seen order', () => {
|
||||
const presets = getAPITokenScopePresets([' b ', 'b', '', 'a']);
|
||||
const pulsePreset = presets.find((preset) => preset.id === PULSE_PRESET_ID);
|
||||
expect(pulsePreset?.scopes).toStrictEqual(['b', 'a']);
|
||||
expect(presets).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('keeps a single non-empty scope after normalization', () => {
|
||||
const presets = getAPITokenScopePresets([' monitoring:read ']);
|
||||
expect(presets.find((preset) => preset.id === PULSE_PRESET_ID)?.scopes).toStrictEqual([
|
||||
'monitoring:read',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- agentActionIdForResource -----------------------------------------------
|
||||
|
||||
describe('agentActionIdForResource', () => {
|
||||
it('returns the actionable agent id when an explicit agent id is resolvable', () => {
|
||||
expect(
|
||||
agentActionIdForResource(
|
||||
makeResource({
|
||||
id: 'fallback-id',
|
||||
platformData: { agent: { agentId: 'agent-007' } },
|
||||
}),
|
||||
),
|
||||
).toBe('agent-007');
|
||||
});
|
||||
|
||||
it('falls back to resource.id when no agent id is resolvable anywhere', () => {
|
||||
expect(
|
||||
agentActionIdForResource(
|
||||
makeResource({ id: 'fallback-id', type: 'vm', platformType: 'proxmox-pve' }),
|
||||
),
|
||||
).toBe('fallback-id');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- dockerActionIdForResource ----------------------------------------------
|
||||
|
||||
describe('dockerActionIdForResource', () => {
|
||||
it('returns the docker runtime id when hostSourceId is resolvable', () => {
|
||||
expect(
|
||||
dockerActionIdForResource(
|
||||
makeResource({
|
||||
id: 'fallback-id',
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
platformData: { docker: { hostSourceId: 'docker-runtime-1' } },
|
||||
}),
|
||||
),
|
||||
).toBe('docker-runtime-1');
|
||||
});
|
||||
|
||||
it('falls back to resource.id when no docker runtime id is resolvable', () => {
|
||||
expect(
|
||||
dockerActionIdForResource(
|
||||
makeResource({ id: 'fallback-id', platformData: { agent: { agentId: 'a1' } } }),
|
||||
),
|
||||
).toBe('fallback-id');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- appendUsageEntry (private) via buildDockerTokenUsage / buildAgentTokenUsage
|
||||
//
|
||||
// appendUsageEntry is module-private; its three arms (first entry / duplicate
|
||||
// item-id no-op / new item-id append) are driven through the two exported
|
||||
// usage builders, which also exercise their own skip/iterate branches.
|
||||
|
||||
describe('appendUsageEntry (via buildDockerTokenUsage)', () => {
|
||||
it('returns an empty map for an empty resource list', () => {
|
||||
expect(buildDockerTokenUsage([])).toStrictEqual(new Map());
|
||||
});
|
||||
|
||||
it('skips resources that have no tokenId', () => {
|
||||
const withoutToken = makeResource({
|
||||
id: 'no-token',
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
displayName: 'No Token',
|
||||
platformData: { docker: { hostSourceId: 'rt-9' } },
|
||||
});
|
||||
expect(buildDockerTokenUsage([withoutToken])).toStrictEqual(new Map());
|
||||
});
|
||||
|
||||
it('creates a count=1 entry for the first resource with a given tokenId', () => {
|
||||
const resource = makeResource({
|
||||
id: 'd-1',
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
displayName: 'Docker One',
|
||||
platformData: { docker: { hostSourceId: 'rt-1', tokenId: 't1' } },
|
||||
});
|
||||
expect(buildDockerTokenUsage([resource])).toStrictEqual(
|
||||
new Map([['t1', { count: 1, items: [{ id: 'rt-1', label: 'Docker One' }] }]]),
|
||||
);
|
||||
});
|
||||
|
||||
it('appends a distinct item and increments the count for a shared tokenId', () => {
|
||||
const a = makeResource({
|
||||
id: 'd-1',
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
displayName: 'Docker One',
|
||||
platformData: { docker: { hostSourceId: 'rt-1', tokenId: 't1' } },
|
||||
});
|
||||
const b = makeResource({
|
||||
id: 'd-2',
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
displayName: 'Docker Two',
|
||||
platformData: { docker: { hostSourceId: 'rt-2', tokenId: 't1' } },
|
||||
});
|
||||
expect(buildDockerTokenUsage([a, b])).toStrictEqual(
|
||||
new Map([
|
||||
[
|
||||
't1',
|
||||
{
|
||||
count: 2,
|
||||
items: [
|
||||
{ id: 'rt-1', label: 'Docker One' },
|
||||
{ id: 'rt-2', label: 'Docker Two' },
|
||||
],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('no-ops when a second resource maps to an already-recorded item id (dedup)', () => {
|
||||
// Both resources resolve to dockerActionId 'rt-shared' (same hostSourceId),
|
||||
// so appendUsageEntry's duplicate-item guard keeps count at 1 and preserves
|
||||
// the first-seen label.
|
||||
const a = makeResource({
|
||||
id: 'd-1',
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
displayName: 'Docker One',
|
||||
platformData: { docker: { hostSourceId: 'rt-shared', tokenId: 't1' } },
|
||||
});
|
||||
const b = makeResource({
|
||||
id: 'd-2',
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
displayName: 'Docker Two',
|
||||
platformData: { docker: { hostSourceId: 'rt-shared', tokenId: 't1' } },
|
||||
});
|
||||
expect(buildDockerTokenUsage([a, b])).toStrictEqual(
|
||||
new Map([
|
||||
['t1', { count: 1, items: [{ id: 'rt-shared', label: 'Docker One' }] }],
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendUsageEntry (via buildAgentTokenUsage)', () => {
|
||||
it('creates a count=1 entry for the first agent resource with a tokenId', () => {
|
||||
const resource = makeResource({
|
||||
id: 'a-1',
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
displayName: 'Agent One',
|
||||
platformData: { agent: { agentId: 'a1', tokenId: 't1' } },
|
||||
});
|
||||
expect(buildAgentTokenUsage([resource])).toStrictEqual(
|
||||
new Map([['t1', { count: 1, items: [{ id: 'a1', label: 'Agent One' }] }]]),
|
||||
);
|
||||
});
|
||||
|
||||
it('appends a distinct agent item and increments the count', () => {
|
||||
const a = makeResource({
|
||||
id: 'a-1',
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
displayName: 'Agent One',
|
||||
platformData: { agent: { agentId: 'a1', tokenId: 't1' } },
|
||||
});
|
||||
const b = makeResource({
|
||||
id: 'a-2',
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
displayName: 'Agent Two',
|
||||
platformData: { agent: { agentId: 'a2', tokenId: 't1' } },
|
||||
});
|
||||
expect(buildAgentTokenUsage([a, b])).toStrictEqual(
|
||||
new Map([
|
||||
[
|
||||
't1',
|
||||
{
|
||||
count: 2,
|
||||
items: [
|
||||
{ id: 'a1', label: 'Agent One' },
|
||||
{ id: 'a2', label: 'Agent Two' },
|
||||
],
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('dedups agent resources that resolve to the same actionable agent id', () => {
|
||||
const a = makeResource({
|
||||
id: 'a-1',
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
displayName: 'Agent One',
|
||||
platformData: { agent: { agentId: 'shared', tokenId: 't1' } },
|
||||
});
|
||||
const b = makeResource({
|
||||
id: 'a-2',
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
displayName: 'Agent Two',
|
||||
platformData: { agent: { agentId: 'shared', tokenId: 't1' } },
|
||||
});
|
||||
expect(buildAgentTokenUsage([a, b])).toStrictEqual(
|
||||
new Map([['t1', { count: 1, items: [{ id: 'shared', label: 'Agent One' }] }]]),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips agent resources that have no tokenId', () => {
|
||||
const withoutToken = makeResource({
|
||||
id: 'a-1',
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
displayName: 'Agent One',
|
||||
platformData: { agent: { agentId: 'a1' } },
|
||||
});
|
||||
expect(buildAgentTokenUsage([withoutToken])).toStrictEqual(new Map());
|
||||
});
|
||||
});
|
||||
|
||||
// ---- matchesScopePreset -----------------------------------------------------
|
||||
|
||||
describe('matchesScopePreset', () => {
|
||||
it('matches regardless of input order for an exact non-empty set', () => {
|
||||
expect(matchesScopePreset(['b', 'a'], ['a', 'b'])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the selection is a strict subset of the preset', () => {
|
||||
expect(matchesScopePreset(['a'], ['a', 'b'])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the selection is a strict superset of the preset', () => {
|
||||
expect(matchesScopePreset(['a', 'b', 'c'], ['a', 'b'])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a disjoint set of the same length', () => {
|
||||
expect(matchesScopePreset(['a'], ['b'])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for an empty preset against an empty selection', () => {
|
||||
expect(matchesScopePreset([], [])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for an empty preset when the selection is just the wildcard', () => {
|
||||
expect(matchesScopePreset(['*'], [])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for an empty preset when the selection has a non-wildcard scope', () => {
|
||||
expect(matchesScopePreset(['monitoring:read'], [])).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores the wildcard when comparing against a non-empty preset that otherwise matches', () => {
|
||||
// The '*' is filtered out before the length/equality check, so ['a','*']
|
||||
// matches preset ['a'].
|
||||
expect(matchesScopePreset(['a', '*'], ['a'])).toBe(true);
|
||||
});
|
||||
|
||||
it('still returns false when a wildcard is present but the remaining scope does not match', () => {
|
||||
expect(matchesScopePreset(['b', '*'], ['a'])).toBe(false);
|
||||
});
|
||||
});
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Connection, ConnectionType } from '@/api/connections';
|
||||
import type {
|
||||
InfrastructureSystemMemberRow,
|
||||
InfrastructureSystemRow,
|
||||
} from '../connectionsTableModel';
|
||||
import { collectInfrastructureAgentUpdateTargets } from '../infrastructureAgentUpdateCommandsModel';
|
||||
|
||||
// ---- Fixtures ---------------------------------------------------------------
|
||||
// Mirrors the sibling infrastructureAgentUpdateCommandsModel.test.ts factory
|
||||
// shape. The six functions under test (rowContextLabel, updateInstallFlagsForRow,
|
||||
// normalizeAgentConnectionID, connectionDisplayName, expectedVersionFor,
|
||||
// pushTarget) are all module-private, so every branch is driven through the
|
||||
// single exported orchestrator `collectInfrastructureAgentUpdateTargets`.
|
||||
|
||||
const connection = (overrides: Partial<Connection> = {}): Connection => ({
|
||||
id: 'pve:homelab',
|
||||
type: 'pve',
|
||||
name: 'homelab',
|
||||
address: 'https://pve.lab:8006',
|
||||
state: 'active',
|
||||
stateReason: '',
|
||||
enabled: true,
|
||||
surfaces: ['vms'],
|
||||
scope: { vms: true },
|
||||
lastSeen: new Date().toISOString(),
|
||||
lastError: null,
|
||||
source: 'manual',
|
||||
capabilities: { supportsPause: true, supportsScope: true, supportsTest: true },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const emptyFleetRow = {
|
||||
fleetSignals: [],
|
||||
fleetHighlights: [],
|
||||
} satisfies Pick<InfrastructureSystemRow, 'fleetSignals' | 'fleetHighlights'>;
|
||||
|
||||
const emptyFleetMember = {
|
||||
fleetSignals: [],
|
||||
fleetHighlights: [],
|
||||
} satisfies Pick<InfrastructureSystemMemberRow, 'fleetSignals' | 'fleetHighlights'>;
|
||||
|
||||
const row = (overrides: Partial<InfrastructureSystemRow> = {}): InfrastructureSystemRow => {
|
||||
const primary = connection();
|
||||
return {
|
||||
id: primary.id,
|
||||
ownerType: 'pve',
|
||||
name: 'homelab',
|
||||
subtitle: 'Cluster · 2 nodes',
|
||||
source: 'both',
|
||||
host: primary.address,
|
||||
coverageLabels: ['VMs', 'Host telemetry'],
|
||||
statusLabel: 'Active',
|
||||
statusClassName: 'bg-green-100 text-green-800',
|
||||
agentUpdateCount: 0,
|
||||
lastActivityText: '1m ago',
|
||||
...emptyFleetRow,
|
||||
enabled: true,
|
||||
canEdit: true,
|
||||
canPause: true,
|
||||
canRemove: true,
|
||||
isAgent: false,
|
||||
isCluster: false,
|
||||
attachedConnections: [],
|
||||
members: [],
|
||||
connection: primary,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
const member = (
|
||||
overrides: Partial<InfrastructureSystemMemberRow> = {},
|
||||
): InfrastructureSystemMemberRow => ({
|
||||
id: 'node-1',
|
||||
name: 'node-1',
|
||||
subtitle: 'Primary node',
|
||||
source: 'both',
|
||||
host: 'https://node-1:8006',
|
||||
coverageLabels: ['Host telemetry'],
|
||||
statusLabel: 'Active',
|
||||
statusClassName: 'bg-green-100 text-green-800',
|
||||
lastActivityText: '1m ago',
|
||||
...emptyFleetMember,
|
||||
primary: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// A stale agent is an agent-type connection that connectionNeedsUpdate() reports
|
||||
// true for. agentUpdateAvailable: true short-circuits the version comparison in
|
||||
// connectionNeedsUpdate, so the agent always reaches the target-building body
|
||||
// of pushTarget unless stated otherwise.
|
||||
const staleAgent = (overrides: Partial<Connection> = {}): Connection =>
|
||||
connection({
|
||||
id: 'agent:agent-1',
|
||||
type: 'agent',
|
||||
name: 'agent-1',
|
||||
address: 'agent-1',
|
||||
surfaces: ['host'],
|
||||
scope: { host: true },
|
||||
source: 'agent',
|
||||
agentVersion: '5.1.34',
|
||||
expectedAgentVersion: '6.0.0',
|
||||
agentUpdateAvailable: true,
|
||||
agentIdentity: { hostname: 'agent-1', platform: 'linux' },
|
||||
capabilities: { supportsPause: false, supportsScope: false, supportsTest: false },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ---- normalizeAgentConnectionID --------------------------------------------
|
||||
// Reachable via scopedAgentIds.map(...) and via the scope filter
|
||||
// (normalizeAgentConnectionID(target.key)). Branches: the (value || '') falsy
|
||||
// arm, the !trimmed early return, and both ternary arms of the agent: prefix
|
||||
// check.
|
||||
|
||||
describe('normalizeAgentConnectionID (via scopedAgentIds + scope filter)', () => {
|
||||
it('drops null/undefined/blank scope entries, keeps already-prefixed ids as-is, and prefixes bare ids', () => {
|
||||
const agentA = staleAgent({
|
||||
id: 'agent:host-1',
|
||||
name: 'host-1',
|
||||
address: 'host-1',
|
||||
agentIdentity: { hostname: 'host-1', platform: 'linux' },
|
||||
});
|
||||
const agentB = staleAgent({
|
||||
id: 'host-2',
|
||||
name: 'host-2',
|
||||
address: 'host-2',
|
||||
agentIdentity: { hostname: 'host-2', platform: 'linux' },
|
||||
});
|
||||
const agentC = staleAgent({
|
||||
id: 'agent:host-3',
|
||||
name: 'host-3',
|
||||
address: 'host-3',
|
||||
agentIdentity: { hostname: 'host-3', platform: 'linux' },
|
||||
});
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets(
|
||||
[row({ attachedConnections: [agentA, agentB, agentC] })],
|
||||
undefined,
|
||||
// null/undefined exercise the (value || '') falsy operand; '' and ' '
|
||||
// exercise the !trimmed early return; 'agent:host-1' exercises the
|
||||
// already-prefixed ternary arm; 'host-2' exercises the prefix-adding arm.
|
||||
[null, undefined, '', ' ', 'agent:host-1', 'host-2'] as unknown as readonly string[],
|
||||
);
|
||||
|
||||
// Only host-1 and host-2 are in the normalised scope set; host-3 is filtered
|
||||
// out. Results are sorted by displayName ascending.
|
||||
expect(targets.map((target) => target.key)).toEqual(['agent:host-1', 'host-2']);
|
||||
});
|
||||
|
||||
it('retains a target whose raw id is not agent-prefixed when a bare scope entry normalises to the same key', () => {
|
||||
// target.key 'host-9' normalises to 'agent:host-9' inside the filter; the
|
||||
// scope entry 'host-9' also normalises to 'agent:host-9', so both sides hit
|
||||
// the prefix-adding ternary arm and the target is retained.
|
||||
const agent = staleAgent({
|
||||
id: 'host-9',
|
||||
name: 'host-9',
|
||||
address: 'host-9',
|
||||
agentIdentity: { hostname: 'host-9', platform: 'linux' },
|
||||
});
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets(
|
||||
[row({ attachedConnections: [agent] })],
|
||||
undefined,
|
||||
['host-9'],
|
||||
);
|
||||
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]?.key).toBe('host-9');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- updateInstallFlagsForRow -----------------------------------------------
|
||||
// Reachable via pushTarget -> installFlags. Branches: every switch arm (pve,
|
||||
// pbs, docker, kubernetes) plus the implicit default (no case matches).
|
||||
|
||||
describe('updateInstallFlagsForRow (via target.installFlags)', () => {
|
||||
it.each([
|
||||
['pve', ['--enable-proxmox', '--proxmox-type pve']],
|
||||
['pbs', ['--enable-proxmox', '--proxmox-type pbs']],
|
||||
['docker', ['--enable-docker']],
|
||||
['kubernetes', ['--enable-kubernetes']],
|
||||
['agent', []],
|
||||
['pmg', []],
|
||||
['vmware', []],
|
||||
['truenas', []],
|
||||
['availability', []],
|
||||
] as const satisfies ReadonlyArray<[ConnectionType, string[]]>)(
|
||||
'emits the expected install flags for ownerType %s',
|
||||
(ownerType, expectedFlags) => {
|
||||
const agent = staleAgent({
|
||||
id: `agent:${ownerType}-node`,
|
||||
name: `${ownerType}-node`,
|
||||
address: `${ownerType}-node`,
|
||||
agentIdentity: { hostname: `${ownerType}-node`, platform: 'linux' },
|
||||
});
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets([
|
||||
row({ ownerType, attachedConnections: [agent] }),
|
||||
]);
|
||||
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]?.installFlags).toStrictEqual(expectedFlags);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---- connectionDisplayName --------------------------------------------------
|
||||
// Reachable via pushTarget -> displayName. Branches: every operand of the
|
||||
// short-circuit || chain (hostname, name, address, id) plus the optional-chain
|
||||
// miss when agentIdentity is undefined.
|
||||
|
||||
describe('connectionDisplayName (via target.displayName)', () => {
|
||||
it('prefers the agentIdentity hostname when it is present and non-empty', () => {
|
||||
const agent = staleAgent({
|
||||
id: 'agent:h1',
|
||||
name: 'named-host',
|
||||
address: '10.0.0.1',
|
||||
agentIdentity: { hostname: 'real-hostname', platform: 'linux' },
|
||||
});
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets([row({ attachedConnections: [agent] })]);
|
||||
|
||||
expect(targets[0]?.displayName).toBe('real-hostname');
|
||||
});
|
||||
|
||||
it('falls back to connection.name when agentIdentity is undefined (optional-chain miss)', () => {
|
||||
const agent = staleAgent({
|
||||
id: 'agent:h2',
|
||||
name: 'named-host',
|
||||
address: '10.0.0.2',
|
||||
agentIdentity: undefined,
|
||||
});
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets([row({ attachedConnections: [agent] })]);
|
||||
|
||||
expect(targets[0]?.displayName).toBe('named-host');
|
||||
});
|
||||
|
||||
it('falls back to connection.name when hostname trims to empty', () => {
|
||||
const agent = staleAgent({
|
||||
id: 'agent:h3',
|
||||
name: 'named-host',
|
||||
address: '10.0.0.3',
|
||||
agentIdentity: { hostname: ' ', platform: 'linux' },
|
||||
});
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets([row({ attachedConnections: [agent] })]);
|
||||
|
||||
expect(targets[0]?.displayName).toBe('named-host');
|
||||
});
|
||||
|
||||
it('falls back to address when both hostname and name trim to empty', () => {
|
||||
const agent = staleAgent({
|
||||
id: 'agent:h4',
|
||||
name: ' ',
|
||||
address: '10.0.0.4',
|
||||
agentIdentity: { hostname: ' ', platform: 'linux' },
|
||||
});
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets([row({ attachedConnections: [agent] })]);
|
||||
|
||||
expect(targets[0]?.displayName).toBe('10.0.0.4');
|
||||
});
|
||||
|
||||
it('falls back to the connection id when hostname, name, and address are all blank', () => {
|
||||
const agent = staleAgent({
|
||||
id: 'agent:h5',
|
||||
name: '',
|
||||
address: '',
|
||||
agentIdentity: { hostname: '', platform: 'linux' },
|
||||
});
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets([row({ attachedConnections: [agent] })]);
|
||||
|
||||
expect(targets[0]?.displayName).toBe('agent:h5');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- rowContextLabel --------------------------------------------------------
|
||||
// Reachable via pushTarget -> contextLabel. Branches: the cluster arm
|
||||
// (isCluster && name.trim()), the ownerType==='agent' arm, and the three-way
|
||||
// return fallback (row.name.trim() || connection.name || ownerType).
|
||||
|
||||
describe('rowContextLabel (via target.contextLabel)', () => {
|
||||
it('returns the raw (untrimmed) row name on the cluster arm', () => {
|
||||
const agent = staleAgent({ id: 'agent:c1' });
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets([
|
||||
row({ isCluster: true, name: ' my-cluster ', attachedConnections: [agent] }),
|
||||
]);
|
||||
|
||||
// The cluster arm returns row.name verbatim (no .trim()), unlike the
|
||||
// non-cluster return arm. See GLM_REPORT for the suspected inconsistency.
|
||||
expect(targets[0]?.contextLabel).toBe(' my-cluster ');
|
||||
});
|
||||
|
||||
it('falls through the cluster arm when the cluster name trims to empty', () => {
|
||||
const agent = staleAgent({ id: 'agent:c2' });
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets([
|
||||
row({ isCluster: true, name: ' ', attachedConnections: [agent] }),
|
||||
]);
|
||||
|
||||
// isCluster true but name.trim() falsy -> cluster condition fails; ownerType
|
||||
// pve (not agent); row.name.trim() falsy; falls back to connection.name.
|
||||
expect(targets[0]?.contextLabel).toBe('homelab');
|
||||
});
|
||||
|
||||
it("returns 'Machine' for an agent-type row", () => {
|
||||
const agent = staleAgent({ id: 'agent:c3' });
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets([
|
||||
row({ ownerType: 'agent', isAgent: true, name: 'whatever', attachedConnections: [agent] }),
|
||||
]);
|
||||
|
||||
expect(targets[0]?.contextLabel).toBe('Machine');
|
||||
});
|
||||
|
||||
it('returns the trimmed row name for a non-cluster, non-agent row with a name', () => {
|
||||
const agent = staleAgent({ id: 'agent:c4' });
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets([
|
||||
row({ ownerType: 'docker', name: ' docker-host ', attachedConnections: [agent] }),
|
||||
]);
|
||||
|
||||
expect(targets[0]?.contextLabel).toBe('docker-host');
|
||||
});
|
||||
|
||||
it('falls back to connection.name when the row name is blank', () => {
|
||||
const primary = connection({ name: 'fallback-name' });
|
||||
const agent = staleAgent({ id: 'agent:c5' });
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets([
|
||||
row({ ownerType: 'docker', name: '', connection: primary, attachedConnections: [agent] }),
|
||||
]);
|
||||
|
||||
expect(targets[0]?.contextLabel).toBe('fallback-name');
|
||||
});
|
||||
|
||||
it('falls back to ownerType when both row name and connection name are blank', () => {
|
||||
const primary = connection({ name: '' });
|
||||
const agent = staleAgent({ id: 'agent:c6' });
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets([
|
||||
row({ ownerType: 'docker', name: '', connection: primary, attachedConnections: [agent] }),
|
||||
]);
|
||||
|
||||
expect(targets[0]?.contextLabel).toBe('docker');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- expectedVersionFor -----------------------------------------------------
|
||||
// Reachable via pushTarget -> expectedVersion. Branches: every operand of the
|
||||
// short-circuit || chain (expectedAgentVersion, formatAgentVersionDisplay,
|
||||
// undefined).
|
||||
|
||||
describe('expectedVersionFor (via target.expectedVersion)', () => {
|
||||
it('returns the trimmed expectedAgentVersion when the connection carries one', () => {
|
||||
const agent = staleAgent({
|
||||
id: 'agent:e1',
|
||||
expectedAgentVersion: ' 6.0.1 ',
|
||||
agentUpdateAvailable: true,
|
||||
});
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets([row({ attachedConnections: [agent] })]);
|
||||
|
||||
expect(targets[0]?.expectedVersion).toBe('6.0.1');
|
||||
});
|
||||
|
||||
it('formats targetVersion when expectedAgentVersion is absent but targetVersion parses', () => {
|
||||
const agent = staleAgent({
|
||||
id: 'agent:e2',
|
||||
expectedAgentVersion: undefined,
|
||||
agentVersion: '6.0.0-rc.5',
|
||||
agentUpdateAvailable: false,
|
||||
});
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets(
|
||||
[row({ attachedConnections: [agent] })],
|
||||
'6.0.0-rc.6',
|
||||
);
|
||||
|
||||
// connectionNeedsUpdate compares 6.0.0-rc.5 < 6.0.0-rc.6 -> true;
|
||||
// formatAgentVersionDisplay normalises to a leading 'v'.
|
||||
expect(targets[0]?.expectedVersion).toBe('v6.0.0-rc.6');
|
||||
});
|
||||
|
||||
it('returns undefined when neither expectedAgentVersion nor a targetVersion is present', () => {
|
||||
const agent = staleAgent({
|
||||
id: 'agent:e3',
|
||||
expectedAgentVersion: undefined,
|
||||
agentUpdateAvailable: true,
|
||||
});
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets([row({ attachedConnections: [agent] })]);
|
||||
|
||||
expect(targets[0]?.expectedVersion).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when expectedAgentVersion is absent and targetVersion is unparseable', () => {
|
||||
const agent = staleAgent({
|
||||
id: 'agent:e4',
|
||||
expectedAgentVersion: undefined,
|
||||
agentUpdateAvailable: true,
|
||||
});
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets(
|
||||
[row({ attachedConnections: [agent] })],
|
||||
'not-a-version',
|
||||
);
|
||||
|
||||
// agentUpdateAvailable true keeps the agent eligible; formatAgentVersionDisplay
|
||||
// ('not-a-version') returns '' so the final || undefined operand wins.
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]?.expectedVersion).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- pushTarget -------------------------------------------------------------
|
||||
// Branches: the !connection guard, the type!=='agent' guard, the
|
||||
// !connectionNeedsUpdate guard, the has(key) dedupe guard, and the happy-path
|
||||
// set.
|
||||
|
||||
describe('pushTarget (guard + dedupe branches)', () => {
|
||||
it('skips a member whose agentConnection is undefined (the !connection guard)', () => {
|
||||
const targets = collectInfrastructureAgentUpdateTargets([
|
||||
row({ members: [member({ agentConnection: undefined })] }),
|
||||
]);
|
||||
|
||||
expect(targets).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a non-agent primary connection (the connection.type !== "agent" guard)', () => {
|
||||
// Default row.connection is the pve primary; no attached/members agents.
|
||||
const targets = collectInfrastructureAgentUpdateTargets([row()]);
|
||||
|
||||
expect(targets).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a current agent whose version equals the target (the !connectionNeedsUpdate guard)', () => {
|
||||
const current = staleAgent({
|
||||
id: 'agent:current',
|
||||
agentVersion: '6.0.0',
|
||||
expectedAgentVersion: undefined,
|
||||
agentUpdateAvailable: false,
|
||||
});
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets(
|
||||
[row({ attachedConnections: [current] })],
|
||||
'v6.0.0',
|
||||
);
|
||||
|
||||
// compareAgentVersions('6.0.0', 'v6.0.0') === 0 -> not < 0 -> needsUpdate
|
||||
// false -> pushTarget returns early.
|
||||
expect(targets).toEqual([]);
|
||||
});
|
||||
|
||||
it('dedupes an agent that appears in both attachedConnections and a member (the has(key) guard)', () => {
|
||||
const shared = staleAgent({
|
||||
id: 'agent:shared',
|
||||
name: 'shared',
|
||||
address: 'shared',
|
||||
agentIdentity: { hostname: 'shared', platform: 'linux' },
|
||||
});
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets([
|
||||
row({
|
||||
attachedConnections: [shared],
|
||||
members: [member({ agentConnection: shared })],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]?.key).toBe('agent:shared');
|
||||
});
|
||||
|
||||
it('pushes when the primary connection itself is a stale agent (happy path via row.connection)', () => {
|
||||
const agent = staleAgent({
|
||||
id: 'agent:primary-agent',
|
||||
name: 'primary-agent',
|
||||
address: 'primary-agent',
|
||||
agentIdentity: { hostname: 'primary-agent', platform: 'linux' },
|
||||
});
|
||||
|
||||
const targets = collectInfrastructureAgentUpdateTargets([
|
||||
row({ ownerType: 'agent', isAgent: true, connection: agent }),
|
||||
]);
|
||||
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0]?.key).toBe('agent:primary-agent');
|
||||
});
|
||||
});
|
||||
+609
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* Branch-coverage tests for the exported helpers in reportingSchedulesModel.
|
||||
* Each block targets a specific function and drives both arms of every
|
||||
* conditional / optional-chain / nullish-coalescing branch that is reachable
|
||||
* from the public surface.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SelectedResource } from '@/components/Settings/ResourcePicker';
|
||||
import {
|
||||
DEFAULT_REPORT_SCHEDULE_FORM,
|
||||
buildReportSchedulePayload,
|
||||
formatReportScheduleTime,
|
||||
normalizeReportSchedule,
|
||||
parseCommaList,
|
||||
parseReportSchedulesResponse,
|
||||
reportScheduleCadenceLabel,
|
||||
reportScheduleDeliveryLabel,
|
||||
reportScheduleLastRunLabel,
|
||||
reportScheduleScopeLabel,
|
||||
scheduleToForm,
|
||||
scheduleToSelectedResources,
|
||||
type ReportSchedule,
|
||||
type ReportScheduleFormState,
|
||||
} from '../reportingSchedulesModel';
|
||||
|
||||
// ---- Fixtures ---------------------------------------------------------------
|
||||
|
||||
const makeSchedule = (overrides: Partial<ReportSchedule> = {}): ReportSchedule => ({
|
||||
id: 'sched-1',
|
||||
name: 'Nightly ops digest',
|
||||
enabled: true,
|
||||
cadence: {
|
||||
type: 'monthly',
|
||||
day_of_month: 15,
|
||||
weekday: 'monday',
|
||||
time: '09:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
scope: {
|
||||
resources: [],
|
||||
tags: [],
|
||||
},
|
||||
format: 'pdf',
|
||||
delivery: {
|
||||
method: 'email',
|
||||
to: [],
|
||||
attach: true,
|
||||
save_to_disk: true,
|
||||
},
|
||||
retention_count: 12,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeForm = (overrides: Partial<ReportScheduleFormState> = {}): ReportScheduleFormState => ({
|
||||
...DEFAULT_REPORT_SCHEDULE_FORM(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeResource = (overrides: Partial<SelectedResource> = {}): SelectedResource => ({
|
||||
id: 'res-1',
|
||||
type: 'vm',
|
||||
name: 'web-01',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ---- parseReportSchedulesResponse ------------------------------------------
|
||||
|
||||
describe('parseReportSchedulesResponse', () => {
|
||||
it('returns an empty array for a null input (!value arm)', () => {
|
||||
expect(parseReportSchedulesResponse(null)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array for an undefined input (!value arm)', () => {
|
||||
expect(parseReportSchedulesResponse(undefined)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array for a non-object primitive (typeof !== "object" arm)', () => {
|
||||
expect(parseReportSchedulesResponse('not-an-object')).toStrictEqual([]);
|
||||
expect(parseReportSchedulesResponse(42)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when the value is an object without a schedules field', () => {
|
||||
expect(parseReportSchedulesResponse({ foo: 'bar' })).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when schedules is present but not an array', () => {
|
||||
expect(parseReportSchedulesResponse({ schedules: 'nope' })).toStrictEqual([]);
|
||||
expect(parseReportSchedulesResponse({ schedules: null })).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('maps each entry through normalizeReportSchedule (Array.isArray truthy arm)', () => {
|
||||
const result = parseReportSchedulesResponse({
|
||||
schedules: [
|
||||
makeSchedule({ id: 'a', enabled: false }),
|
||||
makeSchedule({ id: 'b', format: 'csv' }),
|
||||
],
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]?.id).toBe('a');
|
||||
expect(result[0]?.enabled).toBe(false);
|
||||
expect(result[1]?.format).toBe('csv');
|
||||
});
|
||||
|
||||
it('normalizes a malformed schedule carried inside the response', () => {
|
||||
const result = parseReportSchedulesResponse({
|
||||
schedules: [
|
||||
{
|
||||
id: 'c',
|
||||
name: 'sparse',
|
||||
enabled: true,
|
||||
scope: {},
|
||||
format: 'pdf',
|
||||
delivery: { method: 'email', attach: true, save_to_disk: true },
|
||||
} as unknown as ReportSchedule,
|
||||
],
|
||||
});
|
||||
expect(result[0]?.cadence).toStrictEqual({
|
||||
type: 'monthly',
|
||||
day_of_month: 1,
|
||||
weekday: 'monday',
|
||||
time: '09:00',
|
||||
timezone: 'UTC',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- normalizeReportSchedule -----------------------------------------------
|
||||
|
||||
describe('normalizeReportSchedule', () => {
|
||||
it('passes through fully-populated truthy values unchanged on the happy path', () => {
|
||||
const schedule = makeSchedule({
|
||||
enabled: true,
|
||||
cadence: { type: 'weekly', day_of_month: 20, weekday: 'friday', time: '17:30', timezone: 'Europe/Paris' },
|
||||
scope: { resources: [{ resourceType: 'vm', resourceId: 'v1', name: 'web' }], tags: ['tier1'] },
|
||||
format: 'csv',
|
||||
delivery: { method: 'disk', to: ['ops@x'], attach: true, save_to_disk: true },
|
||||
retention_count: 7,
|
||||
});
|
||||
expect(normalizeReportSchedule(schedule)).toMatchObject({
|
||||
enabled: true,
|
||||
cadence: { type: 'weekly', day_of_month: 20, weekday: 'friday', time: '17:30', timezone: 'Europe/Paris' },
|
||||
format: 'csv',
|
||||
delivery: { method: 'disk', to: ['ops@x'], attach: true, save_to_disk: true },
|
||||
retention_count: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('coerces enabled to false only when explicitly false, and to true otherwise', () => {
|
||||
expect(normalizeReportSchedule(makeSchedule({ enabled: false })).enabled).toBe(false);
|
||||
// The `!== false` arm: any non-false value becomes true.
|
||||
expect(
|
||||
normalizeReportSchedule(makeSchedule({ enabled: 'true' as unknown as boolean })).enabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps day_of_month of 0 (nullish coalescing does not default on falsy numbers)', () => {
|
||||
expect(
|
||||
normalizeReportSchedule(makeSchedule({ cadence: { type: 'monthly', day_of_month: 0, time: '09:00', timezone: 'UTC' } })).cadence
|
||||
.day_of_month,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('applies every cadence default when cadence is entirely missing', () => {
|
||||
const schedule = {
|
||||
id: 'x',
|
||||
name: 'x',
|
||||
enabled: true,
|
||||
scope: {},
|
||||
format: 'pdf',
|
||||
delivery: { method: 'email', attach: true, save_to_disk: true },
|
||||
} as unknown as ReportSchedule;
|
||||
expect(normalizeReportSchedule(schedule).cadence).toStrictEqual({
|
||||
type: 'monthly',
|
||||
day_of_month: 1,
|
||||
weekday: 'monday',
|
||||
time: '09:00',
|
||||
timezone: 'UTC',
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies an unknown cadence type as monthly', () => {
|
||||
const schedule = makeSchedule({
|
||||
cadence: { type: 'daily' as unknown as 'monthly', day_of_month: 3, time: '08:00', timezone: 'UTC' },
|
||||
});
|
||||
expect(normalizeReportSchedule(schedule).cadence.type).toBe('monthly');
|
||||
});
|
||||
|
||||
it('defaults weekday/time/timezone via || when they are empty strings', () => {
|
||||
const schedule = makeSchedule({
|
||||
cadence: { type: 'monthly', day_of_month: 1, weekday: '', time: '', timezone: '', day_of_month_ignored: true } as unknown as ReportSchedule['cadence'],
|
||||
});
|
||||
expect(normalizeReportSchedule(schedule).cadence).toMatchObject({
|
||||
weekday: 'monday',
|
||||
time: '09:00',
|
||||
timezone: 'UTC',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps scope arrays when they are arrays, and replaces them when not', () => {
|
||||
const kept = normalizeReportSchedule(
|
||||
makeSchedule({ scope: { resources: [{ resourceType: 'vm', resourceId: 'v1' }], tags: ['a'] } }),
|
||||
);
|
||||
expect(kept.scope.resources).toHaveLength(1);
|
||||
expect(kept.scope.tags).toEqual(['a']);
|
||||
|
||||
const replaced = normalizeReportSchedule(
|
||||
makeSchedule({ scope: { resources: null as unknown as [], tags: 'nope' as unknown as string[] } }),
|
||||
);
|
||||
expect(replaced.scope.resources).toStrictEqual([]);
|
||||
expect(replaced.scope.tags).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('classifies format as csv only for csv, pdf otherwise', () => {
|
||||
expect(normalizeReportSchedule(makeSchedule({ format: 'csv' })).format).toBe('csv');
|
||||
expect(
|
||||
normalizeReportSchedule(makeSchedule({ format: 'xlsx' as unknown as 'pdf' })).format,
|
||||
).toBe('pdf');
|
||||
});
|
||||
|
||||
it('classifies delivery method as disk only for disk, email otherwise', () => {
|
||||
expect(normalizeReportSchedule(makeSchedule({ delivery: { method: 'disk', attach: true, save_to_disk: true } })).delivery.method).toBe(
|
||||
'disk',
|
||||
);
|
||||
expect(
|
||||
normalizeReportSchedule(
|
||||
makeSchedule({ delivery: { method: 'carrier-pigeon' as unknown as 'email', attach: true, save_to_disk: true } }),
|
||||
).delivery.method,
|
||||
).toBe('email');
|
||||
});
|
||||
|
||||
it('defaults delivery.to to [] when missing and keeps it when an array', () => {
|
||||
expect(
|
||||
normalizeReportSchedule(
|
||||
makeSchedule({ delivery: { method: 'email', to: undefined as unknown as string[], attach: true, save_to_disk: true } }),
|
||||
).delivery.to,
|
||||
).toStrictEqual([]);
|
||||
expect(
|
||||
normalizeReportSchedule(makeSchedule({ delivery: { method: 'email', to: ['a@b'], attach: true, save_to_disk: true } })).delivery.to,
|
||||
).toEqual(['a@b']);
|
||||
});
|
||||
|
||||
it('coerces attach and save_to_disk to false only when explicitly false', () => {
|
||||
const allFalse = normalizeReportSchedule(
|
||||
makeSchedule({ delivery: { method: 'email', attach: false, save_to_disk: false } }),
|
||||
);
|
||||
expect(allFalse.delivery.attach).toBe(false);
|
||||
expect(allFalse.delivery.save_to_disk).toBe(false);
|
||||
|
||||
const defaults = normalizeReportSchedule(
|
||||
makeSchedule({ delivery: { method: 'email', attach: undefined as unknown as boolean, save_to_disk: undefined as unknown as boolean } }),
|
||||
);
|
||||
expect(defaults.delivery.attach).toBe(true);
|
||||
expect(defaults.delivery.save_to_disk).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults retention_count to 12 when missing', () => {
|
||||
const schedule = { ...makeSchedule(), retention_count: undefined } as unknown as ReportSchedule;
|
||||
delete (schedule as Partial<ReportSchedule>).retention_count;
|
||||
expect(normalizeReportSchedule(schedule).retention_count).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- scheduleToForm ---------------------------------------------------------
|
||||
//
|
||||
// scheduleToForm consumes the *normalized* schedule, whose fields are always
|
||||
// populated by normalizeReportSchedule. The internal `?? 1` / `|| 'monday'` /
|
||||
// `?? []` defensives therefore never fire from the public surface; here we
|
||||
// cover the reachable mapping logic for both cadence arms.
|
||||
|
||||
describe('scheduleToForm', () => {
|
||||
it('maps a populated monthly schedule into the form shape', () => {
|
||||
const schedule = makeSchedule({
|
||||
cadence: { type: 'monthly', day_of_month: 27, time: '23:00', timezone: 'UTC' },
|
||||
delivery: { method: 'email', to: ['a@b.com', 'c@d.com'], attach: true, save_to_disk: true },
|
||||
scope: { resources: [], tags: ['tier1', 'tier2'] },
|
||||
retention_count: 5,
|
||||
});
|
||||
expect(scheduleToForm(schedule)).toStrictEqual({
|
||||
id: 'sched-1',
|
||||
name: 'Nightly ops digest',
|
||||
enabled: true,
|
||||
cadenceType: 'monthly',
|
||||
dayOfMonth: 27,
|
||||
weekday: 'monday',
|
||||
time: '23:00',
|
||||
timezone: 'UTC',
|
||||
format: 'pdf',
|
||||
deliveryMethod: 'email',
|
||||
recipients: 'a@b.com, c@d.com',
|
||||
attach: true,
|
||||
saveToDisk: true,
|
||||
tagFilter: 'tier1, tier2',
|
||||
retentionCount: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a weekly schedule and surfaces the weekday field', () => {
|
||||
const schedule = makeSchedule({
|
||||
cadence: { type: 'weekly', weekday: 'friday', time: '17:30', timezone: 'Europe/Paris' },
|
||||
});
|
||||
const form = scheduleToForm(schedule);
|
||||
expect(form.cadenceType).toBe('weekly');
|
||||
expect(form.weekday).toBe('friday');
|
||||
expect(form.dayOfMonth).toBe(1); // monthly default carried through normalization
|
||||
expect(form.timezone).toBe('Europe/Paris');
|
||||
});
|
||||
|
||||
it('joins recipients and tags into empty strings when both lists are empty', () => {
|
||||
const form = scheduleToForm(makeSchedule());
|
||||
expect(form.recipients).toBe('');
|
||||
expect(form.tagFilter).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- scheduleToSelectedResources -------------------------------------------
|
||||
|
||||
describe('scheduleToSelectedResources', () => {
|
||||
it('returns an empty array when scope.resources is undefined (?? [] arm)', () => {
|
||||
const schedule = makeSchedule({ scope: {} });
|
||||
expect(scheduleToSelectedResources(schedule)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('maps each resource, using the explicit name when present', () => {
|
||||
const schedule = makeSchedule({
|
||||
scope: {
|
||||
resources: [
|
||||
{ resourceType: 'vm', resourceId: 'v1', name: 'web-01' },
|
||||
{ resourceType: 'agent', resourceId: 'a1', name: 'host-01' },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(scheduleToSelectedResources(schedule)).toStrictEqual([
|
||||
{ id: 'v1', type: 'vm', name: 'web-01' },
|
||||
{ id: 'a1', type: 'agent', name: 'host-01' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to resourceId when name is missing (|| arm)', () => {
|
||||
const schedule = makeSchedule({
|
||||
scope: { resources: [{ resourceType: 'vm', resourceId: 'v9' }] },
|
||||
});
|
||||
expect(scheduleToSelectedResources(schedule)).toStrictEqual([
|
||||
{ id: 'v9', type: 'vm', name: 'v9' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to resourceId when name is an empty string', () => {
|
||||
const schedule = makeSchedule({
|
||||
scope: { resources: [{ resourceType: 'vm', resourceId: 'v9', name: '' }] },
|
||||
});
|
||||
expect(scheduleToSelectedResources(schedule)[0]?.name).toBe('v9');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildReportSchedulePayload --------------------------------------------
|
||||
|
||||
describe('buildReportSchedulePayload', () => {
|
||||
it('emits day_of_month and omits weekday for a monthly form', () => {
|
||||
const payload = buildReportSchedulePayload(makeForm({ cadenceType: 'monthly', dayOfMonth: 12 }), [
|
||||
makeResource(),
|
||||
]);
|
||||
expect(payload.cadence).toMatchObject({ type: 'monthly', day_of_month: 12, weekday: undefined });
|
||||
});
|
||||
|
||||
it('emits weekday and omits day_of_month for a weekly form', () => {
|
||||
const payload = buildReportSchedulePayload(makeForm({ cadenceType: 'weekly', weekday: 'wednesday' }), []);
|
||||
expect(payload.cadence).toMatchObject({ type: 'weekly', weekday: 'wednesday', day_of_month: undefined });
|
||||
});
|
||||
|
||||
it('falls back to UTC when the timezone trims to empty', () => {
|
||||
const payload = buildReportSchedulePayload(makeForm({ timezone: ' ' }), []);
|
||||
expect(payload.cadence.timezone).toBe('UTC');
|
||||
});
|
||||
|
||||
it('trims the schedule name and maps resources into the scope', () => {
|
||||
const payload = buildReportSchedulePayload(
|
||||
makeForm({ name: ' trimmed ' }),
|
||||
[makeResource({ id: 'r1', type: 'agent', name: 'host' })],
|
||||
);
|
||||
expect(payload.name).toBe('trimmed');
|
||||
expect(payload.scope.resources).toStrictEqual([
|
||||
{ resourceType: 'agent', resourceId: 'r1', name: 'host' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses recipients and tag filters via parseCommaList', () => {
|
||||
const payload = buildReportSchedulePayload(
|
||||
makeForm({ recipients: 'a@b.com, c@d.com, a@b.com', tagFilter: 'tier1,tier2' }),
|
||||
[],
|
||||
);
|
||||
expect(payload.delivery.to).toEqual(['a@b.com', 'c@d.com']);
|
||||
expect(payload.scope.tags).toEqual(['tier1', 'tier2']);
|
||||
});
|
||||
|
||||
it('forwards format, delivery flags, and retention_count verbatim', () => {
|
||||
const payload = buildReportSchedulePayload(
|
||||
makeForm({ format: 'csv', deliveryMethod: 'disk', attach: false, saveToDisk: false, retentionCount: 3 }),
|
||||
[],
|
||||
);
|
||||
expect(payload.format).toBe('csv');
|
||||
expect(payload.delivery).toStrictEqual({ method: 'disk', to: [], attach: false, save_to_disk: false });
|
||||
expect(payload.retention_count).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- parseCommaList ---------------------------------------------------------
|
||||
|
||||
describe('parseCommaList', () => {
|
||||
it('returns an empty array for an empty string', () => {
|
||||
expect(parseCommaList('')).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('returns a single trimmed item', () => {
|
||||
expect(parseCommaList(' alpha ')).toStrictEqual(['alpha']);
|
||||
});
|
||||
|
||||
it('splits, trims, and preserves order for multiple items', () => {
|
||||
expect(parseCommaList('a, b ,c')).toStrictEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('drops empty entries produced by trailing/leading/double commas', () => {
|
||||
expect(parseCommaList(',a,,b,')).toStrictEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('dedupes case-insensitively while keeping the first-seen casing', () => {
|
||||
expect(parseCommaList('Alpha, ALPHA, alpha, Beta, beta')).toStrictEqual(['Alpha', 'Beta']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- reportScheduleCadenceLabel --------------------------------------------
|
||||
|
||||
describe('reportScheduleCadenceLabel', () => {
|
||||
it('labels a monthly schedule with its day-of-month and time', () => {
|
||||
const schedule = makeSchedule({
|
||||
cadence: { type: 'monthly', day_of_month: 15, time: '09:00', timezone: 'UTC' },
|
||||
});
|
||||
expect(reportScheduleCadenceLabel(schedule)).toBe('Monthly on day 15 at 09:00');
|
||||
});
|
||||
|
||||
it('preserves a day_of_month of 0 in the monthly label', () => {
|
||||
const schedule = makeSchedule({
|
||||
cadence: { type: 'monthly', day_of_month: 0, time: '09:00', timezone: 'UTC' },
|
||||
});
|
||||
expect(reportScheduleCadenceLabel(schedule)).toBe('Monthly on day 0 at 09:00');
|
||||
});
|
||||
|
||||
it('labels a weekly schedule with the prettified weekday and time', () => {
|
||||
const schedule = makeSchedule({
|
||||
cadence: { type: 'weekly', weekday: 'friday', time: '17:30', timezone: 'UTC' },
|
||||
});
|
||||
expect(reportScheduleCadenceLabel(schedule)).toBe('Friday at 17:30');
|
||||
});
|
||||
|
||||
it('falls back to the raw weekday string when it is not in WEEKDAY_LABELS', () => {
|
||||
const schedule = makeSchedule({
|
||||
cadence: { type: 'weekly', weekday: 'funday', time: '08:00', timezone: 'UTC' },
|
||||
});
|
||||
expect(reportScheduleCadenceLabel(schedule)).toBe('funday at 08:00');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- reportScheduleScopeLabel ----------------------------------------------
|
||||
|
||||
describe('reportScheduleScopeLabel', () => {
|
||||
it('returns "No scope" when both resources and tags are absent (parts.length falsy arm)', () => {
|
||||
expect(reportScheduleScopeLabel(makeSchedule({ scope: {} }))).toBe('No scope');
|
||||
});
|
||||
|
||||
it('renders the singular form for exactly one resource', () => {
|
||||
expect(
|
||||
reportScheduleScopeLabel(
|
||||
makeSchedule({ scope: { resources: [{ resourceType: 'vm', resourceId: 'v1' }] } }),
|
||||
),
|
||||
).toBe('1 resource');
|
||||
});
|
||||
|
||||
it('renders the plural form for multiple resources', () => {
|
||||
expect(
|
||||
reportScheduleScopeLabel(
|
||||
makeSchedule({
|
||||
scope: {
|
||||
resources: [
|
||||
{ resourceType: 'vm', resourceId: 'v1' },
|
||||
{ resourceType: 'vm', resourceId: 'v2' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('2 resources');
|
||||
});
|
||||
|
||||
it('renders the singular vs plural form for tags', () => {
|
||||
expect(reportScheduleScopeLabel(makeSchedule({ scope: { tags: ['tier1'] } }))).toBe('1 tag');
|
||||
expect(reportScheduleScopeLabel(makeSchedule({ scope: { tags: ['tier1', 'tier2'] } }))).toBe(
|
||||
'2 tags',
|
||||
);
|
||||
});
|
||||
|
||||
it('joins resources and tags with a comma', () => {
|
||||
expect(
|
||||
reportScheduleScopeLabel(
|
||||
makeSchedule({
|
||||
scope: {
|
||||
resources: [{ resourceType: 'vm', resourceId: 'v1' }],
|
||||
tags: ['tier1', 'tier2'],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe('1 resource, 2 tags');
|
||||
});
|
||||
|
||||
it('treats undefined resources as zero (?.length ?? 0 arm)', () => {
|
||||
expect(reportScheduleScopeLabel(makeSchedule({ scope: { tags: ['t1'] } }))).toBe('1 tag');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- reportScheduleDeliveryLabel -------------------------------------------
|
||||
|
||||
describe('reportScheduleDeliveryLabel', () => {
|
||||
it('returns "Save to disk" for the disk method', () => {
|
||||
expect(
|
||||
reportScheduleDeliveryLabel(makeSchedule({ delivery: { method: 'disk', attach: true, save_to_disk: true } })),
|
||||
).toBe('Save to disk');
|
||||
});
|
||||
|
||||
it('renders the singular recipient form for one email recipient', () => {
|
||||
expect(
|
||||
reportScheduleDeliveryLabel(
|
||||
makeSchedule({ delivery: { method: 'email', to: ['ops@x'], attach: true, save_to_disk: true } }),
|
||||
),
|
||||
).toBe('1 email recipient');
|
||||
});
|
||||
|
||||
it('renders the plural recipient form for multiple email recipients', () => {
|
||||
expect(
|
||||
reportScheduleDeliveryLabel(
|
||||
makeSchedule({ delivery: { method: 'email', to: ['a@x', 'b@x'], attach: true, save_to_disk: true } }),
|
||||
),
|
||||
).toBe('2 email recipients');
|
||||
});
|
||||
|
||||
it('falls back to the generic email copy when there are no recipients', () => {
|
||||
expect(
|
||||
reportScheduleDeliveryLabel(
|
||||
makeSchedule({ delivery: { method: 'email', to: [], attach: true, save_to_disk: true } }),
|
||||
),
|
||||
).toBe('Email config recipients');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- reportScheduleLastRunLabel --------------------------------------------
|
||||
|
||||
describe('reportScheduleLastRunLabel', () => {
|
||||
it('returns "Not run yet" when last_run_status is empty (!status arm)', () => {
|
||||
expect(reportScheduleLastRunLabel(makeSchedule({ last_run_status: '' }))).toBe('Not run yet');
|
||||
});
|
||||
|
||||
it('returns "Not run yet" when last_run_status is undefined', () => {
|
||||
const schedule = makeSchedule();
|
||||
delete schedule.last_run_status;
|
||||
expect(reportScheduleLastRunLabel(schedule)).toBe('Not run yet');
|
||||
});
|
||||
|
||||
it('returns "Last run OK" for the ok status', () => {
|
||||
expect(reportScheduleLastRunLabel(makeSchedule({ last_run_status: 'ok' }))).toBe('Last run OK');
|
||||
});
|
||||
|
||||
it('surfaces last_error for a failed run', () => {
|
||||
expect(
|
||||
reportScheduleLastRunLabel(makeSchedule({ last_run_status: 'failed', last_error: 'timeout' })),
|
||||
).toBe('Failed: timeout');
|
||||
});
|
||||
|
||||
it('falls back to the generic failed copy when last_error is missing', () => {
|
||||
const schedule = makeSchedule({ last_run_status: 'failed' });
|
||||
delete schedule.last_error;
|
||||
expect(reportScheduleLastRunLabel(schedule)).toBe('Last run failed');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- formatReportScheduleTime ----------------------------------------------
|
||||
|
||||
describe('formatReportScheduleTime', () => {
|
||||
it('returns an empty string for a falsy value', () => {
|
||||
expect(formatReportScheduleTime(undefined)).toBe('');
|
||||
expect(formatReportScheduleTime('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns an empty string for an unparseable date (NaN arm)', () => {
|
||||
expect(formatReportScheduleTime('not-a-date')).toBe('');
|
||||
});
|
||||
|
||||
it('formats a valid timestamp into a localized, non-empty string', () => {
|
||||
const formatted = formatReportScheduleTime('2026-07-15T10:30:00Z');
|
||||
// The exact locale formatting is environment-dependent, but a real
|
||||
// localized date string always contains digits and is longer than the
|
||||
// empty fallback returned for invalid input.
|
||||
expect(formatted).toMatch(/\d/);
|
||||
expect(formatted.length).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('distinguishes two different dates in the formatted output', () => {
|
||||
const jan = formatReportScheduleTime('2026-01-05T10:30:00Z');
|
||||
const jul = formatReportScheduleTime('2026-07-15T10:30:00Z');
|
||||
expect(jan).not.toBe(jul);
|
||||
expect(jan).not.toBe('');
|
||||
expect(jul).not.toBe('');
|
||||
});
|
||||
});
|
||||
+551
@@ -0,0 +1,551 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SettingsTab } from '../settingsNavigationModel';
|
||||
import {
|
||||
isSettingsNavItemLocked,
|
||||
shouldBlockSettingsRouteItem,
|
||||
shouldHideSettingsNavItem,
|
||||
type SettingsNavVisibilityContext,
|
||||
} from '../settingsNavVisibility';
|
||||
|
||||
// ---- Helpers ---------------------------------------------------------------
|
||||
// Mirrors the sibling `settingsNavigation.integration.test.tsx` hasFeatures
|
||||
// factory and builds a fully-typed SettingsNavVisibilityContext with sensible
|
||||
// defaults so each case only overrides the branch-relevant field.
|
||||
|
||||
const hasFeatures =
|
||||
(features: string[]) =>
|
||||
(feature: string): boolean =>
|
||||
features.includes(feature);
|
||||
|
||||
const createContext = (
|
||||
overrides: Partial<SettingsNavVisibilityContext> = {},
|
||||
): SettingsNavVisibilityContext => ({
|
||||
hasFeature: hasFeatures([]),
|
||||
runtimeCapabilitiesLoaded: () => true,
|
||||
hostedModeEnabled: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// A SettingsTab value that is NOT present in the nav catalog, used to drive
|
||||
// the defensive `!item` early-returns via a controlled cast.
|
||||
const UNKNOWN_TAB = 'nonexistent-tab' as unknown as SettingsTab;
|
||||
|
||||
// ---- shouldHideSettingsNavItem --------------------------------------------
|
||||
|
||||
describe('shouldHideSettingsNavItem', () => {
|
||||
it('returns false for an unknown tab (defensive !item branch)', () => {
|
||||
expect(shouldHideSettingsNavItem(UNKNOWN_TAB, createContext())).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a tab whose item has no visibility gates', () => {
|
||||
expect(shouldHideSettingsNavItem('system-general', createContext())).toBe(false);
|
||||
});
|
||||
|
||||
describe('hostedOnly gate (lines 53-55)', () => {
|
||||
it('hides a hostedOnly tab when hosted mode is disabled', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'organization-billing-admin',
|
||||
createContext({
|
||||
hasFeature: hasFeatures(['multi_tenant']),
|
||||
hostedModeEnabled: false,
|
||||
settingsCapabilities: { billingAdmin: true },
|
||||
settingsCapabilitiesResolved: true,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('skips the hostedOnly gate when hosted mode is enabled', () => {
|
||||
// hostedOnly + hostedModeEnabled true -> gate skipped; multi_tenant present
|
||||
// and capability granted -> no other gate fires -> not hidden.
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'organization-billing-admin',
|
||||
createContext({
|
||||
hasFeature: hasFeatures(['multi_tenant']),
|
||||
hostedModeEnabled: true,
|
||||
settingsCapabilities: { billingAdmin: true },
|
||||
settingsCapabilitiesResolved: true,
|
||||
presentationPolicyResolved: true,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hideWhenOrganizationHidden gate (lines 57-65)', () => {
|
||||
it('hides when presentation policy is unresolved', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'organization-overview',
|
||||
createContext({
|
||||
hasFeature: hasFeatures(['multi_tenant']),
|
||||
presentationPolicyResolved: false,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('hides when organizations are hidden by presentation policy', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'organization-overview',
|
||||
createContext({
|
||||
hasFeature: hasFeatures(['multi_tenant']),
|
||||
presentationPolicyResolved: true,
|
||||
presentationPolicyHidesOrganizations: true,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('falls through when organizations are resolved and visible', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'organization-overview',
|
||||
createContext({
|
||||
hasFeature: hasFeatures(['multi_tenant']),
|
||||
presentationPolicyResolved: true,
|
||||
presentationPolicyHidesOrganizations: false,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hideWhenCommercialHidden gate (lines 67-75)', () => {
|
||||
it('hides a commercial-hidden tab when policy is unresolved', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'system-billing',
|
||||
createContext({ presentationPolicyResolved: false }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('hides a commercial-hidden tab when commercial is hidden', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'system-billing',
|
||||
createContext({
|
||||
presentationPolicyResolved: true,
|
||||
presentationPolicyHidesCommercial: true,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('falls through when commercial is resolved and visible', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'system-billing',
|
||||
createContext({
|
||||
presentationPolicyResolved: true,
|
||||
presentationPolicyHidesCommercial: false,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hideWhenDemoMode gate (lines 77-85)', () => {
|
||||
it('hides a demo-hidden tab when policy is unresolved', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'support-diagnostics',
|
||||
createContext({ presentationPolicyResolved: false }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('hides a demo-hidden tab in demo mode', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'support-diagnostics',
|
||||
createContext({
|
||||
presentationPolicyResolved: true,
|
||||
presentationPolicyIsDemoMode: true,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('falls through when demo mode is resolved off', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'support-diagnostics',
|
||||
createContext({
|
||||
presentationPolicyResolved: true,
|
||||
presentationPolicyIsDemoMode: false,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requiredCapability gate (lines 97-103)', () => {
|
||||
it('hides when resolved and the required capability is false', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'api',
|
||||
createContext({
|
||||
settingsCapabilitiesResolved: true,
|
||||
settingsCapabilities: { apiAccessRead: false },
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not hide when capability state is unresolved', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'api',
|
||||
createContext({ settingsCapabilitiesResolved: false, settingsCapabilities: null }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not hide when the required capability is granted', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'api',
|
||||
createContext({
|
||||
settingsCapabilitiesResolved: true,
|
||||
settingsCapabilities: { apiAccessRead: true },
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('hides when resolved but settingsCapabilities is undefined (optional-chain -> undefined)', () => {
|
||||
// context.settingsCapabilities?.[cap] -> undefined -> undefined !== true -> hidden.
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'api',
|
||||
createContext({
|
||||
settingsCapabilitiesResolved: true,
|
||||
settingsCapabilities: undefined,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hideWhenUnavailable gate -> hasRequiredFeatures / missingFeaturesArePaidRuntimeBlocked', () => {
|
||||
it('does not hide when all required features are present (hasRequiredFeatures every()->true)', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'organization-overview',
|
||||
createContext({
|
||||
hasFeature: hasFeatures(['multi_tenant']),
|
||||
presentationPolicyResolved: true,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('hides when required features are missing and not runtime-blocked (missing-blocked -> false)', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'organization-overview',
|
||||
createContext({
|
||||
hasFeature: hasFeatures([]),
|
||||
presentationPolicyResolved: true,
|
||||
isRuntimeCapabilityBlocked: () => false,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps visible when every missing feature is paid-runtime-blocked (missing-blocked -> true)', () => {
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'organization-overview',
|
||||
createContext({
|
||||
hasFeature: hasFeatures([]),
|
||||
presentationPolicyResolved: true,
|
||||
isRuntimeCapabilityBlocked: (feature, reason) =>
|
||||
feature === 'multi_tenant' && reason === 'paid_runtime_required',
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('hides when isRuntimeCapabilityBlocked is omitted (optional-chain ?. yields undefined -> every()->false)', () => {
|
||||
// missingFeaturesArePaidRuntimeBlocked reads context.isRuntimeCapabilityBlocked?.(...);
|
||||
// when undefined, every() returns false -> hide block falls through to `return true`.
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'organization-overview',
|
||||
createContext({
|
||||
hasFeature: hasFeatures([]),
|
||||
presentationPolicyResolved: true,
|
||||
// isRuntimeCapabilityBlocked deliberately omitted
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('hides when the blocker accepts the feature but a different reason (reason-mismatch arm)', () => {
|
||||
// The source always passes 'paid_runtime_required'; a callback that only
|
||||
// blocks on a different reason returns false -> every()->false -> hidden.
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'organization-overview',
|
||||
createContext({
|
||||
hasFeature: hasFeatures([]),
|
||||
presentationPolicyResolved: true,
|
||||
isRuntimeCapabilityBlocked: (_feature, reason) => reason === 'some_other_reason',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- shouldBlockSettingsRouteItem ------------------------------------------
|
||||
|
||||
describe('shouldBlockSettingsRouteItem', () => {
|
||||
it('returns false for an unknown tab (defensive !item branch)', () => {
|
||||
expect(shouldBlockSettingsRouteItem(UNKNOWN_TAB, createContext())).toBe(false);
|
||||
});
|
||||
|
||||
it('mirrors the hostedOnly behavior for routing', () => {
|
||||
expect(
|
||||
shouldBlockSettingsRouteItem(
|
||||
'organization-billing-admin',
|
||||
createContext({
|
||||
hasFeature: hasFeatures(['multi_tenant']),
|
||||
hostedModeEnabled: false,
|
||||
settingsCapabilities: { billingAdmin: true },
|
||||
settingsCapabilitiesResolved: true,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks a demo-hidden route while demo policy is unresolved', () => {
|
||||
expect(
|
||||
shouldBlockSettingsRouteItem(
|
||||
'support-diagnostics',
|
||||
createContext({ presentationPolicyResolved: false }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks a commercial-hidden route when commercial is hidden', () => {
|
||||
expect(
|
||||
shouldBlockSettingsRouteItem(
|
||||
'system-billing',
|
||||
createContext({
|
||||
presentationPolicyResolved: true,
|
||||
presentationPolicyHidesCommercial: true,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not block a capability-gated route while capability state is unresolved', () => {
|
||||
expect(
|
||||
shouldBlockSettingsRouteItem(
|
||||
'api',
|
||||
createContext({ settingsCapabilitiesResolved: false, settingsCapabilities: null }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe('PANEL_OWNED_FEATURE_GATE_TABS bypass (lines 174-183)', () => {
|
||||
it.each([
|
||||
'system-relay',
|
||||
'support-reporting',
|
||||
'security-roles',
|
||||
'security-users',
|
||||
'security-audit',
|
||||
'security-webhooks',
|
||||
] as const)(
|
||||
'does NOT block panel-owned tab %s even when its features are missing',
|
||||
(tab) => {
|
||||
expect(
|
||||
shouldBlockSettingsRouteItem(
|
||||
tab,
|
||||
createContext({
|
||||
hasFeature: hasFeatures([]),
|
||||
presentationPolicyResolved: true,
|
||||
settingsCapabilitiesResolved: false,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('non-panel-owned hideWhenUnavailable tabs are feature-gated for routing', () => {
|
||||
// organization-overview is hideWhenUnavailable + NOT in PANEL_OWNED_FEATURE_GATE_TABS,
|
||||
// so it is the canonical tab to exercise the route-level feature gate.
|
||||
it('blocks when required features are missing and not runtime-blocked', () => {
|
||||
const ctx = createContext({
|
||||
hasFeature: hasFeatures([]),
|
||||
presentationPolicyResolved: true,
|
||||
isRuntimeCapabilityBlocked: () => false,
|
||||
});
|
||||
expect(shouldBlockSettingsRouteItem('organization-overview', ctx)).toBe(true);
|
||||
// Contrast: the same context hides the item from the nav.
|
||||
expect(shouldHideSettingsNavItem('organization-overview', ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not block when required features are present', () => {
|
||||
expect(
|
||||
shouldBlockSettingsRouteItem(
|
||||
'organization-overview',
|
||||
createContext({
|
||||
hasFeature: hasFeatures(['multi_tenant']),
|
||||
presentationPolicyResolved: true,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block when missing features are paid-runtime-blocked', () => {
|
||||
expect(
|
||||
shouldBlockSettingsRouteItem(
|
||||
'organization-overview',
|
||||
createContext({
|
||||
hasFeature: hasFeatures([]),
|
||||
presentationPolicyResolved: true,
|
||||
isRuntimeCapabilityBlocked: () => true,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block when isRuntimeCapabilityBlocked is omitted', () => {
|
||||
// ?. yields undefined -> every()->false -> missing-blocked returns false
|
||||
// -> but the feature-gate block then returns true... wait: missing-blocked
|
||||
// false means we DO block. So this asserts the omitted-callback path leads
|
||||
// to a block (every()->false).
|
||||
expect(
|
||||
shouldBlockSettingsRouteItem(
|
||||
'organization-overview',
|
||||
createContext({
|
||||
hasFeature: hasFeatures([]),
|
||||
presentationPolicyResolved: true,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- isSettingsNavItemLocked -----------------------------------------------
|
||||
|
||||
describe('isSettingsNavItemLocked', () => {
|
||||
it('returns false for an unknown tab (defensive !item branch)', () => {
|
||||
expect(isSettingsNavItemLocked(UNKNOWN_TAB, createContext())).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a tab with hideWhenUnavailable (early `item.hideWhenUnavailable` branch)', () => {
|
||||
// organization-overview, security-roles, system-relay, support-reporting all
|
||||
// carry hideWhenUnavailable -> short-circuit before isTabLocked is consulted.
|
||||
expect(
|
||||
isSettingsNavItemLocked(
|
||||
'organization-overview',
|
||||
createContext({ hasFeature: hasFeatures([]) }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSettingsNavItemLocked('security-roles', createContext({ hasFeature: hasFeatures([]) })),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSettingsNavItemLocked('system-relay', createContext({ hasFeature: hasFeatures([]) })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a tab without feature requirements (isTabLocked -> isFeatureLocked false branch)', () => {
|
||||
expect(
|
||||
isSettingsNavItemLocked('system-general', createContext({ hasFeature: hasFeatures([]) })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false while runtime capabilities are not loaded (isFeatureLoaded -> false branch)', () => {
|
||||
expect(
|
||||
isSettingsNavItemLocked(
|
||||
'system-general',
|
||||
createContext({ runtimeCapabilitiesLoaded: () => false }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('never reports a real catalog tab as locked (every feature-gated tab also has hideWhenUnavailable)', () => {
|
||||
// See GLM_REPORT: the `return isTabLocked(...)` -> true outcome is unreachable
|
||||
// for real catalog items, so every real tab resolves to false here.
|
||||
const tabs: SettingsTab[] = [
|
||||
'system-relay',
|
||||
'security-webhooks',
|
||||
'organization-overview',
|
||||
'organization-access',
|
||||
'organization-sharing',
|
||||
'organization-billing',
|
||||
'organization-billing-admin',
|
||||
'system-general',
|
||||
'api',
|
||||
'support-diagnostics',
|
||||
];
|
||||
for (const tab of tabs) {
|
||||
expect(
|
||||
isSettingsNavItemLocked(tab, createContext({ hasFeature: hasFeatures([]) })),
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- hasRequiredFeatures / missingFeaturesArePaidRuntimeBlocked (private) ---
|
||||
// These helpers are not exported; they are exercised indirectly through the
|
||||
// three public functions above. This block pins the branch mapping so the
|
||||
// coverage intent is explicit and self-documenting.
|
||||
|
||||
describe('private helpers hasRequiredFeatures / missingFeaturesArePaidRuntimeBlocked (indirect)', () => {
|
||||
it('hasRequiredFeatures: every()->true when features are present, every()->false when missing', () => {
|
||||
// every()->true: features present -> availability gate skipped -> not hidden.
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'organization-overview',
|
||||
createContext({
|
||||
hasFeature: hasFeatures(['multi_tenant']),
|
||||
presentationPolicyResolved: true,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
// every()->false: feature missing -> availability gate engages.
|
||||
expect(
|
||||
shouldHideSettingsNavItem(
|
||||
'organization-overview',
|
||||
createContext({
|
||||
hasFeature: hasFeatures([]),
|
||||
presentationPolicyResolved: true,
|
||||
isRuntimeCapabilityBlocked: () => false,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('missingFeaturesArePaidRuntimeBlocked: every()->true keeps visible, every()->false hides/blocks', () => {
|
||||
// every()->true over the blocker -> not hidden, not blocked.
|
||||
const blockedCtx = createContext({
|
||||
hasFeature: hasFeatures([]),
|
||||
presentationPolicyResolved: true,
|
||||
isRuntimeCapabilityBlocked: () => true,
|
||||
});
|
||||
expect(shouldHideSettingsNavItem('organization-overview', blockedCtx)).toBe(false);
|
||||
expect(shouldBlockSettingsRouteItem('organization-overview', blockedCtx)).toBe(false);
|
||||
|
||||
// every()->false over the blocker -> hidden AND blocked.
|
||||
const notBlockedCtx = createContext({
|
||||
hasFeature: hasFeatures([]),
|
||||
presentationPolicyResolved: true,
|
||||
isRuntimeCapabilityBlocked: () => false,
|
||||
});
|
||||
expect(shouldHideSettingsNavItem('organization-overview', notBlockedCtx)).toBe(true);
|
||||
expect(shouldBlockSettingsRouteItem('organization-overview', notBlockedCtx)).toBe(true);
|
||||
});
|
||||
});
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* Branch-coverage tests for the still-uncovered named helpers in
|
||||
* ssoProvidersModel:
|
||||
* buildProviderTestPayload, buildMetadataPreviewPayload,
|
||||
* mapProviderDetailsToForm, buildProviderPayload, canTestProviderForm.
|
||||
*
|
||||
* Every `||`, optional-chain, and if/else arm is driven with concrete inputs
|
||||
* and asserted against the exact emitted shape (no truthiness-only checks).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildMetadataPreviewPayload,
|
||||
buildProviderPayload,
|
||||
buildProviderTestPayload,
|
||||
canTestProviderForm,
|
||||
createEmptyProviderForm,
|
||||
mapProviderDetailsToForm,
|
||||
type ProviderForm,
|
||||
} from '../ssoProvidersModel';
|
||||
|
||||
// ---- Fixtures ---------------------------------------------------------------
|
||||
|
||||
const oidcForm = (overrides: Partial<ProviderForm> = {}): ProviderForm => ({
|
||||
...createEmptyProviderForm(),
|
||||
type: 'oidc' as const,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const samlForm = (overrides: Partial<ProviderForm> = {}): ProviderForm => ({
|
||||
...createEmptyProviderForm(),
|
||||
type: 'saml' as const,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ---- buildProviderTestPayload ----------------------------------------------
|
||||
|
||||
describe('buildProviderTestPayload', () => {
|
||||
it('emits the oidc arm with a populated issuerUrl and clientId', () => {
|
||||
const payload = buildProviderTestPayload(
|
||||
oidcForm({
|
||||
oidcIssuerUrl: ' https://idp.example.com ',
|
||||
oidcClientId: ' pulse ',
|
||||
}),
|
||||
);
|
||||
expect(payload).toStrictEqual({
|
||||
type: 'oidc',
|
||||
oidc: {
|
||||
issuerUrl: 'https://idp.example.com',
|
||||
clientId: 'pulse',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps issuerUrl as an empty string and collapses a blank oidc clientId to undefined', () => {
|
||||
// issuerUrl is emitted unconditionally (only trimmed); a blank clientId
|
||||
// falls through the `|| undefined` arm.
|
||||
const payload = buildProviderTestPayload(
|
||||
oidcForm({ oidcIssuerUrl: ' ', oidcClientId: ' ' }),
|
||||
);
|
||||
expect(payload).toStrictEqual({
|
||||
type: 'oidc',
|
||||
oidc: { issuerUrl: '', clientId: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('emits the saml arm with every populated field', () => {
|
||||
const payload = buildProviderTestPayload(
|
||||
samlForm({
|
||||
samlIdpMetadataUrl: 'https://idp.example.com/metadata',
|
||||
samlIdpMetadataXml: '<EntityDescriptor/>',
|
||||
samlIdpSsoUrl: 'https://idp.example.com/sso',
|
||||
samlIdpCertificate: 'cert-data',
|
||||
}),
|
||||
);
|
||||
expect(payload).toStrictEqual({
|
||||
type: 'saml',
|
||||
saml: {
|
||||
idpMetadataUrl: 'https://idp.example.com/metadata',
|
||||
idpMetadataXml: '<EntityDescriptor/>',
|
||||
idpSsoUrl: 'https://idp.example.com/sso',
|
||||
idpCertificate: 'cert-data',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses every blank saml field to undefined in the saml arm', () => {
|
||||
const payload = buildProviderTestPayload(
|
||||
samlForm({
|
||||
samlIdpMetadataUrl: ' ',
|
||||
samlIdpMetadataXml: ' ',
|
||||
samlIdpSsoUrl: ' ',
|
||||
samlIdpCertificate: ' ',
|
||||
}),
|
||||
);
|
||||
expect(payload).toStrictEqual({
|
||||
type: 'saml',
|
||||
saml: {
|
||||
idpMetadataUrl: undefined,
|
||||
idpMetadataXml: undefined,
|
||||
idpSsoUrl: undefined,
|
||||
idpCertificate: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildMetadataPreviewPayload --------------------------------------------
|
||||
|
||||
describe('buildMetadataPreviewPayload', () => {
|
||||
it('always reports type "saml" and trims the metadata url', () => {
|
||||
const payload = buildMetadataPreviewPayload(
|
||||
samlForm({ samlIdpMetadataUrl: ' https://idp.example.com/metadata ' }),
|
||||
);
|
||||
expect(payload).toStrictEqual({
|
||||
type: 'saml',
|
||||
metadataUrl: 'https://idp.example.com/metadata',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an empty metadata url as an empty string (not undefined)', () => {
|
||||
expect(buildMetadataPreviewPayload(samlForm({ samlIdpMetadataUrl: ' ' }))).toStrictEqual({
|
||||
type: 'saml',
|
||||
metadataUrl: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- mapProviderDetailsToForm -----------------------------------------------
|
||||
|
||||
describe('mapProviderDetailsToForm', () => {
|
||||
it('maps a fully-populated SAML provider details response into the form', () => {
|
||||
const form = mapProviderDetailsToForm({
|
||||
id: 'saml-1',
|
||||
name: 'Corp SAML',
|
||||
type: 'saml',
|
||||
enabled: true,
|
||||
displayName: 'Corp',
|
||||
priority: 7,
|
||||
saml: {
|
||||
idpMetadataUrl: 'https://idp.example.com/metadata',
|
||||
idpMetadataXml: '<EntityDescriptor/>',
|
||||
idpSsoUrl: 'https://idp.example.com/sso',
|
||||
idpEntityId: 'https://idp.example.com',
|
||||
idpCertificate: 'cert-data',
|
||||
spEntityId: 'https://sp.example.com',
|
||||
signRequests: true,
|
||||
allowIdpInitiated: true,
|
||||
usernameAttr: 'user',
|
||||
emailAttr: 'mail',
|
||||
groupsAttr: 'memberOf',
|
||||
},
|
||||
groupsClaim: 'ignored-when-groupsAttr-set',
|
||||
allowedGroups: ['admins', 'operators'],
|
||||
allowedDomains: ['example.com'],
|
||||
allowedEmails: ['u@example.com'],
|
||||
groupRoleMappings: { admins: 'admin' },
|
||||
});
|
||||
|
||||
expect(form).toStrictEqual({
|
||||
id: 'saml-1',
|
||||
name: 'Corp SAML',
|
||||
type: 'saml',
|
||||
enabled: true,
|
||||
displayName: 'Corp',
|
||||
priority: 7,
|
||||
oidcIssuerUrl: '',
|
||||
oidcClientId: '',
|
||||
oidcClientSecret: '',
|
||||
oidcRedirectUrl: '',
|
||||
oidcLogoutUrl: '',
|
||||
oidcScopes: 'openid profile email',
|
||||
samlIdpMetadataUrl: 'https://idp.example.com/metadata',
|
||||
samlIdpMetadataXml: '<EntityDescriptor/>',
|
||||
samlIdpSsoUrl: 'https://idp.example.com/sso',
|
||||
samlIdpEntityId: 'https://idp.example.com',
|
||||
samlIdpCertificate: 'cert-data',
|
||||
samlSpEntityId: 'https://sp.example.com',
|
||||
samlSignRequests: true,
|
||||
samlAllowIdpInitiated: true,
|
||||
samlUsernameAttr: 'user',
|
||||
samlEmailAttr: 'mail',
|
||||
groupsClaim: 'memberOf',
|
||||
allowedGroups: 'admins, operators',
|
||||
allowedDomains: 'example.com',
|
||||
allowedEmails: 'u@example.com',
|
||||
groupRoleMappings: 'admins=admin',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults displayName to "" and priority to 0 when they are absent', () => {
|
||||
const form = mapProviderDetailsToForm({
|
||||
id: 'p',
|
||||
name: 'P',
|
||||
type: 'oidc',
|
||||
enabled: false,
|
||||
});
|
||||
expect(form.displayName).toBe('');
|
||||
expect(form.priority).toBe(0);
|
||||
expect(form.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults the saml emailAttr to "email" when saml is present without emailAttr', () => {
|
||||
const form = mapProviderDetailsToForm({
|
||||
id: 'p',
|
||||
name: 'P',
|
||||
type: 'saml',
|
||||
enabled: true,
|
||||
saml: { signRequests: false },
|
||||
});
|
||||
expect(form.samlEmailAttr).toBe('email');
|
||||
expect(form.samlSignRequests).toBe(false);
|
||||
expect(form.samlAllowIdpInitiated).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults the saml emailAttr to "email" when saml is entirely absent (oidc provider)', () => {
|
||||
const form = mapProviderDetailsToForm({
|
||||
id: 'p',
|
||||
name: 'P',
|
||||
type: 'oidc',
|
||||
enabled: true,
|
||||
});
|
||||
expect(form.samlEmailAttr).toBe('email');
|
||||
expect(form.samlSpEntityId).toBe('');
|
||||
});
|
||||
|
||||
it('falls back to the top-level groupsClaim when saml.groupsAttr is absent', () => {
|
||||
const form = mapProviderDetailsToForm({
|
||||
id: 'p',
|
||||
name: 'P',
|
||||
type: 'oidc',
|
||||
enabled: true,
|
||||
groupsClaim: 'roles',
|
||||
});
|
||||
expect(form.groupsClaim).toBe('roles');
|
||||
});
|
||||
|
||||
it('defaults groupsClaim to "" when neither saml.groupsAttr nor top-level groupsClaim is set', () => {
|
||||
const form = mapProviderDetailsToForm({
|
||||
id: 'p',
|
||||
name: 'P',
|
||||
type: 'oidc',
|
||||
enabled: true,
|
||||
});
|
||||
expect(form.groupsClaim).toBe('');
|
||||
});
|
||||
|
||||
it('defaults oidcScopes to "openid profile email" when scopes is an empty array (join -> "")', () => {
|
||||
const form = mapProviderDetailsToForm({
|
||||
id: 'p',
|
||||
name: 'P',
|
||||
type: 'oidc',
|
||||
enabled: true,
|
||||
oidc: { scopes: [] },
|
||||
});
|
||||
expect(form.oidcScopes).toBe('openid profile email');
|
||||
});
|
||||
|
||||
it('joins a populated oidc scopes array with single spaces', () => {
|
||||
const form = mapProviderDetailsToForm({
|
||||
id: 'p',
|
||||
name: 'P',
|
||||
type: 'oidc',
|
||||
enabled: true,
|
||||
oidc: { scopes: ['openid', 'groups', 'custom_claim'] },
|
||||
});
|
||||
expect(form.oidcScopes).toBe('openid groups custom_claim');
|
||||
});
|
||||
|
||||
it('serializes a populated oidc block (issuer/clientId/redirect/logout) into the form', () => {
|
||||
const form = mapProviderDetailsToForm({
|
||||
id: 'p',
|
||||
name: 'P',
|
||||
type: 'oidc',
|
||||
enabled: true,
|
||||
oidc: {
|
||||
issuerUrl: 'https://idp.example.com',
|
||||
clientId: 'pulse',
|
||||
redirectUrl: 'https://app.example.com/cb',
|
||||
logoutUrl: 'https://idp.example.com/logout',
|
||||
},
|
||||
});
|
||||
expect(form.oidcIssuerUrl).toBe('https://idp.example.com');
|
||||
expect(form.oidcClientId).toBe('pulse');
|
||||
expect(form.oidcRedirectUrl).toBe('https://app.example.com/cb');
|
||||
expect(form.oidcLogoutUrl).toBe('https://idp.example.com/logout');
|
||||
expect(form.oidcClientSecret).toBe('');
|
||||
});
|
||||
|
||||
it('renders allowedGroups/Domains/Emails as "" for both absent and empty-array inputs', () => {
|
||||
// Both arms of listToString (`values && values.length > 0`) collapse to ''.
|
||||
const absent = mapProviderDetailsToForm({
|
||||
id: 'p',
|
||||
name: 'P',
|
||||
type: 'oidc',
|
||||
enabled: true,
|
||||
});
|
||||
expect(absent.allowedGroups).toBe('');
|
||||
expect(absent.allowedDomains).toBe('');
|
||||
expect(absent.allowedEmails).toBe('');
|
||||
|
||||
const emptyArrays = mapProviderDetailsToForm({
|
||||
id: 'p',
|
||||
name: 'P',
|
||||
type: 'oidc',
|
||||
enabled: true,
|
||||
allowedGroups: [],
|
||||
allowedDomains: [],
|
||||
allowedEmails: [],
|
||||
});
|
||||
expect(emptyArrays.allowedGroups).toBe('');
|
||||
expect(emptyArrays.allowedDomains).toBe('');
|
||||
expect(emptyArrays.allowedEmails).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildProviderPayload ---------------------------------------------------
|
||||
|
||||
describe('buildProviderPayload', () => {
|
||||
it('emits a fully-populated oidc payload including identity-restricted fields', () => {
|
||||
const payload = buildProviderPayload(
|
||||
oidcForm({
|
||||
id: 'oidc-1',
|
||||
name: ' Corp OIDC ',
|
||||
displayName: ' Corp ',
|
||||
priority: 3,
|
||||
oidcIssuerUrl: 'https://idp.example.com',
|
||||
oidcClientId: 'pulse',
|
||||
oidcClientSecret: 'shh',
|
||||
oidcRedirectUrl: 'https://app.example.com/cb',
|
||||
oidcLogoutUrl: 'https://idp.example.com/logout',
|
||||
oidcScopes: 'openid profile email groups',
|
||||
groupsClaim: 'groups',
|
||||
allowedGroups: 'admins, operators',
|
||||
allowedDomains: 'example.com',
|
||||
allowedEmails: 'u@example.com',
|
||||
groupRoleMappings: 'admins=admin, operators=operator',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(payload).toStrictEqual({
|
||||
id: 'oidc-1',
|
||||
name: 'Corp OIDC',
|
||||
type: 'oidc',
|
||||
enabled: true,
|
||||
displayName: 'Corp',
|
||||
priority: 3,
|
||||
allowedGroups: ['admins', 'operators'],
|
||||
allowedDomains: ['example.com'],
|
||||
allowedEmails: ['u@example.com'],
|
||||
groupRoleMappings: { admins: 'admin', operators: 'operator' },
|
||||
oidc: {
|
||||
issuerUrl: 'https://idp.example.com',
|
||||
clientId: 'pulse',
|
||||
clientSecret: 'shh',
|
||||
redirectUrl: 'https://app.example.com/cb',
|
||||
logoutUrl: 'https://idp.example.com/logout',
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
},
|
||||
groupsClaim: 'groups',
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses every blank oidc field to undefined in the oidc arm', () => {
|
||||
// Default empty form, but force scopes to '' so splitList yields [].
|
||||
const payload = buildProviderPayload({ ...oidcForm(), oidcScopes: '' });
|
||||
|
||||
expect(payload).toStrictEqual({
|
||||
id: undefined,
|
||||
name: '',
|
||||
type: 'oidc',
|
||||
enabled: true,
|
||||
displayName: undefined,
|
||||
priority: 0,
|
||||
allowedGroups: [],
|
||||
allowedDomains: [],
|
||||
allowedEmails: [],
|
||||
groupRoleMappings: {},
|
||||
oidc: {
|
||||
issuerUrl: '',
|
||||
clientId: '',
|
||||
clientSecret: undefined,
|
||||
redirectUrl: undefined,
|
||||
logoutUrl: undefined,
|
||||
scopes: [],
|
||||
},
|
||||
groupsClaim: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits a fully-populated saml payload (keeps groupsClaim aligned with groupsAttr)', () => {
|
||||
const payload = buildProviderPayload(
|
||||
samlForm({
|
||||
id: 'saml-1',
|
||||
name: ' Corp SAML ',
|
||||
displayName: ' Corp ',
|
||||
priority: 5,
|
||||
samlIdpMetadataUrl: 'https://idp.example.com/metadata',
|
||||
samlIdpMetadataXml: '<EntityDescriptor/>',
|
||||
samlIdpSsoUrl: 'https://idp.example.com/sso',
|
||||
samlIdpEntityId: 'https://idp.example.com',
|
||||
samlIdpCertificate: 'cert-data',
|
||||
samlSpEntityId: 'https://sp.example.com',
|
||||
samlSignRequests: true,
|
||||
samlAllowIdpInitiated: true,
|
||||
samlUsernameAttr: 'user',
|
||||
samlEmailAttr: 'mail',
|
||||
groupsClaim: 'memberOf',
|
||||
allowedGroups: 'admins',
|
||||
allowedDomains: 'example.com',
|
||||
allowedEmails: 'u@example.com',
|
||||
groupRoleMappings: 'admins=admin',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(payload).toStrictEqual({
|
||||
id: 'saml-1',
|
||||
name: 'Corp SAML',
|
||||
type: 'saml',
|
||||
enabled: true,
|
||||
displayName: 'Corp',
|
||||
priority: 5,
|
||||
allowedGroups: ['admins'],
|
||||
allowedDomains: ['example.com'],
|
||||
allowedEmails: ['u@example.com'],
|
||||
groupRoleMappings: { admins: 'admin' },
|
||||
saml: {
|
||||
idpMetadataUrl: 'https://idp.example.com/metadata',
|
||||
idpMetadataXml: '<EntityDescriptor/>',
|
||||
idpSsoUrl: 'https://idp.example.com/sso',
|
||||
idpEntityId: 'https://idp.example.com',
|
||||
idpCertificate: 'cert-data',
|
||||
spEntityId: 'https://sp.example.com',
|
||||
signRequests: true,
|
||||
allowIdpInitiated: true,
|
||||
usernameAttr: 'user',
|
||||
emailAttr: 'mail',
|
||||
groupsAttr: 'memberOf',
|
||||
},
|
||||
groupsClaim: 'memberOf',
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses every blank saml field to undefined in the saml arm', () => {
|
||||
const payload = buildProviderPayload(samlForm());
|
||||
|
||||
expect(payload).toStrictEqual({
|
||||
id: undefined,
|
||||
name: '',
|
||||
type: 'saml',
|
||||
enabled: true,
|
||||
displayName: undefined,
|
||||
priority: 0,
|
||||
allowedGroups: [],
|
||||
allowedDomains: [],
|
||||
allowedEmails: [],
|
||||
groupRoleMappings: {},
|
||||
saml: {
|
||||
idpMetadataUrl: undefined,
|
||||
idpMetadataXml: undefined,
|
||||
idpSsoUrl: undefined,
|
||||
idpEntityId: undefined,
|
||||
idpCertificate: undefined,
|
||||
spEntityId: undefined,
|
||||
signRequests: false,
|
||||
allowIdpInitiated: false,
|
||||
usernameAttr: undefined,
|
||||
// The empty form seeds samlEmailAttr with 'email' (createEmptyProviderForm),
|
||||
// so it survives the `|| undefined` collapse.
|
||||
emailAttr: 'email',
|
||||
groupsAttr: undefined,
|
||||
},
|
||||
groupsClaim: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---- canTestProviderForm ----------------------------------------------------
|
||||
|
||||
describe('canTestProviderForm', () => {
|
||||
it('returns true for an oidc form with a populated issuerUrl', () => {
|
||||
expect(canTestProviderForm(oidcForm({ oidcIssuerUrl: 'https://idp.example.com' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for an oidc form with an empty issuerUrl', () => {
|
||||
expect(canTestProviderForm(oidcForm({ oidcIssuerUrl: '' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an oidc form with a whitespace-only issuerUrl (trims to empty)', () => {
|
||||
expect(canTestProviderForm(oidcForm({ oidcIssuerUrl: ' ' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a saml form with a populated metadataUrl', () => {
|
||||
expect(
|
||||
canTestProviderForm(samlForm({ samlIdpMetadataUrl: 'https://idp.example.com/metadata' })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a saml form with only a populated metadataXml (metadataUrl absent)', () => {
|
||||
expect(canTestProviderForm(samlForm({ samlIdpMetadataXml: '<EntityDescriptor/>' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a saml form with only a populated idpSsoUrl (first two blank)', () => {
|
||||
expect(canTestProviderForm(samlForm({ samlIdpSsoUrl: 'https://idp.example.com/sso' }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false for a saml form with every test field blank', () => {
|
||||
expect(canTestProviderForm(samlForm())).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a saml form with every test field whitespace-only (trims to empty)', () => {
|
||||
expect(
|
||||
canTestProviderForm(
|
||||
samlForm({
|
||||
samlIdpMetadataUrl: ' ',
|
||||
samlIdpMetadataXml: ' ',
|
||||
samlIdpSsoUrl: ' ',
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+657
@@ -0,0 +1,657 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { AggregatedMetricPoint } from '@/api/charts';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
|
||||
import {
|
||||
buildGuestDrawerHistoryPath,
|
||||
getGuestDrawerAgentLabel,
|
||||
getGuestDrawerAgentTitle,
|
||||
getGuestDrawerBackupPresentation,
|
||||
getGuestDrawerHistoryFallbackMetrics,
|
||||
getGuestDrawerHistoryRangeBounds,
|
||||
getGuestDrawerHistoryScale,
|
||||
getGuestDrawerHistoryTarget,
|
||||
getGuestDrawerHistoryValueLabel,
|
||||
getGuestDrawerMemoryRows,
|
||||
getGuestDrawerNetworkInterfaces,
|
||||
hasGuestDrawerFilesystemDetails,
|
||||
hasGuestDrawerOsInfo,
|
||||
isGuestDrawerVM,
|
||||
normalizeGuestDrawerHistoryPoints,
|
||||
normalizeGuestDrawerTags,
|
||||
} from '../guestDrawerModel';
|
||||
|
||||
const makeGuest = (overrides?: Partial<WorkloadGuest>): WorkloadGuest =>
|
||||
({
|
||||
id: 'guest-0',
|
||||
vmid: 100,
|
||||
name: 'workload-0',
|
||||
node: 'pve',
|
||||
instance: 'cluster-a',
|
||||
status: 'running',
|
||||
type: 'qemu',
|
||||
cpu: 0.5,
|
||||
cpus: 2,
|
||||
memory: { total: 4096, used: 1024, free: 3072, usage: 0.25 },
|
||||
disk: { total: 102400, used: 10240, free: 92160, usage: 0.1 },
|
||||
networkIn: 100,
|
||||
networkOut: 200,
|
||||
diskRead: 10,
|
||||
diskWrite: 5,
|
||||
uptime: 3600,
|
||||
template: false,
|
||||
lastBackup: 0,
|
||||
tags: [],
|
||||
lock: '',
|
||||
lastSeen: new Date().toISOString(),
|
||||
workloadType: 'vm',
|
||||
...overrides,
|
||||
}) as WorkloadGuest;
|
||||
|
||||
// AggregatedMetricPoint declares min/max as required numbers, but the runtime
|
||||
// contract (and the code under test) tolerates missing/non-finite min/max, so
|
||||
// build points with optional min/max and cast to satisfy the declared type.
|
||||
const pt = (
|
||||
timestamp: number,
|
||||
value: number,
|
||||
min?: number,
|
||||
max?: number,
|
||||
): AggregatedMetricPoint => ({ timestamp, value, min, max }) as AggregatedMetricPoint;
|
||||
|
||||
describe('guestDrawerModel (branch coverage)', () => {
|
||||
describe('isGuestDrawerVM', () => {
|
||||
it('returns true for a qemu/vm guest', () => {
|
||||
expect(isGuestDrawerVM(makeGuest({ type: 'qemu', workloadType: 'vm' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a system-container (lxc) guest', () => {
|
||||
expect(isGuestDrawerVM(makeGuest({ type: 'lxc', workloadType: 'system-container' }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false for an app-container', () => {
|
||||
expect(
|
||||
isGuestDrawerVM(
|
||||
makeGuest({ type: 'app-container', workloadType: 'app-container', id: 'c1' }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuestDrawerHistoryFallbackMetrics', () => {
|
||||
it('scales a fractional cpu (<=1.5) by 100 and returns finite metric values', () => {
|
||||
const guest = makeGuest({
|
||||
cpu: 1.0,
|
||||
memory: { total: 100, used: 40, free: 60, usage: 0.4 },
|
||||
disk: { total: 100, used: 30, free: 70, usage: 0.3 },
|
||||
networkIn: 100,
|
||||
networkOut: 200,
|
||||
diskRead: 10,
|
||||
diskWrite: 5,
|
||||
});
|
||||
expect(getGuestDrawerHistoryFallbackMetrics(guest)).toStrictEqual({
|
||||
cpu: 100,
|
||||
memory: 0.4,
|
||||
disk: 0.3,
|
||||
netin: 100,
|
||||
netout: 200,
|
||||
diskread: 10,
|
||||
diskwrite: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes a cpu value greater than 1.5 through unchanged', () => {
|
||||
expect(getGuestDrawerHistoryFallbackMetrics(makeGuest({ cpu: 2.0 })).cpu).toBe(2);
|
||||
});
|
||||
|
||||
it('treats the cpu boundary of exactly 1.5 as a ratio (150)', () => {
|
||||
expect(getGuestDrawerHistoryFallbackMetrics(makeGuest({ cpu: 1.5 })).cpu).toBe(150);
|
||||
});
|
||||
|
||||
it('drops cpu when it is a non-finite number', () => {
|
||||
expect(getGuestDrawerHistoryFallbackMetrics(makeGuest({ cpu: Number.NaN })).cpu).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops cpu when it is not a number (typeof guard)', () => {
|
||||
expect(
|
||||
getGuestDrawerHistoryFallbackMetrics(
|
||||
makeGuest({ cpu: 'busy' as unknown as number }),
|
||||
).cpu,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops memory/disk when those objects are absent (optional-chain arm)', () => {
|
||||
const result = getGuestDrawerHistoryFallbackMetrics(
|
||||
makeGuest({
|
||||
memory: undefined as unknown as WorkloadGuest['memory'],
|
||||
disk: undefined as unknown as WorkloadGuest['disk'],
|
||||
}),
|
||||
);
|
||||
expect(result.memory).toBeUndefined();
|
||||
expect(result.disk).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops a non-finite network value via the finite() guard', () => {
|
||||
const result = getGuestDrawerHistoryFallbackMetrics(
|
||||
makeGuest({ networkIn: Number.POSITIVE_INFINITY }),
|
||||
);
|
||||
expect(result.netin).toBeUndefined();
|
||||
expect(result.netout).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeGuestDrawerHistoryPoints (and internal clampHistoryPointValue)', () => {
|
||||
it('returns an empty array for undefined points', () => {
|
||||
expect(normalizeGuestDrawerHistoryPoints(undefined, '%')).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters non-finite timestamp/value points, clamps % values, and sorts by timestamp', () => {
|
||||
const points = [
|
||||
pt(3, 150, 200, -10), // value->100, min->100, max->0 (% clamp)
|
||||
pt(1, 50), // min/max undefined -> fall back to clamped value (50)
|
||||
pt(2, Number.NaN), // filtered: non-finite value
|
||||
pt(Number.NaN, 40), // filtered: non-finite timestamp
|
||||
pt(4, 60, Number.NaN, 'x' as unknown as number), // min NaN -> value; max non-number -> value
|
||||
];
|
||||
expect(normalizeGuestDrawerHistoryPoints(points, '%')).toEqual([
|
||||
{ timestamp: 1, value: 50, min: 50, max: 50 },
|
||||
{ timestamp: 3, value: 100, min: 100, max: 0 },
|
||||
{ timestamp: 4, value: 60, min: 60, max: 60 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('clamps non-% values to >=0 without the 100 cap', () => {
|
||||
const points = [pt(1, -50, -20, 250)];
|
||||
expect(normalizeGuestDrawerHistoryPoints(points, 'B/s')).toEqual([
|
||||
{ timestamp: 1, value: 0, min: 0, max: 250 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuestDrawerHistoryScale', () => {
|
||||
it('returns a fixed 0-100 scale for percent units', () => {
|
||||
expect(
|
||||
getGuestDrawerHistoryScale(
|
||||
[{ points: [pt(1, 150, 0, 999)] }],
|
||||
'%',
|
||||
),
|
||||
).toStrictEqual({ minValue: 0, maxValue: 100 });
|
||||
});
|
||||
|
||||
describe("unit 'C'", () => {
|
||||
it('falls back to 0-100 when no finite points are present', () => {
|
||||
expect(getGuestDrawerHistoryScale([], 'C')).toStrictEqual({ minValue: 0, maxValue: 100 });
|
||||
expect(
|
||||
getGuestDrawerHistoryScale(
|
||||
[{ points: [pt(1, Number.NaN, Number.NaN, Number.NaN)] }],
|
||||
'C',
|
||||
),
|
||||
).toStrictEqual({ minValue: 0, maxValue: 100 });
|
||||
});
|
||||
|
||||
it('expands a single distinct value (min===max) symmetrically around it', () => {
|
||||
expect(
|
||||
getGuestDrawerHistoryScale([{ points: [pt(1, 50, 50, 50)] }], 'C'),
|
||||
).toStrictEqual({ minValue: 45, maxValue: 55 });
|
||||
});
|
||||
|
||||
it('clamps the symmetric lower bound to 0 when the value is near zero', () => {
|
||||
expect(
|
||||
getGuestDrawerHistoryScale([{ points: [pt(1, 3, 3, 3)] }], 'C'),
|
||||
).toStrictEqual({ minValue: 0, maxValue: 8 });
|
||||
});
|
||||
|
||||
it('applies 15%% padding around distinct min/max using finite min/max fields', () => {
|
||||
// min=50, max=80 -> padding=max(2, 30*0.15=4.5)=4.5 -> {45.5, 84.5}
|
||||
expect(
|
||||
getGuestDrawerHistoryScale([{ points: [pt(1, 55, 50, 80)] }], 'C'),
|
||||
).toStrictEqual({ minValue: 45.5, maxValue: 84.5 });
|
||||
});
|
||||
|
||||
it('falls back to point.value for low/high when min/max are absent', () => {
|
||||
// value=40 only -> min===max=40 -> {35, 45}
|
||||
expect(
|
||||
getGuestDrawerHistoryScale([{ points: [pt(1, 40)] }], 'C'),
|
||||
).toStrictEqual({ minValue: 35, maxValue: 45 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('default unit (e.g. B/s)', () => {
|
||||
it('returns {0,1} when there are no finite points', () => {
|
||||
expect(getGuestDrawerHistoryScale([], 'B/s')).toStrictEqual({ minValue: 0, maxValue: 1 });
|
||||
expect(
|
||||
getGuestDrawerHistoryScale(
|
||||
[
|
||||
{
|
||||
points: [
|
||||
pt(1, 100, undefined, Number.POSITIVE_INFINITY),
|
||||
pt(2, Number.NaN),
|
||||
],
|
||||
},
|
||||
],
|
||||
'B/s',
|
||||
),
|
||||
).toStrictEqual({ minValue: 0, maxValue: 1 });
|
||||
});
|
||||
|
||||
it('scales to 1.15x of the largest finite max (prefers max over value)', () => {
|
||||
// max=200 -> maxValue = max(1, 200*1.15) (IEEE-754 -> 229.99999999999997)
|
||||
const expectedMax = Math.max(1, 200 * 1.15);
|
||||
expect(
|
||||
getGuestDrawerHistoryScale([{ points: [pt(1, 100, 0, 200)] }], 'B/s'),
|
||||
).toStrictEqual({ minValue: 0, maxValue: expectedMax });
|
||||
});
|
||||
|
||||
it('falls back to point.value when max is absent', () => {
|
||||
// value=150 -> maxValue = max(1, 150*1.15) = 172.5
|
||||
expect(
|
||||
getGuestDrawerHistoryScale([{ points: [pt(1, 150)] }], 'B/s'),
|
||||
).toStrictEqual({ minValue: 0, maxValue: 172.5 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGuestDrawerHistoryPath', () => {
|
||||
it('returns an empty string for fewer than two points', () => {
|
||||
expect(buildGuestDrawerHistoryPath([], { minValue: 0, maxValue: 100 }, 0, 100)).toBe('');
|
||||
expect(
|
||||
buildGuestDrawerHistoryPath([pt(1, 50)], { minValue: 0, maxValue: 100 }, 0, 100),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('builds an M.. L.. path with default geometry across the full value range', () => {
|
||||
const path = buildGuestDrawerHistoryPath(
|
||||
[pt(0, 0), pt(100, 100)],
|
||||
{ minValue: 0, maxValue: 100 },
|
||||
0,
|
||||
100,
|
||||
);
|
||||
expect(path).toBe('M34.00,74.00 L352.00,8.00');
|
||||
});
|
||||
|
||||
it('clamps values above/below the scale to the plot edges', () => {
|
||||
const path = buildGuestDrawerHistoryPath(
|
||||
[pt(0, 150), pt(100, -20)],
|
||||
{ minValue: 0, maxValue: 100 },
|
||||
0,
|
||||
100,
|
||||
);
|
||||
expect(path).toBe('M34.00,8.00 L352.00,74.00');
|
||||
});
|
||||
|
||||
it('honors custom width/height', () => {
|
||||
// width=200 -> plotWidth=158; height=100 -> plotHeight=74
|
||||
const path = buildGuestDrawerHistoryPath(
|
||||
[pt(0, 0), pt(100, 100)],
|
||||
{ minValue: 0, maxValue: 100 },
|
||||
0,
|
||||
100,
|
||||
200,
|
||||
100,
|
||||
);
|
||||
expect(path).toBe('M34.00,82.00 L192.00,8.00');
|
||||
});
|
||||
|
||||
it('uses Math.max(1,...) for degenerate time/value spans', () => {
|
||||
// startTime===endTime -> timeSpan=1; minValue===maxValue -> valueSpan=1
|
||||
const path = buildGuestDrawerHistoryPath(
|
||||
[pt(5, 5), pt(5, 5)],
|
||||
{ minValue: 5, maxValue: 5 },
|
||||
5,
|
||||
5,
|
||||
);
|
||||
expect(path).toBe('M34.00,74.00 L34.00,74.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuestDrawerHistoryValueLabel', () => {
|
||||
it("returns '-' when there are no points", () => {
|
||||
expect(getGuestDrawerHistoryValueLabel([], '%')).toBe('-');
|
||||
});
|
||||
|
||||
it('formats the latest point value for each unit arm', () => {
|
||||
expect(getGuestDrawerHistoryValueLabel([pt(1, 10), pt(2, 50)], '%')).toBe('50.0%');
|
||||
expect(getGuestDrawerHistoryValueLabel([pt(1, 1024)], 'B/s')).toBe('1.00 KB/s');
|
||||
expect(getGuestDrawerHistoryValueLabel([pt(1, 1024)], '')).toBe('1.00 KB');
|
||||
expect(getGuestDrawerHistoryValueLabel([pt(1, 22.7)], 'C')).toBe('23°C');
|
||||
expect(getGuestDrawerHistoryValueLabel([pt(1, 3)], 'ops')).toBe('3 ops');
|
||||
expect(getGuestDrawerHistoryValueLabel([pt(1, 3.5)], 'ops')).toBe('3.5 ops');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuestDrawerHistoryRangeBounds', () => {
|
||||
it('returns null when every group has no points', () => {
|
||||
expect(getGuestDrawerHistoryRangeBounds([{ points: [] }, { points: [] }])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the combined min/max timestamps across groups', () => {
|
||||
expect(
|
||||
getGuestDrawerHistoryRangeBounds([
|
||||
{ points: [pt(10, 1), pt(30, 2)] },
|
||||
{ points: [pt(5, 3), pt(20, 4)] },
|
||||
]),
|
||||
).toStrictEqual({ startTime: 5, endTime: 30 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuestDrawerHistoryTarget', () => {
|
||||
it('returns null when the canonical id trims to empty', () => {
|
||||
expect(
|
||||
getGuestDrawerHistoryTarget(
|
||||
makeGuest({ id: ' ', type: 'app-container', workloadType: 'app-container' }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('maps a vm to the canonical node-scoped id', () => {
|
||||
expect(
|
||||
getGuestDrawerHistoryTarget(
|
||||
makeGuest({ type: 'qemu', workloadType: 'vm', instance: 'c', node: 'pve', vmid: 101 }),
|
||||
),
|
||||
).toStrictEqual({ resourceType: 'vm', resourceId: 'c:pve:101' });
|
||||
});
|
||||
|
||||
it('maps a system-container to the canonical node-scoped id', () => {
|
||||
expect(
|
||||
getGuestDrawerHistoryTarget(
|
||||
makeGuest({ type: 'lxc', workloadType: 'system-container', instance: 'c', node: 'pve', vmid: 102 }),
|
||||
),
|
||||
).toStrictEqual({ resourceType: 'system-container', resourceId: 'c:pve:102' });
|
||||
});
|
||||
|
||||
it('maps an app-container to its plain id', () => {
|
||||
expect(
|
||||
getGuestDrawerHistoryTarget(
|
||||
makeGuest({ id: 'app-1', type: 'app-container', workloadType: 'app-container' }),
|
||||
),
|
||||
).toStrictEqual({ resourceType: 'app-container', resourceId: 'app-1' });
|
||||
});
|
||||
|
||||
it('maps a pod to its plain id', () => {
|
||||
expect(
|
||||
getGuestDrawerHistoryTarget(makeGuest({ id: 'pod-1', type: 'pod', workloadType: 'pod' })),
|
||||
).toStrictEqual({ resourceType: 'pod', resourceId: 'pod-1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasGuestDrawerOsInfo', () => {
|
||||
it('returns false when both os fields are absent', () => {
|
||||
expect(hasGuestDrawerOsInfo(makeGuest())).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when osName is a non-empty string', () => {
|
||||
expect(hasGuestDrawerOsInfo(makeGuest({ osName: 'Ubuntu' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when only osVersion is set', () => {
|
||||
expect(hasGuestDrawerOsInfo(makeGuest({ osVersion: '22.04' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for an empty-string osName (length 0)', () => {
|
||||
expect(hasGuestDrawerOsInfo(makeGuest({ osName: '' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuestDrawerAgentLabel', () => {
|
||||
it("returns '' when agentVersion is missing or blank", () => {
|
||||
expect(getGuestDrawerAgentLabel(makeGuest())).toBe('');
|
||||
expect(getGuestDrawerAgentLabel(makeGuest({ agentVersion: ' ' }))).toBe('');
|
||||
});
|
||||
|
||||
it('prefixes the version with QEMU for a vm', () => {
|
||||
expect(getGuestDrawerAgentLabel(makeGuest({ type: 'qemu', agentVersion: '1.0' }))).toBe(
|
||||
'QEMU 1.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the bare version for a non-vm', () => {
|
||||
expect(
|
||||
getGuestDrawerAgentLabel(makeGuest({ type: 'lxc', workloadType: 'system-container', agentVersion: '1.0' })),
|
||||
).toBe('1.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuestDrawerAgentTitle', () => {
|
||||
it("returns '' when agentVersion is missing", () => {
|
||||
expect(getGuestDrawerAgentTitle(makeGuest())).toBe('');
|
||||
});
|
||||
|
||||
it('builds the full QEMU guest-agent title for a vm', () => {
|
||||
expect(getGuestDrawerAgentTitle(makeGuest({ type: 'qemu', agentVersion: '1.0' }))).toBe(
|
||||
'QEMU guest agent 1.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the bare version for a non-vm', () => {
|
||||
expect(
|
||||
getGuestDrawerAgentTitle(
|
||||
makeGuest({ type: 'lxc', workloadType: 'system-container', agentVersion: '1.0' }),
|
||||
),
|
||||
).toBe('1.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuestDrawerMemoryRows', () => {
|
||||
it('returns an empty array when memory is absent', () => {
|
||||
expect(
|
||||
getGuestDrawerMemoryRows(
|
||||
makeGuest({ memory: undefined as unknown as WorkloadGuest['memory'] }),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array when total is not positive and no balloon/swap', () => {
|
||||
expect(
|
||||
getGuestDrawerMemoryRows(
|
||||
makeGuest({ memory: { total: 0, used: 0, free: 0, usage: 0 } }),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits only Usage and Total when total>0 and no cache/free/balloon/swap', () => {
|
||||
expect(
|
||||
getGuestDrawerMemoryRows(
|
||||
makeGuest({
|
||||
memory: { total: 4096, used: 1024, usage: 0.25 } as unknown as WorkloadGuest['memory'],
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{ label: 'Usage', value: '25% · 1.00 KB' },
|
||||
{ label: 'Total', value: '4.00 KB' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends Reclaimable cache (>0) and Free (when free is a number)', () => {
|
||||
expect(
|
||||
getGuestDrawerMemoryRows(
|
||||
makeGuest({
|
||||
memory: { total: 4096, used: 1024, free: 3072, usage: 0.25, cache: 2048 },
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{ label: 'Usage', value: '25% · 1.00 KB' },
|
||||
{ label: 'Total', value: '4.00 KB' },
|
||||
{ label: 'Reclaimable cache', value: '2.00 KB' },
|
||||
{ label: 'Free', value: '3.00 KB' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the Free row when free is not a number', () => {
|
||||
expect(
|
||||
getGuestDrawerMemoryRows(
|
||||
makeGuest({
|
||||
memory: { total: 4096, used: 1024, usage: 0.25, free: 'x' as unknown as number },
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{ label: 'Usage', value: '25% · 1.00 KB' },
|
||||
{ label: 'Total', value: '4.00 KB' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends a Balloon row only when balloon>0 and differs from total', () => {
|
||||
expect(
|
||||
getGuestDrawerMemoryRows(
|
||||
makeGuest({
|
||||
memory: { total: 4096, used: 1024, usage: 0.25, balloon: 2048 } as unknown as WorkloadGuest['memory'],
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{ label: 'Usage', value: '25% · 1.00 KB' },
|
||||
{ label: 'Total', value: '4.00 KB' },
|
||||
{ label: 'Balloon', value: '2.00 KB' },
|
||||
]);
|
||||
// balloon === total -> row suppressed
|
||||
expect(
|
||||
getGuestDrawerMemoryRows(
|
||||
makeGuest({
|
||||
memory: { total: 4096, used: 1024, usage: 0.25, balloon: 4096 } as unknown as WorkloadGuest['memory'],
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{ label: 'Usage', value: '25% · 1.00 KB' },
|
||||
{ label: 'Total', value: '4.00 KB' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends a Swap row and defaults swapUsed to 0 when absent', () => {
|
||||
expect(
|
||||
getGuestDrawerMemoryRows(
|
||||
makeGuest({
|
||||
memory: {
|
||||
total: 4096,
|
||||
used: 1024,
|
||||
usage: 0.25,
|
||||
swapTotal: 1024,
|
||||
swapUsed: 512,
|
||||
} as unknown as WorkloadGuest['memory'],
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{ label: 'Usage', value: '25% · 1.00 KB' },
|
||||
{ label: 'Total', value: '4.00 KB' },
|
||||
{ label: 'Swap', value: '512 B / 1.00 KB' },
|
||||
]);
|
||||
expect(
|
||||
getGuestDrawerMemoryRows(
|
||||
makeGuest({
|
||||
memory: {
|
||||
total: 4096,
|
||||
used: 1024,
|
||||
usage: 0.25,
|
||||
swapTotal: 1024,
|
||||
} as unknown as WorkloadGuest['memory'],
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{ label: 'Usage', value: '25% · 1.00 KB' },
|
||||
{ label: 'Total', value: '4.00 KB' },
|
||||
{ label: 'Swap', value: '0 B / 1.00 KB' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasGuestDrawerFilesystemDetails', () => {
|
||||
it('returns false when disks is absent', () => {
|
||||
expect(hasGuestDrawerFilesystemDetails(makeGuest({ disks: undefined }))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an empty disks array', () => {
|
||||
expect(hasGuestDrawerFilesystemDetails(makeGuest({ disks: [] }))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when at least one disk is present', () => {
|
||||
expect(
|
||||
hasGuestDrawerFilesystemDetails(
|
||||
makeGuest({ disks: [{ total: 1, used: 0, free: 1, usage: 0 }] }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuestDrawerNetworkInterfaces', () => {
|
||||
it('returns an empty array when absent (|| fallback)', () => {
|
||||
expect(getGuestDrawerNetworkInterfaces(makeGuest({ networkInterfaces: undefined }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the same array reference when present', () => {
|
||||
const ifaces = [{ name: 'eth0', mac: 'aa:bb' }];
|
||||
expect(getGuestDrawerNetworkInterfaces(makeGuest({ networkInterfaces: ifaces }))).toBe(ifaces);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeGuestDrawerTags', () => {
|
||||
it('trims and filters an array of tags', () => {
|
||||
expect(normalizeGuestDrawerTags([' a ', '', 'b'])).toEqual(['a', 'b']);
|
||||
expect(normalizeGuestDrawerTags([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('splits, trims, and filters a comma-separated string', () => {
|
||||
expect(normalizeGuestDrawerTags(' a , , b ')).toEqual(['a', 'b']);
|
||||
expect(normalizeGuestDrawerTags('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array for null', () => {
|
||||
expect(normalizeGuestDrawerTags(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuestDrawerBackupPresentation', () => {
|
||||
const now = new Date(2024, 5, 15, 12, 0, 0); // 2024-06-15T12:00:00 local
|
||||
const dayMs = 1000 * 60 * 60 * 24;
|
||||
const backupNDaysAgo = (days: number): Date => new Date(now.getTime() - days * dayMs);
|
||||
|
||||
const expectPresentation = (days: number) => {
|
||||
const lastBackup = backupNDaysAgo(days);
|
||||
const result = getGuestDrawerBackupPresentation(lastBackup, now);
|
||||
expect(result.dateLabel).toBe(new Date(lastBackup).toLocaleDateString());
|
||||
return result;
|
||||
};
|
||||
|
||||
it('labels day 0 as Today and uses the green (fresh) class', () => {
|
||||
expect(expectPresentation(0)).toStrictEqual({
|
||||
ageClass: 'text-green-600 dark:text-green-400',
|
||||
ageLabel: 'Today',
|
||||
dateLabel: new Date(backupNDaysAgo(0)).toLocaleDateString(),
|
||||
});
|
||||
});
|
||||
|
||||
it('labels day 1 as Yesterday', () => {
|
||||
const result = expectPresentation(1);
|
||||
expect(result.ageLabel).toBe('Yesterday');
|
||||
expect(result.ageClass).toBe('text-green-600 dark:text-green-400');
|
||||
});
|
||||
|
||||
it('uses the green class up to and including day 7 (isOld = daysSince > 7)', () => {
|
||||
expect(expectPresentation(7).ageClass).toBe('text-green-600 dark:text-green-400');
|
||||
expect(expectPresentation(7).ageLabel).toBe('7d ago');
|
||||
});
|
||||
|
||||
it('switches to the amber class at day 8', () => {
|
||||
const result = expectPresentation(8);
|
||||
expect(result.ageClass).toBe('text-amber-600 dark:text-amber-400');
|
||||
expect(result.ageLabel).toBe('8d ago');
|
||||
});
|
||||
|
||||
it('keeps the amber class at day 30 (isCritical = daysSince > 30)', () => {
|
||||
expect(expectPresentation(30).ageClass).toBe('text-amber-600 dark:text-amber-400');
|
||||
expect(expectPresentation(30).ageLabel).toBe('30d ago');
|
||||
});
|
||||
|
||||
it('switches to the red (critical) class at day 31', () => {
|
||||
const result = expectPresentation(31);
|
||||
expect(result.ageClass).toBe('text-red-600 dark:text-red-400');
|
||||
expect(result.ageLabel).toBe('31d ago');
|
||||
});
|
||||
|
||||
it('accepts a numeric (ms) lastBackup value', () => {
|
||||
const lastBackup = backupNDaysAgo(10).getTime();
|
||||
const result = getGuestDrawerBackupPresentation(lastBackup, now);
|
||||
expect(result.ageLabel).toBe('10d ago');
|
||||
expect(result.ageClass).toBe('text-amber-600 dark:text-amber-400');
|
||||
expect(result.dateLabel).toBe(new Date(lastBackup).toLocaleDateString());
|
||||
});
|
||||
});
|
||||
});
|
||||
+460
@@ -0,0 +1,460 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Connection, ConnectionFleetGovernance } from '@/api/connections';
|
||||
import { buildWorkloadInventorySourceIssues } from '../workloadInventorySourceIssues';
|
||||
|
||||
const fleet = (overrides: Partial<ConnectionFleetGovernance> = {}): ConnectionFleetGovernance => ({
|
||||
enrollmentState: 'configured',
|
||||
livenessState: 'active',
|
||||
versionDrift: 'not-applicable',
|
||||
adapterHealth: 'healthy',
|
||||
configRollout: 'configured',
|
||||
credentialStatus: 'verified',
|
||||
updateStatus: 'not-applicable',
|
||||
remoteControl: 'not-applicable',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const connection = (overrides: Partial<Connection>): Connection =>
|
||||
({
|
||||
id: 'pve:node',
|
||||
type: 'pve',
|
||||
name: 'node',
|
||||
address: 'https://node:8006',
|
||||
state: 'active',
|
||||
stateReason: '',
|
||||
enabled: true,
|
||||
surfaces: ['vms'],
|
||||
scope: { vms: true },
|
||||
lastSeen: null,
|
||||
lastError: null,
|
||||
source: 'agent',
|
||||
fleet: fleet(),
|
||||
capabilities: {
|
||||
supportsPause: true,
|
||||
supportsScope: true,
|
||||
supportsTest: true,
|
||||
},
|
||||
...overrides,
|
||||
}) as Connection;
|
||||
|
||||
describe('workloadInventorySourceIssues (branch coverage)', () => {
|
||||
describe('credentialInvalid', () => {
|
||||
it('is true when state is unauthorized (first OR arm)', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'pve:unauth',
|
||||
name: 'unauth',
|
||||
state: 'unauthorized',
|
||||
fleet: fleet({ credentialStatus: 'verified' }),
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?.stateLabel).toBe('Credentials invalid');
|
||||
});
|
||||
|
||||
it('is true when fleet.credentialStatus is invalid (second OR arm, active state)', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'pve:badstatus',
|
||||
name: 'badstatus',
|
||||
state: 'active',
|
||||
fleet: fleet({ credentialStatus: 'invalid' }),
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?.stateLabel).toBe('Credentials invalid');
|
||||
});
|
||||
|
||||
it('is true when fleet.credentialHealth.status is invalid (third OR arm)', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'pve:healthbad',
|
||||
name: 'healthbad',
|
||||
state: 'active',
|
||||
fleet: fleet({ credentialHealth: { status: 'invalid' } }),
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?.stateLabel).toBe('Credentials invalid');
|
||||
});
|
||||
|
||||
it('is true when fleet.credentialHealth.status is expired (fourth OR arm)', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'pve:expired',
|
||||
name: 'expired',
|
||||
state: 'active',
|
||||
fleet: fleet({ credentialHealth: { status: 'expired' } }),
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?.stateLabel).toBe('Credentials invalid');
|
||||
});
|
||||
|
||||
it('short-circuits gracefully when fleet is undefined (optional-chain false arm)', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'pve:nofleet',
|
||||
name: 'nofleet',
|
||||
state: 'paused',
|
||||
fleet: undefined as unknown as Connection['fleet'],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?.stateLabel).toBe('Collection paused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stateLabelFor switch arms (credentialInvalid false)', () => {
|
||||
it('maps pending to "Collection pending"', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({ id: 'pve:pend', name: 'pend', state: 'pending' }),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.stateLabel).toBe('Collection pending');
|
||||
});
|
||||
|
||||
it('maps stale to "Collection stale"', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({ id: 'pve:stale', name: 'stale', state: 'stale' }),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.stateLabel).toBe('Collection stale');
|
||||
});
|
||||
|
||||
it('maps unreachable to "Source unreachable"', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'vmware:down',
|
||||
type: 'vmware',
|
||||
name: 'down',
|
||||
state: 'unreachable',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.stateLabel).toBe('Source unreachable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('descriptionFor branches', () => {
|
||||
it('credentialInvalid arm names the type-label API credentials', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'docker:bad',
|
||||
type: 'docker',
|
||||
name: 'dockerhost',
|
||||
state: 'active',
|
||||
surfaces: ['containers'],
|
||||
scope: { containers: true },
|
||||
fleet: fleet({ credentialStatus: 'invalid' }),
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.description).toBe(
|
||||
'Pulse has containers enabled for dockerhost, but its Docker API credentials are invalid.',
|
||||
);
|
||||
});
|
||||
|
||||
it('paused arm says collection is paused', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({ id: 'pve:paused', name: 'paused', state: 'paused' }),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.description).toBe(
|
||||
'Pulse has VMs enabled for paused, but collection is paused.',
|
||||
);
|
||||
});
|
||||
|
||||
it('pending arm says collection has not completed yet', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({ id: 'pve:pend', name: 'pend', state: 'pending' }),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.description).toBe(
|
||||
'Pulse has VMs enabled for pend, but collection has not completed yet.',
|
||||
);
|
||||
});
|
||||
|
||||
it('stale arm says inventory data is stale', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({ id: 'pve:stale', name: 'stale', state: 'stale' }),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.description).toBe(
|
||||
'Pulse has VMs enabled for stale, but the last inventory data is stale.',
|
||||
);
|
||||
});
|
||||
|
||||
it('unreachable arm interpolates the type label before "API is unreachable"', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'vmware:down',
|
||||
type: 'vmware',
|
||||
name: 'vc1',
|
||||
state: 'unreachable',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.description).toBe(
|
||||
'Pulse has VMs enabled for vc1, but the VMware vCenter API is unreachable.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCoverage', () => {
|
||||
it('returns the single label unchanged for one surface (length === 1 arm)', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({ id: 'pve:one', name: 'one', state: 'paused' }),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.coverageLabel).toBe('VMs');
|
||||
});
|
||||
|
||||
it('joins two labels with "and" (length === 2 arm)', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'pve:two',
|
||||
name: 'two',
|
||||
state: 'paused',
|
||||
surfaces: ['vms', 'containers'],
|
||||
scope: { vms: true, containers: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.coverageLabel).toBe('VMs and containers');
|
||||
});
|
||||
|
||||
it('joins three-plus labels with Oxford comma (length >= 3 arm)', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'pve:three',
|
||||
type: 'kubernetes',
|
||||
name: 'three',
|
||||
state: 'paused',
|
||||
surfaces: ['vms', 'containers', 'kubernetes'],
|
||||
scope: { vms: true, containers: true, kubernetes: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.coverageLabel).toBe(
|
||||
'VMs, containers, and Kubernetes workloads',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeWorkloadSurfaces', () => {
|
||||
it('keeps only truthy scope entries and drops surfaces with no label', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'pve:scoped',
|
||||
name: 'scoped',
|
||||
state: 'paused',
|
||||
surfaces: ['vms', 'containers', 'storage'],
|
||||
scope: { vms: true, containers: false, storage: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.coverageLabel).toBe('VMs');
|
||||
});
|
||||
|
||||
it('falls back to connection.surfaces when scope is empty', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'pve:fallback',
|
||||
name: 'fallback',
|
||||
state: 'paused',
|
||||
surfaces: ['vms', 'pods'],
|
||||
scope: {},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.coverageLabel).toBe('VMs and pods');
|
||||
});
|
||||
|
||||
it('falls back to surfaces when scope is undefined (?? {} arm)', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'pve:noscope',
|
||||
name: 'noscope',
|
||||
state: 'paused',
|
||||
surfaces: ['vms'],
|
||||
scope: undefined as unknown as Connection['scope'],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.coverageLabel).toBe('VMs');
|
||||
});
|
||||
|
||||
it('deduplicates surfaces that map to the same label (seen.has arm)', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'docker:dup',
|
||||
type: 'docker',
|
||||
name: 'dup',
|
||||
state: 'paused',
|
||||
surfaces: ['containers', 'docker'],
|
||||
scope: {},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.coverageLabel).toBe('containers');
|
||||
});
|
||||
|
||||
it('sorts an unknown surface via the -1 rank normalization branch', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'pve:unknown',
|
||||
name: 'unknown',
|
||||
state: 'paused',
|
||||
surfaces: ['zzz', 'vms'],
|
||||
scope: {},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.coverageLabel).toBe('VMs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compactDetail', () => {
|
||||
it('returns undefined when no error message is available (!formatted arm)', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'pve:nomsg',
|
||||
name: 'nomsg',
|
||||
state: 'paused',
|
||||
stateReason: '',
|
||||
lastError: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.detail).toBeUndefined();
|
||||
});
|
||||
|
||||
it('formats a short lastError.message (left ?? operand non-null)', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'pve:shorterr',
|
||||
name: 'shorterr',
|
||||
state: 'paused',
|
||||
stateReason: 'should-not-be-used',
|
||||
lastError: {
|
||||
at: '2026-07-12T00:00:00Z',
|
||||
message: 'no such host',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.detail).toBe('Host not found. Check the hostname or IP address.');
|
||||
});
|
||||
|
||||
it('falls back to stateReason when lastError is null (right ?? operand)', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'pve:reason',
|
||||
name: 'reason',
|
||||
state: 'paused',
|
||||
stateReason: 'connection refused',
|
||||
lastError: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues[0]?.detail).toBe(
|
||||
'Connection refused. The host is reachable but rejected the connection on this port. Check the port is correct and the service is running.',
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates a formatted message longer than 220 characters (>220 arm)', () => {
|
||||
const longMessage = 'x'.repeat(300);
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'pve:longerr',
|
||||
name: 'longerr',
|
||||
state: 'paused',
|
||||
lastError: {
|
||||
at: '2026-07-12T00:00:00Z',
|
||||
message: longMessage,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const detail = issues[0]?.detail;
|
||||
expect(detail).toBeDefined();
|
||||
expect(detail?.length).toBe(220);
|
||||
expect(detail).toBe(`${'x'.repeat(217)}...`);
|
||||
expect(detail?.endsWith('...')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWorkloadInventorySourceIssues pipeline', () => {
|
||||
it('excludes disabled, non-workload-type, and active-valid connections', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({ id: 'pve:disabled', name: 'disabled', enabled: false, state: 'unauthorized' }),
|
||||
connection({
|
||||
id: 'pbs:tower',
|
||||
type: 'pbs',
|
||||
name: 'tower',
|
||||
state: 'unreachable',
|
||||
surfaces: ['backups'],
|
||||
scope: { backups: true },
|
||||
}),
|
||||
connection({ id: 'pve:healthy', name: 'healthy', state: 'active' }),
|
||||
connection({ id: 'pve:blocked', name: 'blocked', state: 'stale' }),
|
||||
]);
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?.id).toBe('pve:blocked');
|
||||
});
|
||||
|
||||
it('orders by descending STATE_RANK when states differ', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({ id: 'pve:aaa', name: 'aaa', state: 'paused' }),
|
||||
connection({ id: 'pve:zzz', name: 'zzz', state: 'unreachable' }),
|
||||
]);
|
||||
|
||||
expect(issues.map((issue) => issue.state)).toEqual(['unreachable', 'paused']);
|
||||
expect(issues.map((issue) => issue.name)).toEqual(['zzz', 'aaa']);
|
||||
});
|
||||
|
||||
it('breaks state-rank ties with name localeCompare', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({ id: 'pve:zeta', name: 'zeta', state: 'paused' }),
|
||||
connection({ id: 'pve:alpha', name: 'alpha', state: 'paused' }),
|
||||
]);
|
||||
|
||||
expect(issues.map((issue) => issue.name)).toEqual(['alpha', 'zeta']);
|
||||
});
|
||||
|
||||
it('emits a fully-shaped issue for a kubernetes source', () => {
|
||||
const issues = buildWorkloadInventorySourceIssues([
|
||||
connection({
|
||||
id: 'kubernetes:k1',
|
||||
type: 'kubernetes',
|
||||
name: 'k1',
|
||||
state: 'pending',
|
||||
surfaces: ['kubernetes'],
|
||||
scope: { kubernetes: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(issues).toStrictEqual([
|
||||
{
|
||||
id: 'kubernetes:k1',
|
||||
name: 'k1',
|
||||
type: 'kubernetes',
|
||||
typeLabel: 'Kubernetes',
|
||||
state: 'pending',
|
||||
stateLabel: 'Collection pending',
|
||||
coverageLabel: 'Kubernetes workloads',
|
||||
description:
|
||||
'Pulse has Kubernetes workloads enabled for k1, but collection has not completed yet.',
|
||||
detail: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { WorkloadGuest } from '@/types/workloads';
|
||||
import {
|
||||
computeWorkloadStats,
|
||||
createWorkloadSortComparator,
|
||||
filterWorkloads,
|
||||
getWorkloadGroupLabel,
|
||||
} from '@/components/Workloads/workloadSelectors';
|
||||
|
||||
const makeGuest = (i: number, overrides?: Partial<WorkloadGuest>): WorkloadGuest => ({
|
||||
id: `guest-${i}`,
|
||||
vmid: 100 + i,
|
||||
name: `workload-${i}`,
|
||||
node: `node-${i % 5}`,
|
||||
instance: `cluster-${i % 3}`,
|
||||
status: 'running',
|
||||
type: 'vm',
|
||||
cpu: (i % 100) / 100,
|
||||
cpus: 2,
|
||||
memory: { total: 4096, used: ((i % 80) / 100) * 4096, free: 0, usage: (i % 80) / 100 },
|
||||
disk: { total: 102400, used: ((i % 60) / 100) * 102400, free: 0, usage: (i % 60) / 100 },
|
||||
networkIn: i * 100,
|
||||
networkOut: i * 50,
|
||||
diskRead: i * 10,
|
||||
diskWrite: i * 5,
|
||||
uptime: i * 3600,
|
||||
template: false,
|
||||
lastBackup: 0,
|
||||
tags: [],
|
||||
lock: '',
|
||||
lastSeen: new Date().toISOString(),
|
||||
workloadType: 'vm',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const baseFilterParams = {
|
||||
viewMode: 'all' as const,
|
||||
statusMode: 'all',
|
||||
searchTerm: '',
|
||||
selectedNode: null,
|
||||
selectedHostHint: null,
|
||||
selectedKubernetesContext: null,
|
||||
};
|
||||
|
||||
describe('workloadSelectors (branch coverage 2)', () => {
|
||||
describe('createWorkloadSortComparator', () => {
|
||||
it('routes both-null disk values through the name/id tiebreaker and orders empties last', () => {
|
||||
const nullDisk = { total: 0, used: 50, free: 0, usage: NaN };
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'zz', name: 'zebra', disk: nullDisk }),
|
||||
makeGuest(2, { id: 'aa', name: 'alpha', disk: nullDisk }),
|
||||
makeGuest(3, { id: 'mm', name: 'mid', disk: { total: 100, used: 20, free: 80, usage: 0.2 } }),
|
||||
makeGuest(4, { id: 'bb', name: 'bravo', disk: { total: 100, used: 90, free: 10, usage: 0.9 } }),
|
||||
];
|
||||
|
||||
const diskAsc = createWorkloadSortComparator('disk', 'asc');
|
||||
const diskDesc = createWorkloadSortComparator('disk', 'desc');
|
||||
|
||||
// Non-empty values sort first (asc: mid=20, bravo=90); both-null pair falls
|
||||
// through aIsEmpty && bIsEmpty -> tiebreak (alpha < zebra).
|
||||
expect([...guests].sort(diskAsc!).map((g) => g.id)).toEqual(['mm', 'bb', 'aa', 'zz']);
|
||||
// desc only flips the non-empty comparison; empties still sort last by name asc.
|
||||
expect([...guests].sort(diskDesc!).map((g) => g.id)).toEqual(['bb', 'mm', 'aa', 'zz']);
|
||||
});
|
||||
|
||||
it('uses the name/id tiebreaker when a generic key is undefined on every guest', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'b', name: 'beta' }),
|
||||
makeGuest(2, { id: 'a', name: 'alpha' }),
|
||||
];
|
||||
|
||||
const cmp = createWorkloadSortComparator('nonexistentField', 'asc');
|
||||
// Every value is undefined -> both empty -> tiebreak by name (alpha < beta).
|
||||
expect([...guests].sort(cmp!).map((g) => g.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('returns 0 when numeric values are equal and both names and ids match', () => {
|
||||
const a = makeGuest(1, { id: 'dup', name: 'same', cpu: 0.5 });
|
||||
const b = makeGuest(2, { id: 'dup', name: 'same', cpu: 0.5 });
|
||||
|
||||
const cmp = createWorkloadSortComparator('cpu', 'asc');
|
||||
// Equal numeric values -> tiebreak; equal names -> id compare; equal ids -> 0.
|
||||
expect(cmp!(a, b)).toBe(0);
|
||||
expect(cmp!(b, a)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterWorkloads', () => {
|
||||
it('skips the node-scope filter in pod view mode so pods on other nodes survive', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'pod-a',
|
||||
name: 'api',
|
||||
type: 'pod',
|
||||
workloadType: 'pod',
|
||||
node: 'worker-a',
|
||||
instance: 'ctx',
|
||||
contextLabel: 'ctx',
|
||||
namespace: 'default',
|
||||
status: 'running',
|
||||
}),
|
||||
makeGuest(2, {
|
||||
id: 'pod-b',
|
||||
name: 'web',
|
||||
type: 'pod',
|
||||
workloadType: 'pod',
|
||||
node: 'worker-b',
|
||||
instance: 'ctx',
|
||||
contextLabel: 'ctx',
|
||||
namespace: 'default',
|
||||
status: 'running',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterWorkloads({
|
||||
...baseFilterParams,
|
||||
guests,
|
||||
viewMode: 'pod',
|
||||
selectedNode: 'worker-a',
|
||||
});
|
||||
|
||||
// workloadHostScopeId(pod) === '' would remove both if the node filter ran;
|
||||
// because viewMode === 'pod' the guard is skipped and both pods remain.
|
||||
expect(result.map((g) => g.id)).toEqual(['pod-a', 'pod-b']);
|
||||
});
|
||||
|
||||
it('excludes non-pod guests during kubernetes namespace filtering in pod view', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'pod-a',
|
||||
name: 'api',
|
||||
type: 'pod',
|
||||
workloadType: 'pod',
|
||||
node: 'worker-a',
|
||||
instance: 'ctx',
|
||||
contextLabel: 'ctx',
|
||||
namespace: 'payments',
|
||||
status: 'running',
|
||||
}),
|
||||
makeGuest(2, {
|
||||
id: 'vm-a',
|
||||
name: 'vm',
|
||||
type: 'vm',
|
||||
workloadType: 'vm',
|
||||
node: 'node-a',
|
||||
instance: 'inst-a',
|
||||
namespace: 'payments',
|
||||
status: 'running',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterWorkloads({
|
||||
...baseFilterParams,
|
||||
guests,
|
||||
viewMode: 'pod',
|
||||
selectedKubernetesNamespace: 'payments',
|
||||
});
|
||||
|
||||
// The vm hits `resolveWorkloadType(g) !== 'pod' -> return false` in the
|
||||
// namespace filter and is dropped (it would also be dropped by the later
|
||||
// view-mode filter, but this exercises the guard's true arm).
|
||||
expect(result.map((g) => g.id)).toEqual(['pod-a']);
|
||||
});
|
||||
|
||||
it('excludes system-containers from the runtime filter under the combined container view mode', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'app-1',
|
||||
name: 'redis',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
containerRuntime: 'docker',
|
||||
contextLabel: 'host-a',
|
||||
node: '',
|
||||
instance: '',
|
||||
status: 'running',
|
||||
}),
|
||||
makeGuest(2, {
|
||||
id: 'lxc-1',
|
||||
name: 'db',
|
||||
type: 'lxc',
|
||||
workloadType: 'system-container',
|
||||
instance: 'inst-a',
|
||||
node: 'node-a',
|
||||
containerRuntime: 'docker',
|
||||
status: 'running',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterWorkloads({
|
||||
...baseFilterParams,
|
||||
guests,
|
||||
viewMode: 'container',
|
||||
containerRuntime: 'docker',
|
||||
});
|
||||
|
||||
// 'container' view mode keeps both app- and system-containers via
|
||||
// workloadMatchesViewMode, so the system-container reaches the runtime
|
||||
// filter where `resolveWorkloadType(g) !== 'app-container' -> return false`.
|
||||
expect(result.map((g) => g.id)).toEqual(['app-1']);
|
||||
});
|
||||
|
||||
it('does not apply the platform filter when the normalized platform is "all"', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'truenas-1',
|
||||
name: 'nextcloud',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformScopes: ['truenas'],
|
||||
contextLabel: 'host-a',
|
||||
node: '',
|
||||
instance: '',
|
||||
status: 'running',
|
||||
}),
|
||||
makeGuest(2, {
|
||||
id: 'docker-1',
|
||||
name: 'grafana',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformScopes: ['docker'],
|
||||
contextLabel: 'host-b',
|
||||
node: '',
|
||||
instance: '',
|
||||
status: 'running',
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterWorkloads({
|
||||
...baseFilterParams,
|
||||
guests,
|
||||
selectedPlatform: 'all',
|
||||
});
|
||||
|
||||
// normalizeSourcePlatformQueryValue('all') === 'all' -> filter skipped.
|
||||
expect(result.map((g) => g.id)).toEqual(['truenas-1', 'docker-1']);
|
||||
});
|
||||
|
||||
it('counts an empty-status guest as degraded via the (status || "") fallback in degraded mode', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'empty', name: 'empty', status: '' }),
|
||||
makeGuest(2, { id: 'running', name: 'running', status: 'running' }),
|
||||
makeGuest(3, { id: 'offline', name: 'offline', status: 'offline' }),
|
||||
];
|
||||
|
||||
const result = filterWorkloads({
|
||||
...baseFilterParams,
|
||||
guests,
|
||||
statusMode: 'degraded',
|
||||
});
|
||||
|
||||
// '' -> toLowerCase '' -> not DEGRADED, !== 'running', not OFFLINE -> degraded.
|
||||
expect(result.map((g) => g.id)).toEqual(['empty']);
|
||||
});
|
||||
|
||||
it('matches status case-sensitively in running mode (capital "Running" is excluded)', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'capital', name: 'capital', status: 'Running' }),
|
||||
makeGuest(2, { id: 'lower', name: 'lower', status: 'running' }),
|
||||
];
|
||||
|
||||
const result = filterWorkloads({
|
||||
...baseFilterParams,
|
||||
guests,
|
||||
statusMode: 'running',
|
||||
});
|
||||
|
||||
// g.status === 'running' is an exact, case-sensitive comparison.
|
||||
expect(result.map((g) => g.id)).toEqual(['lower']);
|
||||
});
|
||||
|
||||
it('matches the numeric vmid and string status/instance text-search candidates', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'g1', name: 'numhost', vmid: 7777, status: 'running' }),
|
||||
makeGuest(2, { id: 'g2', name: 'stathost', vmid: 1, status: 'quarantined' }),
|
||||
makeGuest(3, { id: 'g3', name: 'insthost', vmid: 2, status: 'running', instance: 'insttoken' }),
|
||||
];
|
||||
|
||||
// vmid is a number candidate -> filtered into the String(value) match.
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: '7777' }).map((g) => g.id),
|
||||
).toEqual(['g1']);
|
||||
// status candidate.
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: 'quarantined' }).map((g) => g.id),
|
||||
).toEqual(['g2']);
|
||||
// instance candidate.
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: 'insttoken' }).map((g) => g.id),
|
||||
).toEqual(['g3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesWorkloadTextSearch (exercised via filterWorkloads)', () => {
|
||||
it('excludes a guest whose only matching candidate is an empty platformScopes array', () => {
|
||||
// platformScopes is joined with ' ' -> '' for an empty array; the type
|
||||
// guard inside matchesWorkloadTextSearch filters non-string/number values,
|
||||
// and an empty joined string never satisfies .includes(needle).
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'scope-1',
|
||||
name: 'alpha',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformScopes: [],
|
||||
status: 'running',
|
||||
}),
|
||||
makeGuest(2, {
|
||||
id: 'scope-2',
|
||||
name: 'beta',
|
||||
type: 'app-container',
|
||||
workloadType: 'app-container',
|
||||
platformScopes: ['uniquematch'],
|
||||
status: 'running',
|
||||
}),
|
||||
];
|
||||
|
||||
expect(
|
||||
filterWorkloads({ ...baseFilterParams, guests, searchTerm: 'uniquematch' }).map((g) => g.id),
|
||||
).toEqual(['scope-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWorkloadGroupLabel', () => {
|
||||
it('returns empty type and the raw context for a vm guest with no node and no cluster', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'solo',
|
||||
name: 'solo',
|
||||
type: 'vm',
|
||||
workloadType: 'vm',
|
||||
instance: 'solo',
|
||||
node: '',
|
||||
clusterName: '',
|
||||
}),
|
||||
];
|
||||
|
||||
// groupKey = 'solo-' (no colon) -> prefix not recognized -> first guest has
|
||||
// no node and no cluster -> final fallback { type: '', name: context }.
|
||||
expect(getWorkloadGroupLabel('', guests)).toStrictEqual({ type: '', name: 'solo-' });
|
||||
});
|
||||
|
||||
it('joins a multi-segment context after a recognized prefix for an empty guests array', () => {
|
||||
// normalizedGroupKey = groupKey; split(':') -> ['pod', 'ctx', 'extra'];
|
||||
// rest joins back to 'ctx:extra'.
|
||||
expect(getWorkloadGroupLabel('pod:ctx:extra', [])).toStrictEqual({
|
||||
type: 'Pods',
|
||||
name: 'ctx:extra',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeWorkloadStats', () => {
|
||||
it('classifies empty and non-standard statuses as degraded via the second condition', () => {
|
||||
const guests = [
|
||||
makeGuest(1, { id: 'empty', name: 'empty', status: '', type: 'vm', workloadType: 'vm' }),
|
||||
makeGuest(2, {
|
||||
id: 'custom',
|
||||
name: 'custom',
|
||||
status: 'quarantined',
|
||||
type: 'vm',
|
||||
workloadType: 'vm',
|
||||
}),
|
||||
makeGuest(3, { id: 'running', name: 'running', status: 'running', type: 'vm', workloadType: 'vm' }),
|
||||
makeGuest(4, { id: 'offline', name: 'offline', status: 'offline', type: 'vm', workloadType: 'vm' }),
|
||||
];
|
||||
|
||||
// '' and 'quarantined' are neither DEGRADED nor OFFLINE nor running, so the
|
||||
// (status !== 'running' && !OFFLINE) second condition counts them degraded.
|
||||
expect(computeWorkloadStats(guests)).toStrictEqual({
|
||||
total: 4,
|
||||
running: 1,
|
||||
degraded: 2,
|
||||
stopped: 1,
|
||||
vms: 4,
|
||||
containers: 0,
|
||||
appContainers: 0,
|
||||
pods: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies a capitalized "Running" status as stopped due to case-sensitive running check', () => {
|
||||
const guests = [
|
||||
makeGuest(1, {
|
||||
id: 'capital',
|
||||
name: 'capital',
|
||||
status: 'Running',
|
||||
type: 'vm',
|
||||
workloadType: 'vm',
|
||||
}),
|
||||
];
|
||||
|
||||
// g.status === 'running' is exact; the degraded check lowercases, so
|
||||
// 'Running' is neither running nor degraded and lands in stopped.
|
||||
expect(computeWorkloadStats(guests)).toStrictEqual({
|
||||
total: 1,
|
||||
running: 0,
|
||||
degraded: 0,
|
||||
stopped: 1,
|
||||
vms: 1,
|
||||
containers: 0,
|
||||
appContainers: 0,
|
||||
pods: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { DockerContainerUpdateStatus } from '@/types/api';
|
||||
import {
|
||||
getContainerUpdateBadgeTooltip,
|
||||
getUpdateIconTooltip,
|
||||
getUpdateButtonTooltip,
|
||||
getUpdateButtonLabel,
|
||||
getUpdateButtonClass,
|
||||
getContainerUpdateErrorTooltip,
|
||||
getContainerUpdateCurrentTooltip,
|
||||
} from '@/components/shared/containerUpdateBadgeModel';
|
||||
import type { UpdateState } from '@/components/shared/containerUpdateBadgeModel';
|
||||
|
||||
// Mirrors the module-private UPDATE_BUTTON_BASE_CLASS so the class assertions
|
||||
// are exact-string equality rather than substring/truthiness checks.
|
||||
const UPDATE_BUTTON_BASE_CLASS =
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium transition-all';
|
||||
|
||||
// A digest comfortably longer than the longest preview length used (19) so the
|
||||
// truncation arms are genuinely exercised. 'sha256:' is 7 chars.
|
||||
const LONG_DIGEST = 'sha256:' + 'a'.repeat(40);
|
||||
const DIGEST_FIRST_19 = 'sha256:' + 'a'.repeat(12); // 7 + 12 = 19
|
||||
const DIGEST_FIRST_12 = 'sha256:' + 'a'.repeat(5); // 7 + 5 = 12
|
||||
|
||||
function makeUpdateStatus(
|
||||
overrides: Partial<DockerContainerUpdateStatus> = {},
|
||||
): DockerContainerUpdateStatus {
|
||||
return {
|
||||
updateAvailable: true,
|
||||
lastChecked: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('containerUpdateBadgeModel.branchcov2', () => {
|
||||
describe('getContainerUpdateBadgeTooltip', () => {
|
||||
it('falls back to "unknown" for both digests when updateStatus is undefined', () => {
|
||||
expect(getContainerUpdateBadgeTooltip(undefined)).toBe(
|
||||
'Image update available\nCurrent: unknown...\nLatest: unknown...',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to "unknown" when both digests are absent on a defined status', () => {
|
||||
expect(getContainerUpdateBadgeTooltip(makeUpdateStatus())).toBe(
|
||||
'Image update available\nCurrent: unknown...\nLatest: unknown...',
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates both digests to 19 characters', () => {
|
||||
const status = makeUpdateStatus({
|
||||
currentDigest: LONG_DIGEST,
|
||||
latestDigest: LONG_DIGEST,
|
||||
});
|
||||
expect(getContainerUpdateBadgeTooltip(status)).toBe(
|
||||
`Image update available\nCurrent: ${DIGEST_FIRST_19}...\nLatest: ${DIGEST_FIRST_19}...`,
|
||||
);
|
||||
});
|
||||
|
||||
it('mixes a present currentDigest with an absent latestDigest', () => {
|
||||
const status = makeUpdateStatus({
|
||||
currentDigest: LONG_DIGEST,
|
||||
latestDigest: undefined,
|
||||
});
|
||||
expect(getContainerUpdateBadgeTooltip(status)).toBe(
|
||||
`Image update available\nCurrent: ${DIGEST_FIRST_19}...\nLatest: unknown...`,
|
||||
);
|
||||
});
|
||||
|
||||
it('treats empty-string digests as unknown (|| fallback)', () => {
|
||||
const status = makeUpdateStatus({ currentDigest: '', latestDigest: '' });
|
||||
expect(getContainerUpdateBadgeTooltip(status)).toBe(
|
||||
'Image update available\nCurrent: unknown...\nLatest: unknown...',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUpdateIconTooltip', () => {
|
||||
it('returns the short tooltip via the early return when updateStatus is undefined', () => {
|
||||
expect(getUpdateIconTooltip(undefined)).toBe('Image update available');
|
||||
});
|
||||
|
||||
it('falls back to "unknown" for both digests on a defined status with no digests', () => {
|
||||
expect(getUpdateIconTooltip(makeUpdateStatus())).toBe(
|
||||
'Update available\nCurrent: unknown...\nLatest: unknown...',
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates both digests to 12 characters (icon uses a shorter preview than the badge)', () => {
|
||||
const status = makeUpdateStatus({
|
||||
currentDigest: LONG_DIGEST,
|
||||
latestDigest: LONG_DIGEST,
|
||||
});
|
||||
expect(getUpdateIconTooltip(status)).toBe(
|
||||
`Update available\nCurrent: ${DIGEST_FIRST_12}...\nLatest: ${DIGEST_FIRST_12}...`,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty-string digests via the || fallback', () => {
|
||||
const status = makeUpdateStatus({ currentDigest: '', latestDigest: '' });
|
||||
expect(getUpdateIconTooltip(status)).toBe(
|
||||
'Update available\nCurrent: unknown...\nLatest: unknown...',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUpdateButtonTooltip', () => {
|
||||
it('returns the confirm prompt for the "confirming" state', () => {
|
||||
expect(
|
||||
getUpdateButtonTooltip({ state: 'confirming', now: 1000 }),
|
||||
).toBe('Click again to confirm update');
|
||||
});
|
||||
|
||||
it('returns the success message for the "success" state', () => {
|
||||
expect(getUpdateButtonTooltip({ state: 'success', now: 1000 })).toBe(
|
||||
'✓ Update completed successfully!',
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults step to "Processing..." and elapsed to 0 when storeState is missing (updating)', () => {
|
||||
expect(
|
||||
getUpdateButtonTooltip({ state: 'updating', now: 100_000 }),
|
||||
).toBe('Processing... (0s)');
|
||||
});
|
||||
|
||||
it('uses storeState.message and computes elapsed seconds when elapsed <= 60', () => {
|
||||
const result = getUpdateButtonTooltip({
|
||||
state: 'updating',
|
||||
now: 100_000,
|
||||
storeState: { startedAt: 95_000, message: 'Pulling layers' },
|
||||
});
|
||||
expect(result).toBe('Pulling layers (5s)');
|
||||
});
|
||||
|
||||
it('falls back to "Processing..." when storeState exists but message is absent/empty', () => {
|
||||
expect(
|
||||
getUpdateButtonTooltip({
|
||||
state: 'updating',
|
||||
now: 100_000,
|
||||
storeState: { startedAt: 100_000 },
|
||||
}),
|
||||
).toBe('Processing... (0s)');
|
||||
|
||||
expect(
|
||||
getUpdateButtonTooltip({
|
||||
state: 'updating',
|
||||
now: 100_000,
|
||||
storeState: { startedAt: 95_000, message: '' },
|
||||
}),
|
||||
).toBe('Processing... (5s)');
|
||||
});
|
||||
|
||||
it('formats as minutes+seconds when elapsed > 60', () => {
|
||||
const result = getUpdateButtonTooltip({
|
||||
state: 'updating',
|
||||
now: 1_000_000,
|
||||
storeState: { startedAt: 875_000, message: 'Extracting' },
|
||||
});
|
||||
// elapsed = round(125000/1000) = 125 -> 2m 5s
|
||||
expect(result).toBe('Extracting (2m 5s)');
|
||||
});
|
||||
|
||||
it('keeps the seconds format exactly at the 60-second boundary (not > 60)', () => {
|
||||
const result = getUpdateButtonTooltip({
|
||||
state: 'updating',
|
||||
now: 1_000_000,
|
||||
storeState: { startedAt: 940_000, message: 'Working' },
|
||||
});
|
||||
// elapsed = 60 -> not > 60 -> seconds form
|
||||
expect(result).toBe('Working (60s)');
|
||||
});
|
||||
|
||||
it('falls back to Date.now() for `now` when omitted (?? right arm) — observed via updating elapsed', () => {
|
||||
const spy = vi.spyOn(Date, 'now').mockReturnValue(200_000);
|
||||
try {
|
||||
const result = getUpdateButtonTooltip({
|
||||
state: 'updating',
|
||||
storeState: { startedAt: 150_000, message: 'Working' },
|
||||
});
|
||||
// elapsed = round((200000 - 150000)/1000) = 50
|
||||
expect(result).toBe('Working (50s)');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses storeState.message for the error tooltip when present', () => {
|
||||
expect(
|
||||
getUpdateButtonTooltip({
|
||||
state: 'error',
|
||||
now: 1000,
|
||||
storeState: { startedAt: 0, message: 'image not found' },
|
||||
}),
|
||||
).toBe('✗ Update failed: image not found');
|
||||
});
|
||||
|
||||
it('falls back to errorMessage when storeState.message is missing (error)', () => {
|
||||
expect(
|
||||
getUpdateButtonTooltip({
|
||||
state: 'error',
|
||||
now: 1000,
|
||||
errorMessage: 'network timeout',
|
||||
}),
|
||||
).toBe('✗ Update failed: network timeout');
|
||||
});
|
||||
|
||||
it('falls back to "Unknown error" when neither storeState.message nor errorMessage is set', () => {
|
||||
expect(getUpdateButtonTooltip({ state: 'error', now: 1000 })).toBe(
|
||||
'✗ Update failed: Unknown error',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns "Update container" for the default state when updateStatus is absent', () => {
|
||||
expect(getUpdateButtonTooltip({ state: 'idle', now: 1000 })).toBe(
|
||||
'Update container',
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates digests to 12 chars for the default state when updateStatus is present', () => {
|
||||
const result = getUpdateButtonTooltip({
|
||||
state: 'idle',
|
||||
now: 1000,
|
||||
updateStatus: makeUpdateStatus({
|
||||
currentDigest: LONG_DIGEST,
|
||||
latestDigest: LONG_DIGEST,
|
||||
}),
|
||||
});
|
||||
expect(result).toBe(
|
||||
`Click to update\nCurrent: ${DIGEST_FIRST_12}...\nLatest: ${DIGEST_FIRST_12}...`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUpdateButtonLabel', () => {
|
||||
it('short-circuits to "Update" when settingsLoaded is false, regardless of state', () => {
|
||||
const states: UpdateState[] = [
|
||||
'confirming',
|
||||
'updating',
|
||||
'success',
|
||||
'error',
|
||||
'idle',
|
||||
];
|
||||
for (const state of states) {
|
||||
expect(getUpdateButtonLabel(state, false)).toBe('Update');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns "Confirm?" for the confirming state', () => {
|
||||
expect(getUpdateButtonLabel('confirming', true)).toBe('Confirm?');
|
||||
});
|
||||
|
||||
it('returns "Updating..." for the updating state', () => {
|
||||
expect(getUpdateButtonLabel('updating', true)).toBe('Updating...');
|
||||
});
|
||||
|
||||
it('returns "Queued!" for the success state', () => {
|
||||
expect(getUpdateButtonLabel('success', true)).toBe('Queued!');
|
||||
});
|
||||
|
||||
it('returns "Failed" for the error state', () => {
|
||||
expect(getUpdateButtonLabel('error', true)).toBe('Failed');
|
||||
});
|
||||
|
||||
it('returns "Update" for the default (idle) state', () => {
|
||||
expect(getUpdateButtonLabel('idle', true)).toBe('Update');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUpdateButtonClass', () => {
|
||||
it('returns the amber confirming classes with pointer hover', () => {
|
||||
expect(getUpdateButtonClass('confirming')).toBe(
|
||||
`${UPDATE_BUTTON_BASE_CLASS} bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300 cursor-pointer hover:bg-amber-200 dark:hover:bg-amber-900`,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the blue updating classes with cursor-wait', () => {
|
||||
expect(getUpdateButtonClass('updating')).toBe(
|
||||
`${UPDATE_BUTTON_BASE_CLASS} bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 cursor-wait`,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the green success classes with no special cursor', () => {
|
||||
expect(getUpdateButtonClass('success')).toBe(
|
||||
`${UPDATE_BUTTON_BASE_CLASS} bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300`,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the red error classes with cursor-help', () => {
|
||||
expect(getUpdateButtonClass('error')).toBe(
|
||||
`${UPDATE_BUTTON_BASE_CLASS} bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 cursor-help`,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the blue idle/default classes with pointer hover', () => {
|
||||
expect(getUpdateButtonClass('idle')).toBe(
|
||||
`${UPDATE_BUTTON_BASE_CLASS} bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// getDigestPreview is module-private (not exported), so its branches are
|
||||
// exercised transitively through the public callers above and below. The
|
||||
// empty-string / undefined / truncation arms are all reached via the badge,
|
||||
// icon, current, and button-default tooltip tests.
|
||||
describe('getDigestPreview (via public callers)', () => {
|
||||
it('returns "unknown" for an undefined digest (badge tooltip, undefined status)', () => {
|
||||
expect(getContainerUpdateBadgeTooltip(undefined)).toContain(
|
||||
'Current: unknown...',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the full digest when it is shorter than the preview length', () => {
|
||||
const shortDigest = 'sha256:abc'; // 10 chars < 19
|
||||
const status = makeUpdateStatus({
|
||||
currentDigest: shortDigest,
|
||||
latestDigest: shortDigest,
|
||||
});
|
||||
expect(getContainerUpdateBadgeTooltip(status)).toBe(
|
||||
'Image update available\nCurrent: sha256:abc...\nLatest: sha256:abc...',
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates at exactly the requested length (12 for icon, 19 for badge)', () => {
|
||||
const status = makeUpdateStatus({
|
||||
currentDigest: LONG_DIGEST,
|
||||
latestDigest: LONG_DIGEST,
|
||||
});
|
||||
expect(getContainerUpdateBadgeTooltip(status)).toContain(
|
||||
`Current: ${DIGEST_FIRST_19}...`,
|
||||
);
|
||||
expect(getUpdateIconTooltip(status)).toContain(
|
||||
`Current: ${DIGEST_FIRST_12}...`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContainerUpdateErrorTooltip', () => {
|
||||
it('falls back to "Unknown error" when updateStatus is undefined', () => {
|
||||
expect(getContainerUpdateErrorTooltip(undefined)).toBe(
|
||||
'Update check failed: Unknown error',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to "Unknown error" when error is absent on a defined status', () => {
|
||||
expect(getContainerUpdateErrorTooltip(makeUpdateStatus())).toBe(
|
||||
'Update check failed: Unknown error',
|
||||
);
|
||||
});
|
||||
|
||||
it('embeds the concrete error message when present', () => {
|
||||
const status = makeUpdateStatus({ error: 'registry unreachable' });
|
||||
expect(getContainerUpdateErrorTooltip(status)).toBe(
|
||||
'Update check failed: registry unreachable',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to "Unknown error" for an empty-string error (|| falsy arm)', () => {
|
||||
const status = makeUpdateStatus({ error: '' });
|
||||
expect(getContainerUpdateErrorTooltip(status)).toBe(
|
||||
'Update check failed: Unknown error',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContainerUpdateCurrentTooltip', () => {
|
||||
it('returns the plain "Image is current" via the early return when updateStatus is undefined', () => {
|
||||
expect(getContainerUpdateCurrentTooltip(undefined)).toBe('Image is current');
|
||||
});
|
||||
|
||||
it('takes the early return when currentDigest is absent', () => {
|
||||
expect(getContainerUpdateCurrentTooltip(makeUpdateStatus())).toBe(
|
||||
'Image is current',
|
||||
);
|
||||
});
|
||||
|
||||
it('takes the early return for an empty-string currentDigest (falsy guard)', () => {
|
||||
const status = makeUpdateStatus({ currentDigest: '' });
|
||||
expect(getContainerUpdateCurrentTooltip(status)).toBe('Image is current');
|
||||
});
|
||||
|
||||
it('appends a 12-char digest preview when currentDigest is present', () => {
|
||||
const status = makeUpdateStatus({ currentDigest: LONG_DIGEST });
|
||||
expect(getContainerUpdateCurrentTooltip(status)).toBe(
|
||||
`Image is current\nDigest: ${DIGEST_FIRST_12}...`,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the full digest (no truncation effect) when it is shorter than 12', () => {
|
||||
const status = makeUpdateStatus({ currentDigest: 'sha256:xy' });
|
||||
expect(getContainerUpdateCurrentTooltip(status)).toBe(
|
||||
'Image is current\nDigest: sha256:xy...',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AggregatedMetricPoint, HistoryTimeRange } from '@/api/charts';
|
||||
import {
|
||||
createHistoryChartGeometry,
|
||||
findHistoryChartClosestPoint,
|
||||
formatHistoryChartTimeLabel,
|
||||
formatHistoryChartTooltipValue,
|
||||
getHistoryChartDataMax,
|
||||
getHistoryChartDataMin,
|
||||
getHistoryChartDefaultColor,
|
||||
getHistoryChartRefreshIntervalMs,
|
||||
getHistoryChartScale,
|
||||
getHistoryChartTooltipLayout,
|
||||
getHistoryChartYAxisLabels,
|
||||
} from '@/components/shared/historyChartModel';
|
||||
|
||||
const pt = (
|
||||
timestamp: number,
|
||||
value: number,
|
||||
min: number,
|
||||
max: number,
|
||||
): AggregatedMetricPoint => ({ timestamp, value, min, max });
|
||||
|
||||
describe('formatHistoryChartTooltipValue', () => {
|
||||
it('formats percentage units with one decimal', () => {
|
||||
expect(formatHistoryChartTooltipValue(42.35, '%')).toBe('42.4%');
|
||||
});
|
||||
|
||||
it('formats byte-rate units via formatBytes with a /s suffix', () => {
|
||||
expect(formatHistoryChartTooltipValue(1024, 'B/s')).toBe('1.00 KB/s');
|
||||
});
|
||||
|
||||
it('formats celsius units by rounding to the nearest degree', () => {
|
||||
expect(formatHistoryChartTooltipValue(23.6, 'C')).toBe('24°C');
|
||||
});
|
||||
|
||||
it('falls back to raw formatBytes output when unit is undefined', () => {
|
||||
expect(formatHistoryChartTooltipValue(0)).toBe('0 B');
|
||||
expect(formatHistoryChartTooltipValue(2048)).toBe('2.00 KB');
|
||||
});
|
||||
|
||||
it('treats an empty-string unit the same as no unit', () => {
|
||||
expect(formatHistoryChartTooltipValue(2048, '')).toBe('2.00 KB');
|
||||
});
|
||||
|
||||
it('renders integer values for arbitrary units without decimals', () => {
|
||||
expect(formatHistoryChartTooltipValue(42, 'rpm')).toBe('42 rpm');
|
||||
});
|
||||
|
||||
it('renders fractional values for arbitrary units with one decimal', () => {
|
||||
expect(formatHistoryChartTooltipValue(42.5, 'rpm')).toBe('42.5 rpm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHistoryChartRefreshIntervalMs', () => {
|
||||
it.each<[HistoryTimeRange, number]>([
|
||||
['7d', 30000],
|
||||
['14d', 30000],
|
||||
['30d', 60000],
|
||||
['90d', 120000],
|
||||
['1h', 10000],
|
||||
['30m', 10000],
|
||||
])('returns the expected refresh interval for range %s', (range, expected) => {
|
||||
expect(getHistoryChartRefreshIntervalMs(range)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHistoryChartDefaultColor', () => {
|
||||
it('returns the explicit color override when provided', () => {
|
||||
expect(getHistoryChartDefaultColor('cpu', '#custom')).toBe('#custom');
|
||||
});
|
||||
|
||||
it.each<[string, string]>([
|
||||
['cpu', '#8b5cf6'],
|
||||
['memory', '#f59e0b'],
|
||||
['disk', '#10b981'],
|
||||
['network', '#3b82f6'],
|
||||
])('uses the metric-specific default color for %s', (metric, color) => {
|
||||
expect(getHistoryChartDefaultColor(metric)).toBe(color);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHistoryChartDataMin', () => {
|
||||
it('returns null for an empty point set', () => {
|
||||
expect(getHistoryChartDataMin([])).toBeNull();
|
||||
});
|
||||
|
||||
it('uses point.min when it is present (including zero)', () => {
|
||||
const points = [pt(1, 100, 10, 200), pt(2, 50, 5, 60)];
|
||||
expect(getHistoryChartDataMin(points)).toBe(5);
|
||||
});
|
||||
|
||||
it('keeps a zero min because the null-check guards against falsy zero', () => {
|
||||
const points = [pt(1, 100, 0, 200), pt(2, 50, 5, 60)];
|
||||
expect(getHistoryChartDataMin(points)).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to point.value when min is null', () => {
|
||||
const nullMin = { timestamp: 1, value: 7, min: null, max: null } as unknown as AggregatedMetricPoint;
|
||||
const value = { timestamp: 2, value: 3, min: null, max: null } as unknown as AggregatedMetricPoint;
|
||||
expect(getHistoryChartDataMin([nullMin, value])).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHistoryChartDataMax', () => {
|
||||
it('returns null for an empty point set', () => {
|
||||
expect(getHistoryChartDataMax([])).toBeNull();
|
||||
});
|
||||
|
||||
it('uses point.max when it is present (including zero)', () => {
|
||||
const points = [pt(1, 100, 10, 200), pt(2, 50, 5, 60)];
|
||||
expect(getHistoryChartDataMax(points)).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps a zero max because the null-check guards against falsy zero', () => {
|
||||
const points = [pt(1, 100, 0, 0), pt(2, 50, 5, 60)];
|
||||
expect(getHistoryChartDataMax(points)).toBe(60);
|
||||
});
|
||||
|
||||
it('falls back to point.value when max is null', () => {
|
||||
const low = { timestamp: 1, value: 7, min: null, max: null } as unknown as AggregatedMetricPoint;
|
||||
const high = { timestamp: 2, value: 99, min: null, max: null } as unknown as AggregatedMetricPoint;
|
||||
expect(getHistoryChartDataMax([low, high])).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHistoryChartScale', () => {
|
||||
it('returns the 0..100 baseline when there are no points and no unit (byte-like)', () => {
|
||||
expect(getHistoryChartScale([])).toStrictEqual({
|
||||
isPercentLike: false,
|
||||
isByteLike: true,
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps percent-like scales to at least 100 even with low rawMax', () => {
|
||||
expect(getHistoryChartScale([pt(1, 10, 0, 30)], '%')).toStrictEqual({
|
||||
isPercentLike: true,
|
||||
isByteLike: false,
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets percent-like scales exceed 100 when rawMax is larger', () => {
|
||||
expect(getHistoryChartScale([pt(1, 10, 0, 150)], '%').maxValue).toBe(150);
|
||||
});
|
||||
|
||||
it('applies the 1.15 headroom factor for byte-rate units', () => {
|
||||
expect(getHistoryChartScale([pt(1, 0, 0, 100)], 'B/s').maxValue).toBeCloseTo(115, 10);
|
||||
});
|
||||
|
||||
it('treats an undefined unit as byte-like and applies the headroom factor', () => {
|
||||
const scale = getHistoryChartScale([pt(1, 0, 0, 10)]);
|
||||
expect(scale.isByteLike).toBe(true);
|
||||
expect(scale.maxValue).toBeCloseTo(11.5, 6);
|
||||
});
|
||||
|
||||
it('falls back to point.value for rawMax when point.max is falsy zero', () => {
|
||||
expect(getHistoryChartScale([pt(1, 50, 0, 0)], 'B/s').maxValue).toBeCloseTo(57.5, 10);
|
||||
});
|
||||
|
||||
it('clamps non-byte, non-percent scales to a minimum of 1', () => {
|
||||
expect(getHistoryChartScale([pt(1, 0, 0, 0)], 'rpm').maxValue).toBe(1);
|
||||
});
|
||||
|
||||
it('marks arbitrary units as neither byte-like nor percent-like', () => {
|
||||
expect(getHistoryChartScale([pt(1, 0, 0, 100)], 'rpm').isPercentLike).toBe(false);
|
||||
expect(getHistoryChartScale([pt(1, 0, 0, 100)], 'rpm').isByteLike).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHistoryChartYAxisLabels', () => {
|
||||
it('renders percent labels at the three tick positions', () => {
|
||||
expect(
|
||||
getHistoryChartYAxisLabels({ minValue: 0, maxValue: 100, isPercentLike: true, isByteLike: false }),
|
||||
).toStrictEqual([
|
||||
{ pct: 0, label: '0%' },
|
||||
{ pct: 0.5, label: '50%' },
|
||||
{ pct: 1, label: '100%' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders byte-like 0/avg/max labels', () => {
|
||||
expect(
|
||||
getHistoryChartYAxisLabels({ minValue: 0, maxValue: 100, isPercentLike: false, isByteLike: true }),
|
||||
).toStrictEqual([
|
||||
{ pct: 0, label: '0' },
|
||||
{ pct: 0.5, label: 'Avg' },
|
||||
{ pct: 1, label: 'Max' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders computed numeric labels for other unit kinds', () => {
|
||||
expect(
|
||||
getHistoryChartYAxisLabels({ minValue: 10, maxValue: 110, isPercentLike: false, isByteLike: false }),
|
||||
).toStrictEqual([
|
||||
{ pct: 0, label: '0' },
|
||||
{ pct: 0.5, label: '60' },
|
||||
{ pct: 1, label: '110' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatHistoryChartTimeLabel', () => {
|
||||
const ts = new Date(2024, 0, 15, 9, 30).getTime();
|
||||
|
||||
it.each<HistoryTimeRange>(['7d', '14d', '30d', '90d'])(
|
||||
'renders a calendar date for the %s range',
|
||||
(range) => {
|
||||
expect(formatHistoryChartTimeLabel(ts, range)).toBe(
|
||||
new Date(ts).toLocaleDateString([], { month: 'short', day: 'numeric' }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each<HistoryTimeRange>(['30m', '1h', '6h', '12h', '24h'])(
|
||||
'renders a clock time for the %s range',
|
||||
(range) => {
|
||||
expect(formatHistoryChartTimeLabel(ts, range)).toBe(
|
||||
new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('produces different output for the day vs time branches at the same timestamp', () => {
|
||||
expect(formatHistoryChartTimeLabel(ts, '7d')).not.toBe(formatHistoryChartTimeLabel(ts, '1h'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('createHistoryChartGeometry', () => {
|
||||
it('maps timestamps and values onto pixel coordinates', () => {
|
||||
const geo = createHistoryChartGeometry({
|
||||
width: 200,
|
||||
height: 100,
|
||||
startTime: 0,
|
||||
endTime: 100,
|
||||
minValue: 0,
|
||||
maxValue: 10,
|
||||
});
|
||||
|
||||
expect(geo.timeSpan).toBe(100);
|
||||
expect(geo.getX(50)).toBe(120);
|
||||
expect(geo.getY(5)).toBe(50);
|
||||
});
|
||||
|
||||
it('clamps a negative/inverted time span down to 1', () => {
|
||||
const geo = createHistoryChartGeometry({
|
||||
width: 200,
|
||||
height: 100,
|
||||
startTime: 100,
|
||||
endTime: 50,
|
||||
minValue: 0,
|
||||
maxValue: 10,
|
||||
});
|
||||
|
||||
expect(geo.timeSpan).toBe(1);
|
||||
expect(geo.getX(100)).toBe(40);
|
||||
});
|
||||
|
||||
it('left-pads the first timestamp to the chart origin', () => {
|
||||
const geo = createHistoryChartGeometry({
|
||||
width: 200,
|
||||
height: 100,
|
||||
startTime: 1000,
|
||||
endTime: 1100,
|
||||
minValue: 0,
|
||||
maxValue: 10,
|
||||
});
|
||||
|
||||
expect(geo.getX(1000)).toBe(40);
|
||||
expect(geo.getX(1100)).toBe(200);
|
||||
});
|
||||
|
||||
it('inverts the value axis so the max sits at the top padding', () => {
|
||||
const geo = createHistoryChartGeometry({
|
||||
width: 200,
|
||||
height: 100,
|
||||
startTime: 0,
|
||||
endTime: 100,
|
||||
minValue: 0,
|
||||
maxValue: 10,
|
||||
});
|
||||
|
||||
expect(geo.getY(0)).toBe(80);
|
||||
expect(geo.getY(10)).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findHistoryChartClosestPoint', () => {
|
||||
it('returns the first point when its timestamp is the exact match', () => {
|
||||
const points = [pt(100, 1, 0, 0), pt(200, 2, 0, 0), pt(300, 3, 0, 0)];
|
||||
expect(findHistoryChartClosestPoint(points, 100)).toStrictEqual(points[0]);
|
||||
});
|
||||
|
||||
it('updates the closest point as a nearer timestamp is found', () => {
|
||||
const points = [pt(100, 1, 0, 0), pt(200, 2, 0, 0), pt(300, 3, 0, 0)];
|
||||
expect(findHistoryChartClosestPoint(points, 210)).toStrictEqual(points[1]);
|
||||
});
|
||||
|
||||
it('keeps the earlier point on an exact tie (strictly-less comparison)', () => {
|
||||
const points = [pt(100, 1, 0, 0), pt(200, 2, 0, 0)];
|
||||
expect(findHistoryChartClosestPoint(points, 150)).toStrictEqual(points[0]);
|
||||
});
|
||||
|
||||
it('keeps the first point when no later point is closer', () => {
|
||||
const points = [pt(100, 1, 0, 0), pt(500, 2, 0, 0)];
|
||||
expect(findHistoryChartClosestPoint(points, 90)).toStrictEqual(points[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHistoryChartTooltipLayout', () => {
|
||||
it('places the tooltip to the right when only the right side has room', () => {
|
||||
const layout = getHistoryChartTooltipLayout({
|
||||
hoveredPoint: { x: 150, y: 70, timestamp: 0, value: 42 },
|
||||
chartWidth: 420,
|
||||
chartHeight: 180,
|
||||
});
|
||||
|
||||
expect(layout).toStrictEqual({ x: 162, y: 47, width: 156, height: 46 });
|
||||
});
|
||||
|
||||
it('places the tooltip to the left when only the left side has room', () => {
|
||||
const layout = getHistoryChartTooltipLayout({
|
||||
hoveredPoint: { x: 380, y: 70, timestamp: 0, value: 42 },
|
||||
chartWidth: 420,
|
||||
chartHeight: 180,
|
||||
});
|
||||
|
||||
expect(layout.x).toBe(212);
|
||||
expect(layout.x + layout.width).toBeLessThan(380);
|
||||
});
|
||||
|
||||
it('prefers the right side when both sides fit and right room is >= left room', () => {
|
||||
const layout = getHistoryChartTooltipLayout({
|
||||
hoveredPoint: { x: 250, y: 70, timestamp: 0, value: 42 },
|
||||
chartWidth: 500,
|
||||
chartHeight: 180,
|
||||
});
|
||||
|
||||
expect(layout.x).toBe(262);
|
||||
});
|
||||
|
||||
it('prefers the left side when both sides fit but left room is greater', () => {
|
||||
const layout = getHistoryChartTooltipLayout({
|
||||
hoveredPoint: { x: 250, y: 70, timestamp: 0, value: 42 },
|
||||
chartWidth: 490,
|
||||
chartHeight: 180,
|
||||
});
|
||||
|
||||
expect(layout.x).toBe(82);
|
||||
});
|
||||
|
||||
it('centers and clamps the tooltip when neither side can fit', () => {
|
||||
const layout = getHistoryChartTooltipLayout({
|
||||
hoveredPoint: { x: 90, y: 70, timestamp: 0, value: 42 },
|
||||
chartWidth: 180,
|
||||
chartHeight: 180,
|
||||
});
|
||||
|
||||
expect(layout).toStrictEqual({ x: 12, y: 12, width: 156, height: 46 });
|
||||
});
|
||||
|
||||
it('pushes an overlapping tooltip above the hovered point when there is headroom above', () => {
|
||||
const layout = getHistoryChartTooltipLayout({
|
||||
hoveredPoint: { x: 90, y: 30, timestamp: 0, value: 42 },
|
||||
chartWidth: 180,
|
||||
chartHeight: 180,
|
||||
});
|
||||
|
||||
expect(layout.x).toBe(12);
|
||||
expect(layout.y).toBe(42);
|
||||
});
|
||||
|
||||
it('clamps the tooltip y to the bottom edge when the hovered point is near the bottom', () => {
|
||||
const layout = getHistoryChartTooltipLayout({
|
||||
hoveredPoint: { x: 150, y: 200, timestamp: 0, value: 42 },
|
||||
chartWidth: 420,
|
||||
chartHeight: 180,
|
||||
});
|
||||
|
||||
expect(layout.y).toBe(126);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,947 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Alert } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
import {
|
||||
MS_PER_HOUR,
|
||||
applyAlertHistoryWindow,
|
||||
buildAlertHistoryItems,
|
||||
buildAlertHistoryParams,
|
||||
buildAlertRangeSummary,
|
||||
buildAlertAxisTicks,
|
||||
buildAlertTrends,
|
||||
filterAlertHistoryItems,
|
||||
formatAlertAxisTickLabel,
|
||||
formatAlertBucketRange,
|
||||
formatAlertHistoryDuration,
|
||||
formatAlertHistoryGroupLabel,
|
||||
getAlertBucketDurationLabel,
|
||||
getAlertHistoryDaySuffix,
|
||||
getIncidentRowKey,
|
||||
groupAlertHistoryItems,
|
||||
resolveAlertHistoryResourceType,
|
||||
type AlertHistoryRange,
|
||||
type AlertTrendSeries,
|
||||
type HistoryItem,
|
||||
} from '../alertHistoryModel';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures — keep these minimal but real-shaped so the production
|
||||
// code paths (resource-type resolution, severity filtering, etc.) exercise
|
||||
// the same branches the UI triggers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeItem(overrides: Partial<HistoryItem> = {}): HistoryItem {
|
||||
return {
|
||||
id: 'alert-1',
|
||||
source: 'alert',
|
||||
status: 'resolved',
|
||||
startTime: '2026-03-22T09:00:00.000Z',
|
||||
duration: '1h',
|
||||
resourceName: 'vm-101',
|
||||
resourceType: 'VM',
|
||||
severity: 'critical',
|
||||
title: 'CPU High',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAlert(overrides: Partial<Alert> = {}): Alert {
|
||||
return {
|
||||
id: 'alert-1',
|
||||
type: 'cpu',
|
||||
level: 'critical',
|
||||
resourceId: 'resource-1',
|
||||
resourceName: 'vm-101',
|
||||
node: 'px1',
|
||||
instance: 'cpu',
|
||||
message: 'CPU high',
|
||||
value: 90,
|
||||
threshold: 80,
|
||||
startTime: '2026-03-22T09:00:00.000Z',
|
||||
acknowledged: false,
|
||||
...overrides,
|
||||
} as Alert;
|
||||
}
|
||||
|
||||
function makeResource(overrides: Partial<Resource> = {}): Resource {
|
||||
return {
|
||||
id: 'resource-1',
|
||||
type: 'vm',
|
||||
name: 'vm-101',
|
||||
displayName: 'vm-101',
|
||||
...overrides,
|
||||
} as unknown as Resource;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildAlertHistoryParams — exact limit/startTime for each switch arm.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildAlertHistoryParams', () => {
|
||||
const now = Date.UTC(2026, 2, 22, 12, 0, 0);
|
||||
|
||||
it('emits limit 2000 and a startTime exactly 24h before now for "24h"', () => {
|
||||
expect(buildAlertHistoryParams('24h', now)).toStrictEqual({
|
||||
limit: 2000,
|
||||
startTime: new Date(now - 24 * MS_PER_HOUR).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it('emits limit 10000 and a startTime exactly 7d before now for "7d"', () => {
|
||||
expect(buildAlertHistoryParams('7d', now)).toStrictEqual({
|
||||
limit: 10000,
|
||||
startTime: new Date(now - 7 * 24 * MS_PER_HOUR).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it('emits limit 10000 and a startTime exactly 30d before now for "30d"', () => {
|
||||
expect(buildAlertHistoryParams('30d', now)).toStrictEqual({
|
||||
limit: 10000,
|
||||
startTime: new Date(now - 30 * 24 * MS_PER_HOUR).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it('emits limit 0 and no startTime for "all"', () => {
|
||||
expect(buildAlertHistoryParams('all', now)).toStrictEqual({ limit: 0 });
|
||||
});
|
||||
|
||||
it('falls back to limit 1000 with no startTime for an unrecognised range', () => {
|
||||
expect(
|
||||
buildAlertHistoryParams('bogus' as AlertHistoryRange, now),
|
||||
).toStrictEqual({ limit: 1000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatAlertHistoryDuration — negative-duration guard, now-fallback, and
|
||||
// the three magnitude arms at their boundaries.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatAlertHistoryDuration', () => {
|
||||
it('returns "0m" when end is before start (negative duration)', () => {
|
||||
expect(
|
||||
formatAlertHistoryDuration('2026-03-22T10:00:00.000Z', '2026-03-22T09:00:00.000Z'),
|
||||
).toBe('0m');
|
||||
});
|
||||
|
||||
it('uses the provided `now` when endTime is omitted', () => {
|
||||
const start = '2026-03-22T09:00:00.000Z';
|
||||
const now = Date.UTC(2026, 2, 22, 9, 30, 0);
|
||||
expect(formatAlertHistoryDuration(start, undefined, now)).toBe('30m');
|
||||
});
|
||||
|
||||
it('returns "0m" for a zero-length duration (start === end)', () => {
|
||||
const start = '2026-03-22T09:00:00.000Z';
|
||||
expect(formatAlertHistoryDuration(start, start)).toBe('0m');
|
||||
});
|
||||
|
||||
it('formats the minute-only arm with leading minutes under one hour', () => {
|
||||
expect(
|
||||
formatAlertHistoryDuration('2026-03-22T09:00:00.000Z', '2026-03-22T09:05:00.000Z'),
|
||||
).toBe('5m');
|
||||
});
|
||||
|
||||
it('renders the residual minutes even when hours divide evenly', () => {
|
||||
// exactly 1 hour → minutes=60, hours=1, days=0 → "1h 0m"
|
||||
expect(
|
||||
formatAlertHistoryDuration('2026-03-22T09:00:00.000Z', '2026-03-22T10:00:00.000Z'),
|
||||
).toBe('1h 0m');
|
||||
});
|
||||
|
||||
it('renders the residual hours even when days divide evenly', () => {
|
||||
// exactly 1 day → minutes=1440, hours=24, days=1 → "1d 0h"
|
||||
expect(
|
||||
formatAlertHistoryDuration('2026-03-22T09:00:00.000Z', '2026-03-23T09:00:00.000Z'),
|
||||
).toBe('1d 0h');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatAlertBucketRange — same-day, cross-day, and cross-year (which forces
|
||||
// the `year: 'numeric'` option on the start formatter).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatAlertBucketRange', () => {
|
||||
it('uses the en-dash separator and a single date for an intra-day bucket', () => {
|
||||
const start = Date.UTC(2026, 2, 22, 12, 0, 0);
|
||||
const end = Date.UTC(2026, 2, 22, 13, 0, 0);
|
||||
const label = formatAlertBucketRange(start, end, 'en-US');
|
||||
expect(label).toContain('\u2013'); // –
|
||||
expect(label).not.toContain('\u2192'); // →
|
||||
expect(label).toContain('Mar 22');
|
||||
expect(label.startsWith('Mar 22,')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the arrow separator and both dates when the bucket spans midnight', () => {
|
||||
const start = Date.UTC(2026, 2, 22, 22, 0, 0);
|
||||
const end = Date.UTC(2026, 2, 23, 4, 0, 0);
|
||||
const label = formatAlertBucketRange(start, end, 'en-US');
|
||||
expect(label).toContain('\u2192'); // →
|
||||
expect(label).not.toContain('\u2013'); // –
|
||||
expect(label).toContain('Mar 22');
|
||||
expect(label).toContain('Mar 23');
|
||||
});
|
||||
|
||||
it('adds a year to the start segment when start and end fall in different years', () => {
|
||||
const start = Date.UTC(2025, 11, 31, 22, 0, 0);
|
||||
const end = Date.UTC(2026, 0, 1, 2, 0, 0);
|
||||
const label = formatAlertBucketRange(start, end, 'en-US');
|
||||
expect(label).toContain('2025');
|
||||
expect(label).toContain('Dec 31');
|
||||
expect(label).toContain('Jan 1');
|
||||
expect(label).toContain('2026');
|
||||
// Cross-day so the arrow separator is used.
|
||||
expect(label).toContain('\u2192');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveAlertHistoryResourceType — every early-return and fallback arm.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveAlertHistoryResourceType', () => {
|
||||
it('returns the metadata resourceType when it is a non-empty string', () => {
|
||||
expect(
|
||||
resolveAlertHistoryResourceType({
|
||||
resourceName: 'vm-101',
|
||||
metadata: { resourceType: 'Custom' },
|
||||
resourceId: 'resource-1',
|
||||
getResource: () => makeResource({ type: 'vm' }),
|
||||
allResources: [makeResource({ type: 'vm' })],
|
||||
}),
|
||||
).toBe('Custom');
|
||||
});
|
||||
|
||||
it('falls through when metadata.resourceType is only whitespace', () => {
|
||||
const result = resolveAlertHistoryResourceType({
|
||||
resourceName: 'vm-101',
|
||||
metadata: { resourceType: ' ' },
|
||||
resourceId: 'resource-1',
|
||||
getResource: () => makeResource({ type: 'vm' }),
|
||||
allResources: [],
|
||||
});
|
||||
expect(result).toBe('VM');
|
||||
});
|
||||
|
||||
it('falls through when metadata.resourceType is not a string (number)', () => {
|
||||
const result = resolveAlertHistoryResourceType({
|
||||
resourceName: 'vm-101',
|
||||
metadata: { resourceType: 42 },
|
||||
resourceId: 'resource-1',
|
||||
getResource: () => makeResource({ type: 'vm' }),
|
||||
allResources: [],
|
||||
});
|
||||
expect(result).toBe('VM');
|
||||
});
|
||||
|
||||
it('falls through when metadata is undefined', () => {
|
||||
const result = resolveAlertHistoryResourceType({
|
||||
resourceName: 'vm-101',
|
||||
metadata: undefined,
|
||||
resourceId: 'resource-1',
|
||||
getResource: () => makeResource({ type: 'vm' }),
|
||||
allResources: [],
|
||||
});
|
||||
expect(result).toBe('VM');
|
||||
});
|
||||
|
||||
it('resolves via getResource when resourceId is provided and the lookup hits', () => {
|
||||
expect(
|
||||
resolveAlertHistoryResourceType({
|
||||
resourceName: 'whatever',
|
||||
resourceId: 'resource-1',
|
||||
getResource: (id) => (id === 'resource-1' ? makeResource({ type: 'vm' }) : undefined),
|
||||
allResources: [],
|
||||
}),
|
||||
).toBe('VM');
|
||||
});
|
||||
|
||||
it('falls through to a name match when getResource returns undefined', () => {
|
||||
const result = resolveAlertHistoryResourceType({
|
||||
resourceName: 'vm-101',
|
||||
resourceId: 'missing',
|
||||
getResource: () => undefined,
|
||||
allResources: [makeResource({ name: 'vm-101', type: 'vm' })],
|
||||
});
|
||||
expect(result).toBe('VM');
|
||||
});
|
||||
|
||||
it('matches a resource by displayName when the name match misses', () => {
|
||||
const result = resolveAlertHistoryResourceType({
|
||||
resourceName: 'pretty-vm',
|
||||
resourceId: 'missing',
|
||||
getResource: () => undefined,
|
||||
allResources: [
|
||||
makeResource({ name: 'other', displayName: 'pretty-vm', type: 'app-container' }),
|
||||
],
|
||||
});
|
||||
expect(result).toBe('Container');
|
||||
});
|
||||
|
||||
it('returns "Unknown" when no resolution path succeeds', () => {
|
||||
expect(
|
||||
resolveAlertHistoryResourceType({
|
||||
resourceName: 'lonely',
|
||||
resourceId: undefined,
|
||||
getResource: () => undefined,
|
||||
allResources: [],
|
||||
}),
|
||||
).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('returns "Unknown" even with a resourceId when getResource misses and no name matches', () => {
|
||||
expect(
|
||||
resolveAlertHistoryResourceType({
|
||||
resourceName: 'lonely',
|
||||
resourceId: 'also-missing',
|
||||
getResource: () => undefined,
|
||||
allResources: [makeResource({ name: 'someone-else' })],
|
||||
}),
|
||||
).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildAlertHistoryItems — active vs history, acknowledged vs resolved, and
|
||||
// the active-id suppression of duplicate history rows.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildAlertHistoryItems', () => {
|
||||
const getResource = (id: string): Resource | undefined =>
|
||||
id === 'resource-1' ? makeResource({ id: 'resource-1', type: 'vm' }) : undefined;
|
||||
|
||||
it('returns an empty array when both activeAlerts and alertHistory are empty', () => {
|
||||
expect(
|
||||
buildAlertHistoryItems({
|
||||
activeAlerts: {},
|
||||
alertHistory: [],
|
||||
getResource,
|
||||
allResources: [],
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('marks active alerts with status "active" and acknowledged:false', () => {
|
||||
const items = buildAlertHistoryItems({
|
||||
activeAlerts: { 'alert-1': makeAlert({ id: 'alert-1', acknowledged: true }) },
|
||||
alertHistory: [],
|
||||
getResource,
|
||||
allResources: [],
|
||||
now: Date.UTC(2026, 2, 22, 10, 0, 0),
|
||||
});
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
id: 'alert-1',
|
||||
status: 'active',
|
||||
acknowledged: false,
|
||||
resourceType: 'VM',
|
||||
title: 'CPU',
|
||||
});
|
||||
// Active alerts carry no end time.
|
||||
expect(items[0].endTime).toBeUndefined();
|
||||
});
|
||||
|
||||
it('marks an unacknowledged historical alert as "resolved"', () => {
|
||||
const items = buildAlertHistoryItems({
|
||||
activeAlerts: {},
|
||||
alertHistory: [
|
||||
makeAlert({
|
||||
id: 'h-1',
|
||||
acknowledged: false,
|
||||
lastSeen: '2026-03-22T09:30:00.000Z',
|
||||
}),
|
||||
],
|
||||
getResource,
|
||||
allResources: [],
|
||||
now: Date.UTC(2026, 2, 22, 10, 0, 0),
|
||||
});
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ id: 'h-1', status: 'resolved', acknowledged: false });
|
||||
expect(items[0].endTime).toBe('2026-03-22T09:30:00.000Z');
|
||||
});
|
||||
|
||||
it('marks an acknowledged historical alert as "acknowledged"', () => {
|
||||
const items = buildAlertHistoryItems({
|
||||
activeAlerts: {},
|
||||
alertHistory: [
|
||||
makeAlert({
|
||||
id: 'h-2',
|
||||
acknowledged: true,
|
||||
lastSeen: '2026-03-22T09:30:00.000Z',
|
||||
}),
|
||||
],
|
||||
getResource,
|
||||
allResources: [],
|
||||
now: Date.UTC(2026, 2, 22, 10, 0, 0),
|
||||
});
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ id: 'h-2', status: 'acknowledged', acknowledged: true });
|
||||
});
|
||||
|
||||
it('does not duplicate an alert that is present in both active and history', () => {
|
||||
const items = buildAlertHistoryItems({
|
||||
activeAlerts: { 'alert-1': makeAlert({ id: 'alert-1' }) },
|
||||
alertHistory: [makeAlert({ id: 'alert-1', lastSeen: '2026-03-22T09:30:00.000Z' })],
|
||||
getResource,
|
||||
allResources: [],
|
||||
now: Date.UTC(2026, 2, 22, 10, 0, 0),
|
||||
});
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].status).toBe('active');
|
||||
});
|
||||
|
||||
it('interleaves active and historical rows preserving both sets', () => {
|
||||
const items = buildAlertHistoryItems({
|
||||
activeAlerts: { 'a-1': makeAlert({ id: 'a-1' }) },
|
||||
alertHistory: [makeAlert({ id: 'h-1', lastSeen: '2026-03-22T09:30:00.000Z' })],
|
||||
getResource,
|
||||
allResources: [],
|
||||
now: Date.UTC(2026, 2, 22, 10, 0, 0),
|
||||
});
|
||||
expect(items.map((i) => i.id)).toEqual(['a-1', 'h-1']);
|
||||
expect(items.map((i) => i.status)).toEqual(['active', 'resolved']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// filterAlertHistoryItems — pass-through identity, multi-field de-dupe, and
|
||||
// the undefined-field safe-access arms.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('filterAlertHistoryItems', () => {
|
||||
it('returns the same array reference when no filter is applied', () => {
|
||||
const items = [makeItem()];
|
||||
expect(filterAlertHistoryItems(items, 'all', '')).toBe(items);
|
||||
});
|
||||
|
||||
it('matches a search term against the title only when other fields are blank', () => {
|
||||
const items = [
|
||||
makeItem({ id: '1', title: 'CPU High', resourceName: 'x', description: '', node: '' }),
|
||||
makeItem({ id: '2', title: 'Disk Full', resourceName: 'y', description: '', node: '' }),
|
||||
];
|
||||
const result = filterAlertHistoryItems(items, 'all', 'cpu');
|
||||
expect(result.map((i) => i.id)).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('returns a single item even when the term matches multiple of its fields', () => {
|
||||
const items = [
|
||||
makeItem({
|
||||
id: '1',
|
||||
resourceName: 'cpu-thing',
|
||||
title: 'CPU',
|
||||
description: 'cpu spike',
|
||||
node: 'cpunode',
|
||||
}),
|
||||
];
|
||||
const result = filterAlertHistoryItems(items, 'all', 'cpu');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('safely filters items whose title/description/node are undefined', () => {
|
||||
const item = makeItem({ id: 'u', title: undefined, description: undefined, node: undefined });
|
||||
expect(filterAlertHistoryItems([item], 'all', '')).toHaveLength(1);
|
||||
expect(filterAlertHistoryItems([item], 'all', 'missing')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildAlertTrends — exercises the `?? rawBucketSize` fallback by forcing a
|
||||
// range so wide that no nice bucket size is large enough.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildAlertTrends (nice-size fallback)', () => {
|
||||
const now = Date.UTC(2026, 2, 22, 12, 0, 0);
|
||||
|
||||
it('falls back to rawBucketSize when the range exceeds the largest nice value', () => {
|
||||
// ~1801 days ago → rawRangeHours ≈ 43224 → rawBucketSize = ceil(43224/30) = 1441
|
||||
// which is larger than the max nice value (1440), so `.find()` returns undefined
|
||||
// and the `?? rawBucketSize` fallback engages.
|
||||
const alerts = [
|
||||
makeItem({ startTime: new Date(now - 1801 * 24 * MS_PER_HOUR).toISOString() }),
|
||||
];
|
||||
const trends = buildAlertTrends(alerts, 'all', now);
|
||||
expect(trends.bucketSize).toBe(1441);
|
||||
expect(trends.rangeHours).toBe(30 * 1441);
|
||||
expect(trends.buckets).toHaveLength(30);
|
||||
});
|
||||
|
||||
it('caps the bucket count at maxBuckets (30) for a very wide range', () => {
|
||||
const alerts = [
|
||||
makeItem({ startTime: new Date(now - 5000 * 24 * MS_PER_HOUR).toISOString() }),
|
||||
];
|
||||
const trends = buildAlertTrends(alerts, 'all', now);
|
||||
expect(trends.buckets.length).toBeLessThanOrEqual(30);
|
||||
expect(trends.rangeHours).toBe(trends.buckets.length * trends.bucketSize);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyAlertHistoryWindow — 24h/30d cutoff arms, selected-bar precedence
|
||||
// over an "all" timeFilter, and the id-equality tiebreaker.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyAlertHistoryWindow (cutoff + tiebreak arms)', () => {
|
||||
const now = Date.UTC(2026, 2, 22, 12, 0, 0);
|
||||
|
||||
it('applies the 24h cutoff when no bar is selected', () => {
|
||||
const trends = buildAlertTrends([], '24h', now);
|
||||
const items = [
|
||||
makeItem({ id: 'within', startTime: new Date(now - MS_PER_HOUR).toISOString() }),
|
||||
makeItem({ id: 'beyond', startTime: new Date(now - 25 * MS_PER_HOUR).toISOString() }),
|
||||
];
|
||||
const result = applyAlertHistoryWindow({
|
||||
filteredItems: items,
|
||||
timeFilter: '24h',
|
||||
selectedBarIndex: null,
|
||||
trends,
|
||||
now,
|
||||
});
|
||||
expect(result.map((i) => i.id)).toEqual(['within']);
|
||||
});
|
||||
|
||||
it('applies the 30d cutoff when no bar is selected', () => {
|
||||
const trends = buildAlertTrends([], '30d', now);
|
||||
const items = [
|
||||
makeItem({ id: 'within', startTime: new Date(now - 10 * 24 * MS_PER_HOUR).toISOString() }),
|
||||
makeItem({ id: 'beyond', startTime: new Date(now - 60 * 24 * MS_PER_HOUR).toISOString() }),
|
||||
];
|
||||
const result = applyAlertHistoryWindow({
|
||||
filteredItems: items,
|
||||
timeFilter: '30d',
|
||||
selectedBarIndex: null,
|
||||
trends,
|
||||
now,
|
||||
});
|
||||
expect(result.map((i) => i.id)).toEqual(['within']);
|
||||
});
|
||||
|
||||
it('prefers the selected bar over the timeFilter cutoff (timeFilter "all")', () => {
|
||||
const trends = buildAlertTrends([], 'all', now);
|
||||
const bucketIndex = 0;
|
||||
const bucketStart = trends.bucketTimes[bucketIndex];
|
||||
const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR;
|
||||
|
||||
const items = [
|
||||
makeItem({ id: 'in-bucket', startTime: new Date(bucketStart + 1000).toISOString() }),
|
||||
makeItem({ id: 'out-of-bucket', startTime: new Date(bucketEnd + MS_PER_HOUR).toISOString() }),
|
||||
];
|
||||
|
||||
const result = applyAlertHistoryWindow({
|
||||
filteredItems: items,
|
||||
timeFilter: 'all',
|
||||
selectedBarIndex: bucketIndex,
|
||||
trends,
|
||||
now,
|
||||
});
|
||||
expect(result.map((i) => i.id)).toEqual(['in-bucket']);
|
||||
});
|
||||
|
||||
it('returns 0 from the comparator when two items share both startTime and id', () => {
|
||||
const trends = buildAlertTrends([], 'all', now);
|
||||
const sharedStart = new Date(now - MS_PER_HOUR).toISOString();
|
||||
// Identical id + startTime — the `a.id > b.id` branch is skipped and 0 is returned.
|
||||
const items = [
|
||||
makeItem({ id: 'same', startTime: sharedStart }),
|
||||
makeItem({ id: 'same', startTime: sharedStart }),
|
||||
];
|
||||
const result = applyAlertHistoryWindow({
|
||||
filteredItems: items,
|
||||
timeFilter: 'all',
|
||||
selectedBarIndex: null,
|
||||
trends,
|
||||
now,
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((i) => i.id === 'same')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getAlertHistoryDaySuffix — every ordinal arm including the 11–13 special case.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getAlertHistoryDaySuffix', () => {
|
||||
it('returns "st" for 1, 21, 31, 101', () => {
|
||||
expect(getAlertHistoryDaySuffix(1)).toBe('st');
|
||||
expect(getAlertHistoryDaySuffix(21)).toBe('st');
|
||||
expect(getAlertHistoryDaySuffix(31)).toBe('st');
|
||||
expect(getAlertHistoryDaySuffix(101)).toBe('st');
|
||||
});
|
||||
|
||||
it('returns "nd" for 2, 22', () => {
|
||||
expect(getAlertHistoryDaySuffix(2)).toBe('nd');
|
||||
expect(getAlertHistoryDaySuffix(22)).toBe('nd');
|
||||
});
|
||||
|
||||
it('returns "rd" for 3, 23', () => {
|
||||
expect(getAlertHistoryDaySuffix(3)).toBe('rd');
|
||||
expect(getAlertHistoryDaySuffix(23)).toBe('rd');
|
||||
});
|
||||
|
||||
it('returns "th" for the default arm (e.g. 4, 25)', () => {
|
||||
expect(getAlertHistoryDaySuffix(4)).toBe('th');
|
||||
expect(getAlertHistoryDaySuffix(25)).toBe('th');
|
||||
});
|
||||
|
||||
it('returns "th" for the 11–13 special case even though they end in 1/2/3', () => {
|
||||
expect(getAlertHistoryDaySuffix(11)).toBe('th');
|
||||
expect(getAlertHistoryDaySuffix(12)).toBe('th');
|
||||
expect(getAlertHistoryDaySuffix(13)).toBe('th');
|
||||
});
|
||||
|
||||
it('documents current behaviour for 111–113 (guard only covers 11–13)', () => {
|
||||
// The 11–13 guard is `day >= 11 && day <= 13`, so 111/112/113 fall through
|
||||
// to the `% 10` switch and are *not* special-cased. This pins the current
|
||||
// output; see GLM_REPORT.md for the suspected source bug.
|
||||
expect(getAlertHistoryDaySuffix(111)).toBe('st');
|
||||
expect(getAlertHistoryDaySuffix(112)).toBe('nd');
|
||||
expect(getAlertHistoryDaySuffix(113)).toBe('rd');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatAlertHistoryGroupLabel — direct calls for all three label arms.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatAlertHistoryGroupLabel', () => {
|
||||
it('labels a date equal to todayStart with the "Today (...)" prefix', () => {
|
||||
const todayStart = Date.UTC(2026, 2, 22);
|
||||
const date = new Date(todayStart);
|
||||
expect(formatAlertHistoryGroupLabel(date, todayStart, 0)).toBe('Today (March 22nd)');
|
||||
});
|
||||
|
||||
it('labels a date equal to yesterdayStart with the "Yesterday (...)" prefix', () => {
|
||||
const yesterdayStart = Date.UTC(2026, 2, 21);
|
||||
const date = new Date(yesterdayStart);
|
||||
expect(formatAlertHistoryGroupLabel(date, 0, yesterdayStart)).toBe('Yesterday (March 21st)');
|
||||
});
|
||||
|
||||
it('uses the absolute "Month DaySuffix" label for any other date', () => {
|
||||
const date = new Date(Date.UTC(2026, 0, 2));
|
||||
// Neither todayStart nor yesterdayStart match.
|
||||
expect(formatAlertHistoryGroupLabel(date, 0, 0)).toBe('January 2nd');
|
||||
});
|
||||
|
||||
it('applies the correct suffix for an 11th day', () => {
|
||||
const date = new Date(Date.UTC(2026, 0, 11));
|
||||
expect(formatAlertHistoryGroupLabel(date, 0, 0)).toBe('January 11th');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getIncidentRowKey
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getIncidentRowKey', () => {
|
||||
it('joins id and startTime with "::" into a stable composite key', () => {
|
||||
expect(
|
||||
getIncidentRowKey(
|
||||
makeItem({ id: 'inc-9', startTime: '2026-03-22T09:00:00.000Z' }),
|
||||
),
|
||||
).toBe('inc-9::2026-03-22T09:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// groupAlertHistoryItems — multi-day grouping, add-to-existing arm, ordering.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('groupAlertHistoryItems', () => {
|
||||
it('appends subsequent same-day alerts to an already-created group', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'a', startTime: '2026-01-15T08:00:00.000Z' }),
|
||||
makeItem({ id: 'b', startTime: '2026-01-15T20:00:00.000Z' }),
|
||||
];
|
||||
const groups = groupAlertHistoryItems(items);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].alerts.map((a) => a.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('produces one group per distinct calendar day, newest first', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'oldest', startTime: '2026-01-10T08:00:00.000Z' }),
|
||||
makeItem({ id: 'mid', startTime: '2026-02-10T08:00:00.000Z' }),
|
||||
makeItem({ id: 'newest', startTime: '2026-03-10T08:00:00.000Z' }),
|
||||
];
|
||||
const groups = groupAlertHistoryItems(items);
|
||||
expect(groups.map((g) => g.alerts[0].id)).toEqual(['newest', 'mid', 'oldest']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getAlertBucketDurationLabel — every arm of the guard + day/hour formatters.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getAlertBucketDurationLabel', () => {
|
||||
it('returns the em-dash placeholder for non-finite input', () => {
|
||||
expect(getAlertBucketDurationLabel(Number.NaN)).toBe('—');
|
||||
});
|
||||
|
||||
it('returns the em-dash placeholder for zero and negative input', () => {
|
||||
expect(getAlertBucketDurationLabel(0)).toBe('—');
|
||||
expect(getAlertBucketDurationLabel(-3)).toBe('—');
|
||||
});
|
||||
|
||||
it('uses the singular "1 day" form for exactly 24 hours', () => {
|
||||
expect(getAlertBucketDurationLabel(24)).toBe('1 day');
|
||||
});
|
||||
|
||||
it('uses the plural "N days" form for whole-day buckets > 24h', () => {
|
||||
expect(getAlertBucketDurationLabel(48)).toBe('2 days');
|
||||
expect(getAlertBucketDurationLabel(72)).toBe('3 days');
|
||||
});
|
||||
|
||||
it('uses the singular "1 hour" form for exactly one hour', () => {
|
||||
expect(getAlertBucketDurationLabel(1)).toBe('1 hour');
|
||||
});
|
||||
|
||||
it('uses the plural "N hours" form for non-whole-day hour buckets', () => {
|
||||
expect(getAlertBucketDurationLabel(6)).toBe('6 hours');
|
||||
expect(getAlertBucketDurationLabel(12)).toBe('12 hours');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatAlertAxisTickLabel — invalid-timestamp guard, "Now" end tick, and the
|
||||
// three totalHours formatting tiers (with/without the hour option).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatAlertAxisTickLabel', () => {
|
||||
it('returns the em-dash placeholder for a non-finite timestamp', () => {
|
||||
expect(
|
||||
formatAlertAxisTickLabel({
|
||||
timestamp: Number.NaN,
|
||||
bucketHours: 1,
|
||||
totalHours: 24,
|
||||
locale: 'en-US',
|
||||
}),
|
||||
).toBe('—');
|
||||
});
|
||||
|
||||
it('returns "Now" for an end tick within 0.75 * bucketHours of now', () => {
|
||||
const now = Date.UTC(2026, 2, 22, 12, 0, 0);
|
||||
expect(
|
||||
formatAlertAxisTickLabel({
|
||||
timestamp: now - 10 * 60 * 1000, // 10 min ago, within 0.75h
|
||||
bucketHours: 1,
|
||||
totalHours: 24,
|
||||
locale: 'en-US',
|
||||
isEnd: true,
|
||||
now,
|
||||
}),
|
||||
).toBe('Now');
|
||||
});
|
||||
|
||||
it('does NOT return "Now" for a non-end tick even when close to now', () => {
|
||||
const now = Date.UTC(2026, 2, 22, 12, 0, 0);
|
||||
const label = formatAlertAxisTickLabel({
|
||||
timestamp: now - 10 * 60 * 1000,
|
||||
bucketHours: 1,
|
||||
totalHours: 24,
|
||||
locale: 'en-US',
|
||||
isEnd: false,
|
||||
now,
|
||||
});
|
||||
expect(label).not.toBe('Now');
|
||||
expect(label).toContain('Mar');
|
||||
});
|
||||
|
||||
it('does NOT return "Now" for an end tick that is far from now', () => {
|
||||
const now = Date.UTC(2026, 2, 22, 12, 0, 0);
|
||||
const label = formatAlertAxisTickLabel({
|
||||
timestamp: now - 20 * MS_PER_HOUR, // 20h ago, outside 0.75h window for 1h bucket
|
||||
bucketHours: 1,
|
||||
totalHours: 24,
|
||||
locale: 'en-US',
|
||||
isEnd: true,
|
||||
now,
|
||||
});
|
||||
expect(label).not.toBe('Now');
|
||||
expect(label).toContain('Mar');
|
||||
});
|
||||
|
||||
it('uses month/day/hour/minute options for totalHours <= 48', () => {
|
||||
const ts = Date.UTC(2026, 2, 22, 9, 30, 0);
|
||||
const label = formatAlertAxisTickLabel({
|
||||
timestamp: ts,
|
||||
bucketHours: 1,
|
||||
totalHours: 24,
|
||||
locale: 'en-US',
|
||||
});
|
||||
// Short range includes the time-of-day.
|
||||
expect(label).toContain('Mar 22');
|
||||
expect(label).toContain('9:30');
|
||||
expect(label).toMatch(/AM|PM/);
|
||||
});
|
||||
|
||||
it('includes the hour for a mid-range total with a small bucket', () => {
|
||||
const ts = Date.UTC(2026, 2, 22, 9, 30, 0);
|
||||
const label = formatAlertAxisTickLabel({
|
||||
timestamp: ts,
|
||||
bucketHours: 6,
|
||||
totalHours: 7 * 24, // 168h, <= 24*90 and bucketHours <= 12 → hour shown
|
||||
locale: 'en-US',
|
||||
});
|
||||
expect(label).toContain('Mar 22');
|
||||
// The mid-range branch sets only `hour` (no `minute`), so the time-of-day
|
||||
// token is a bare hour like "09 AM" with no colon.
|
||||
expect(label).toMatch(/09/);
|
||||
expect(label).toMatch(/AM|PM/);
|
||||
expect(label).not.toMatch(/\d{1,2}:\d{2}/);
|
||||
});
|
||||
|
||||
it('omits the hour for a mid-range total with a large bucket and long span', () => {
|
||||
const ts = Date.UTC(2026, 2, 22, 0, 0, 0);
|
||||
const label = formatAlertAxisTickLabel({
|
||||
timestamp: ts,
|
||||
bucketHours: 24, // > 12
|
||||
totalHours: 24 * 60, // 1440h: <= 24*90 (2160) but > 24*14 (336) → no hour
|
||||
locale: 'en-US',
|
||||
});
|
||||
expect(label).toContain('Mar 22');
|
||||
// No time-of-day token should appear.
|
||||
expect(label).not.toMatch(/\d{1,2}:\d{2}/);
|
||||
});
|
||||
|
||||
it('uses year/month/day options for very long ranges (totalHours > 24*90)', () => {
|
||||
const ts = Date.UTC(2026, 2, 22, 0, 0, 0);
|
||||
const label = formatAlertAxisTickLabel({
|
||||
timestamp: ts,
|
||||
bucketHours: 24,
|
||||
totalHours: 24 * 120, // 2880h > 2160 → year branch
|
||||
locale: 'en-US',
|
||||
});
|
||||
expect(label).toContain('2026');
|
||||
expect(label).toContain('Mar 22');
|
||||
// Year branch never includes time-of-day.
|
||||
expect(label).not.toMatch(/\d{1,2}:\d{2}/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildAlertRangeSummary — null guard, normal output shape, and the
|
||||
// `rangeHours ?? bucketHours` nullish-coalescing fallback.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildAlertRangeSummary', () => {
|
||||
it('returns null when bucketTimes is empty', () => {
|
||||
const trends: AlertTrendSeries = {
|
||||
buckets: [],
|
||||
max: 0,
|
||||
bucketSize: 1,
|
||||
bucketTimes: [],
|
||||
rangeStart: 0,
|
||||
rangeHours: 0,
|
||||
};
|
||||
expect(buildAlertRangeSummary(trends, 'en-US')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when bucketSize is zero', () => {
|
||||
const trends: AlertTrendSeries = {
|
||||
buckets: [1],
|
||||
max: 1,
|
||||
bucketSize: 0,
|
||||
bucketTimes: [Date.UTC(2026, 2, 22)],
|
||||
rangeStart: 0,
|
||||
rangeHours: 1,
|
||||
};
|
||||
expect(buildAlertRangeSummary(trends, 'en-US')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns concrete startLabel/endLabel for a normal series', () => {
|
||||
const start = Date.UTC(2026, 2, 22, 0, 0, 0);
|
||||
const trends: AlertTrendSeries = {
|
||||
buckets: [0, 0],
|
||||
max: 1,
|
||||
bucketSize: 6,
|
||||
bucketTimes: [start, start + 6 * MS_PER_HOUR],
|
||||
rangeStart: start,
|
||||
rangeHours: 12,
|
||||
};
|
||||
const summary = buildAlertRangeSummary(trends, 'en-US');
|
||||
expect(summary).not.toBeNull();
|
||||
expect(summary!.startLabel).toContain('Mar 22');
|
||||
expect(summary!.endLabel).toContain('Mar 22');
|
||||
});
|
||||
|
||||
it('falls back to bucketHours when rangeHours is undefined', () => {
|
||||
const start = Date.UTC(2026, 2, 22, 0, 0, 0);
|
||||
const trends = {
|
||||
buckets: [0],
|
||||
max: 1,
|
||||
bucketSize: 6,
|
||||
bucketTimes: [start],
|
||||
rangeStart: start,
|
||||
rangeHours: undefined,
|
||||
} as unknown as AlertTrendSeries;
|
||||
// Should not throw; totalHours resolves to bucketHours (6) via the `??` arm.
|
||||
const summary = buildAlertRangeSummary(trends, 'en-US');
|
||||
expect(summary).not.toBeNull();
|
||||
expect(typeof summary!.startLabel).toBe('string');
|
||||
expect(summary!.startLabel.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildAlertAxisTicks — push-vs-replace last-tick arms and the align mapping.
|
||||
// (The first-tick `unshift` arm is suspected dead code — see GLM_REPORT.md.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildAlertAxisTicks (last-tick + align arms)', () => {
|
||||
const now = Date.UTC(2026, 2, 22, 12, 0, 0);
|
||||
|
||||
it('pushes a new last tick when the loop does not reach position 1', () => {
|
||||
const trends = buildAlertTrends([], '24h', now);
|
||||
const ticks = buildAlertAxisTicks(trends, 'en-US');
|
||||
expect(ticks.length).toBeGreaterThanOrEqual(2);
|
||||
expect(ticks[0].position).toBe(0);
|
||||
expect(ticks[ticks.length - 1].position).toBe(1);
|
||||
// First and last carry the start/end align markers.
|
||||
expect(ticks[0].align).toBe('start');
|
||||
expect(ticks[ticks.length - 1].align).toBe('end');
|
||||
});
|
||||
|
||||
it('replaces the existing last tick when the loop already lands on position 1', () => {
|
||||
// 2 bucket times, 1 bucket → loop hits index 0 and 1; index 1 maps to
|
||||
// position 1.0, triggering the replace-last-tick branch.
|
||||
const trends: AlertTrendSeries = {
|
||||
buckets: [0],
|
||||
max: 1,
|
||||
bucketSize: 1,
|
||||
bucketTimes: [now, now + MS_PER_HOUR],
|
||||
rangeStart: now,
|
||||
rangeHours: 1,
|
||||
};
|
||||
const ticks = buildAlertAxisTicks(trends, 'en-US');
|
||||
expect(ticks).toHaveLength(2);
|
||||
expect(ticks[1].position).toBe(1);
|
||||
expect(ticks[1].align).toBe('end');
|
||||
});
|
||||
|
||||
it('marks every non-edge tick as center-aligned', () => {
|
||||
const trends = buildAlertTrends([], '30d', now);
|
||||
const ticks = buildAlertAxisTicks(trends, 'en-US');
|
||||
for (let i = 1; i < ticks.length - 1; i++) {
|
||||
expect(ticks[i].align).toBe('center');
|
||||
}
|
||||
});
|
||||
|
||||
it('handles a single-bucket series by still emitting start and end ticks', () => {
|
||||
const start = Date.UTC(2026, 2, 22, 0, 0, 0);
|
||||
const trends: AlertTrendSeries = {
|
||||
buckets: [0],
|
||||
max: 1,
|
||||
bucketSize: 6,
|
||||
bucketTimes: [start],
|
||||
rangeStart: start,
|
||||
rangeHours: 6,
|
||||
};
|
||||
const ticks = buildAlertAxisTicks(trends, 'en-US');
|
||||
expect(ticks.length).toBeGreaterThanOrEqual(2);
|
||||
expect(ticks[0].position).toBe(0);
|
||||
expect(ticks[ticks.length - 1].position).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
alertTypeDisplayLabel,
|
||||
getAlertResourceDisplayLabel,
|
||||
getLocalTimezone,
|
||||
normalizeMetricDelayMap,
|
||||
unifiedTypeToAlertDisplayType,
|
||||
} from '@/features/alerts/helpers';
|
||||
|
||||
describe('alerts helpers — branch coverage (batch 2)', () => {
|
||||
describe('alertTypeDisplayLabel', () => {
|
||||
it('maps the remaining standard metric arms and aliases', () => {
|
||||
expect(alertTypeDisplayLabel('disk-usage')).toBe('Disk');
|
||||
expect(alertTypeDisplayLabel('usage')).toBe('Usage');
|
||||
expect(alertTypeDisplayLabel('network')).toBe('Network');
|
||||
expect(alertTypeDisplayLabel('load')).toBe('Load');
|
||||
});
|
||||
|
||||
it('maps all three temperature aliases to the same label', () => {
|
||||
expect(alertTypeDisplayLabel('temperature')).toBe('Temperature');
|
||||
expect(alertTypeDisplayLabel('disk_temperature')).toBe('Temperature');
|
||||
expect(alertTypeDisplayLabel('diskTemperature')).toBe('Temperature');
|
||||
});
|
||||
|
||||
it('maps the remaining docker-container and docker-host arms', () => {
|
||||
expect(alertTypeDisplayLabel('docker-container-cpu')).toBe('Container CPU');
|
||||
expect(alertTypeDisplayLabel('docker-container-disk')).toBe('Container Disk');
|
||||
expect(alertTypeDisplayLabel('docker-container-update')).toBe('Update Available');
|
||||
expect(alertTypeDisplayLabel('docker-host-offline')).toBe('Host Offline');
|
||||
});
|
||||
|
||||
it('maps the remaining infrastructure and storage arms', () => {
|
||||
expect(alertTypeDisplayLabel('node')).toBe('Node');
|
||||
expect(alertTypeDisplayLabel('zfs-device')).toBe('ZFS Device');
|
||||
expect(alertTypeDisplayLabel('raid')).toBe('RAID');
|
||||
expect(alertTypeDisplayLabel('resource-incident')).toBe('Resource Health');
|
||||
});
|
||||
|
||||
it('maps the remaining standalone arms', () => {
|
||||
expect(alertTypeDisplayLabel('pbs')).toBe('PBS');
|
||||
expect(alertTypeDisplayLabel('message-age')).toBe('Message Age');
|
||||
});
|
||||
|
||||
it('title-cases unknown types containing a mix of hyphens and underscores', () => {
|
||||
expect(alertTypeDisplayLabel('snapshot_disk-usage')).toBe('Snapshot Disk Usage');
|
||||
});
|
||||
|
||||
it('returns the empty string for an empty unknown type', () => {
|
||||
expect(alertTypeDisplayLabel('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unifiedTypeToAlertDisplayType', () => {
|
||||
it('returns the canonical resource-type label for known types', () => {
|
||||
expect(unifiedTypeToAlertDisplayType('vm')).toBe('VM');
|
||||
expect(unifiedTypeToAlertDisplayType('pbs')).toBe('PBS');
|
||||
expect(unifiedTypeToAlertDisplayType('storage')).toBe('Storage');
|
||||
expect(unifiedTypeToAlertDisplayType('ceph')).toBe('Ceph');
|
||||
});
|
||||
|
||||
it('falls back to the raw type string when no canonical label resolves', () => {
|
||||
expect(unifiedTypeToAlertDisplayType('' as unknown as Parameters<typeof unifiedTypeToAlertDisplayType>[0])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLocalTimezone', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('falls back to UTC when the resolved timezone is empty', () => {
|
||||
const spy = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(
|
||||
(() => ({ resolvedOptions: () => ({ timeZone: '' }) })) as unknown as typeof Intl.DateTimeFormat,
|
||||
);
|
||||
expect(getLocalTimezone()).toBe('UTC');
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns the resolved IANA timezone when one is available', () => {
|
||||
const spy = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(
|
||||
(() => ({ resolvedOptions: () => ({ timeZone: 'Australia/Sydney' }) })) as unknown as typeof Intl.DateTimeFormat,
|
||||
);
|
||||
expect(getLocalTimezone()).toBe('Australia/Sydney');
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeMetricDelayMap', () => {
|
||||
it('returns an empty object for nullish input', () => {
|
||||
expect(normalizeMetricDelayMap(undefined)).toEqual({});
|
||||
expect(normalizeMetricDelayMap(null)).toEqual({});
|
||||
});
|
||||
|
||||
it('returns an empty object for an empty record', () => {
|
||||
expect(normalizeMetricDelayMap({})).toEqual({});
|
||||
});
|
||||
|
||||
it('trims and lowercases type and metric keys and rounds fractional values', () => {
|
||||
const input = { ' VM ': { ' CPU ': 3.6, 'Memory': 7 } };
|
||||
expect(normalizeMetricDelayMap(input)).toEqual({ vm: { cpu: 4, memory: 7 } });
|
||||
});
|
||||
|
||||
it('rounds 0.5 up and 0.4 down', () => {
|
||||
expect(normalizeMetricDelayMap({ vm: { a: 0.5, b: 0.4 } })).toEqual({ vm: { a: 1, b: 0 } });
|
||||
});
|
||||
|
||||
it('skips entries whose metrics value is null', () => {
|
||||
expect(
|
||||
normalizeMetricDelayMap({ vm: null } as unknown as Parameters<typeof normalizeMetricDelayMap>[0]),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('skips entries with a whitespace-only type key', () => {
|
||||
expect(normalizeMetricDelayMap({ ' ': { cpu: 5 }, vm: { cpu: 1 } })).toEqual({
|
||||
vm: { cpu: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('drops non-number, NaN, and negative metric values but keeps zero', () => {
|
||||
const input = {
|
||||
vm: {
|
||||
cpu: 5,
|
||||
badString: 'x' as unknown as number,
|
||||
nanVal: Number.NaN,
|
||||
negative: -1,
|
||||
zero: 0,
|
||||
},
|
||||
};
|
||||
expect(normalizeMetricDelayMap(input)).toEqual({ vm: { cpu: 5, zero: 0 } });
|
||||
});
|
||||
|
||||
it('skips metric entries with a whitespace-only metric key', () => {
|
||||
expect(normalizeMetricDelayMap({ vm: { ' ': 5, cpu: 1 } })).toEqual({ vm: { cpu: 1 } });
|
||||
});
|
||||
|
||||
it('omits a type entirely when none of its metrics survive validation', () => {
|
||||
expect(
|
||||
normalizeMetricDelayMap({
|
||||
vm: { onlyBad: -1 },
|
||||
host: { cpu: 2 },
|
||||
}),
|
||||
).toEqual({ host: { cpu: 2 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlertResourceDisplayLabel', () => {
|
||||
const makeResource = (overrides: Partial<Resource>): Resource =>
|
||||
({ id: 'r1', name: 'r1', type: 'vm', ...overrides }) as Resource;
|
||||
|
||||
it('returns the preferred display name when it differs from the id', () => {
|
||||
const resource = makeResource({ id: 'node-1', displayName: 'Tower' });
|
||||
expect(getAlertResourceDisplayLabel(resource)).toBe('Tower');
|
||||
});
|
||||
|
||||
it('returns the fallback when the preferred name equals the id', () => {
|
||||
const resource = makeResource({ id: 'r1', name: 'r1', displayName: '' });
|
||||
expect(getAlertResourceDisplayLabel(resource, 'fallback-label')).toBe('fallback-label');
|
||||
});
|
||||
|
||||
it('falls through to the preferred (= id) when no fallback is supplied', () => {
|
||||
const resource = makeResource({ id: 'r1', name: 'r1', displayName: '' });
|
||||
expect(getAlertResourceDisplayLabel(resource)).toBe('r1');
|
||||
});
|
||||
|
||||
it('returns the empty id when nothing resolves and no fallback is given', () => {
|
||||
const resource = makeResource({ id: '', name: '', displayName: '' });
|
||||
expect(getAlertResourceDisplayLabel(resource)).toBe('');
|
||||
});
|
||||
|
||||
it('prefers a non-id display name over a supplied fallback', () => {
|
||||
const resource = makeResource({ id: 'r1', displayName: 'My Host' });
|
||||
expect(getAlertResourceDisplayLabel(resource, 'ignored')).toBe('My Host');
|
||||
});
|
||||
});
|
||||
});
|
||||
+475
@@ -0,0 +1,475 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Resource, ResourceStatus } from '@/types/resource';
|
||||
import {
|
||||
dockerContainerLifecycleName,
|
||||
getDockerContainerLifecycleDisabledReason,
|
||||
} from '../dockerContainerLifecycleActions';
|
||||
|
||||
const ALL_CAPABILITIES = [
|
||||
{ name: 'start', type: 'common', platform: 'docker', minimumApprovalLevel: 'admin' },
|
||||
{ name: 'stop', type: 'common', platform: 'docker', minimumApprovalLevel: 'admin' },
|
||||
{ name: 'restart', type: 'common', platform: 'docker', minimumApprovalLevel: 'admin' },
|
||||
];
|
||||
|
||||
const resource = (overrides: Partial<Resource> = {}): Resource => ({
|
||||
id: 'app-container:docker-host:web',
|
||||
name: 'web',
|
||||
displayName: 'web',
|
||||
platformId: 'docker-1',
|
||||
platformType: 'docker',
|
||||
sourceType: 'agent',
|
||||
sources: ['docker'],
|
||||
status: 'running',
|
||||
type: 'app-container',
|
||||
lastSeen: 1_700_000_000_000,
|
||||
docker: {
|
||||
runtime: 'docker',
|
||||
agentId: 'agent-1',
|
||||
containerId: 'abc123',
|
||||
containerState: 'running',
|
||||
},
|
||||
capabilities: ALL_CAPABILITIES,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('dockerContainerLifecycleActions.branchcov2', () => {
|
||||
describe('dockerContainerLifecycleName', () => {
|
||||
it('returns the trimmed resource name when present', () => {
|
||||
expect(dockerContainerLifecycleName(resource({ name: ' web-app ' }))).toBe('web-app');
|
||||
});
|
||||
|
||||
it('falls back to displayName when name is blank', () => {
|
||||
expect(
|
||||
dockerContainerLifecycleName(resource({ name: ' ', displayName: 'Web App' })),
|
||||
).toBe('Web App');
|
||||
});
|
||||
|
||||
it('falls back to docker.displayName when name and displayName are blank', () => {
|
||||
expect(
|
||||
dockerContainerLifecycleName(
|
||||
resource({
|
||||
name: '',
|
||||
displayName: ' ',
|
||||
docker: { runtime: 'docker', displayName: 'Container Display' },
|
||||
}),
|
||||
),
|
||||
).toBe('Container Display');
|
||||
});
|
||||
|
||||
it('falls back to docker.containerId when name, displayName, and docker.displayName are blank', () => {
|
||||
expect(
|
||||
dockerContainerLifecycleName(
|
||||
resource({
|
||||
name: '',
|
||||
displayName: '',
|
||||
docker: { runtime: 'docker', containerId: 'cid-9' },
|
||||
}),
|
||||
),
|
||||
).toBe('cid-9');
|
||||
});
|
||||
|
||||
it('falls back to resource.id when every name source is blank', () => {
|
||||
expect(
|
||||
dockerContainerLifecycleName(
|
||||
resource({
|
||||
name: '',
|
||||
displayName: '',
|
||||
docker: { runtime: 'docker' },
|
||||
}),
|
||||
),
|
||||
).toBe('app-container:docker-host:web');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDockerContainerLifecycleDisabledReason', () => {
|
||||
describe('runtime gating', () => {
|
||||
it('reports an unsupported runtime using the runtime label', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({ docker: { runtime: 'containerd', agentId: 'agent-1' } }),
|
||||
'restart',
|
||||
),
|
||||
).toBe('containerd is not supported for governed container lifecycle actions.');
|
||||
});
|
||||
|
||||
it('treats podman as supported and reaches the agent check with a Podman label', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({ docker: { runtime: 'podman' } }),
|
||||
'restart',
|
||||
),
|
||||
).toBe('No reporting Pulse agent is attached to this Podman host.');
|
||||
});
|
||||
|
||||
it('reports a missing runtime when docker is absent entirely', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(resource({ docker: undefined }), 'restart'),
|
||||
).toBe('Container runtime is not reported.');
|
||||
});
|
||||
|
||||
it('reports a missing runtime when the runtime token is whitespace-only', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
docker: { runtime: ' ', agentId: 'agent-1', containerState: 'running' },
|
||||
}),
|
||||
'restart',
|
||||
),
|
||||
).toBe('Container runtime is not reported.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceStatusDisabledReason (exercised via orchestrator)', () => {
|
||||
it('blocks on an offline inventory status', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({ sourceStatus: { docker: { status: 'offline' } } }),
|
||||
'restart',
|
||||
),
|
||||
).toBe('Docker inventory is offline; refresh inventory before running lifecycle actions.');
|
||||
});
|
||||
|
||||
it('blocks on a missing inventory status', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({ sourceStatus: { docker: { status: 'missing' } } }),
|
||||
'restart',
|
||||
),
|
||||
).toBe('Docker inventory is missing; refresh inventory before running lifecycle actions.');
|
||||
});
|
||||
|
||||
it('blocks on an inventory error when the status itself is healthy', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
sourceStatus: { docker: { status: 'healthy', error: 'connection refused' } },
|
||||
}),
|
||||
'restart',
|
||||
),
|
||||
).toBe('Docker inventory is not healthy: connection refused');
|
||||
});
|
||||
|
||||
it('blocks when lastSeen is zero (falsy timestamp)', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(resource({ lastSeen: 0 }), 'restart'),
|
||||
).toBe('Docker inventory has not reported a valid last-seen timestamp.');
|
||||
});
|
||||
|
||||
it('blocks when lastSeen is negative (truthy but <= 0)', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(resource({ lastSeen: -5 }), 'restart'),
|
||||
).toBe('Docker inventory has not reported a valid last-seen timestamp.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('security gating', () => {
|
||||
it('uses the default host-policy copy when mutatingCommandsBlockedReason is absent', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
docker: {
|
||||
runtime: 'docker',
|
||||
agentId: 'agent-1',
|
||||
containerId: 'abc123',
|
||||
containerState: 'running',
|
||||
security: { mutatingCommandsBlocked: true },
|
||||
},
|
||||
}),
|
||||
'restart',
|
||||
),
|
||||
).toBe('Docker host policy blocks mutating container lifecycle commands.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stateDisabledReason (exercised via orchestrator)', () => {
|
||||
it('reports a non-startable, non-running state for start', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
docker: {
|
||||
runtime: 'docker',
|
||||
agentId: 'agent-1',
|
||||
containerId: 'abc',
|
||||
containerState: 'paused',
|
||||
},
|
||||
}),
|
||||
'start',
|
||||
),
|
||||
).toBe('Container state paused is not startable.');
|
||||
});
|
||||
|
||||
it('prefers containerState over status for the state lookup', () => {
|
||||
// status 'stopped' would be startable; containerState 'paused' is not -> proves containerState wins.
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
status: 'stopped',
|
||||
docker: {
|
||||
runtime: 'docker',
|
||||
agentId: 'agent-1',
|
||||
containerId: 'abc',
|
||||
containerState: 'paused',
|
||||
},
|
||||
}),
|
||||
'start',
|
||||
),
|
||||
).toBe('Container state paused is not startable.');
|
||||
});
|
||||
|
||||
it('falls back to status when containerState is blank', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
status: 'paused',
|
||||
docker: {
|
||||
runtime: 'docker',
|
||||
agentId: 'agent-1',
|
||||
containerId: 'abc',
|
||||
containerState: '',
|
||||
},
|
||||
}),
|
||||
'start',
|
||||
),
|
||||
).toBe('Container state paused is not startable.');
|
||||
});
|
||||
|
||||
it('reports an unknown state for start when both containerState and status are blank', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
status: '' as unknown as ResourceStatus,
|
||||
docker: {
|
||||
runtime: 'docker',
|
||||
agentId: 'agent-1',
|
||||
containerId: 'abc',
|
||||
containerState: '',
|
||||
},
|
||||
}),
|
||||
'start',
|
||||
),
|
||||
).toBe('Container state is unknown.');
|
||||
});
|
||||
|
||||
it('reports that the container must be running before stop', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
docker: {
|
||||
runtime: 'docker',
|
||||
agentId: 'agent-1',
|
||||
containerId: 'abc',
|
||||
containerState: 'exited',
|
||||
},
|
||||
}),
|
||||
'stop',
|
||||
),
|
||||
).toBe('Container must be running before stop.');
|
||||
});
|
||||
|
||||
it('reports an unknown state for stop when both containerState and status are blank', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
status: '' as unknown as ResourceStatus,
|
||||
docker: {
|
||||
runtime: 'docker',
|
||||
agentId: 'agent-1',
|
||||
containerId: 'abc',
|
||||
containerState: '',
|
||||
},
|
||||
}),
|
||||
'stop',
|
||||
),
|
||||
).toBe('Container state is unknown.');
|
||||
});
|
||||
|
||||
it('does not block start for a startable state (created)', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
status: 'stopped',
|
||||
docker: {
|
||||
runtime: 'docker',
|
||||
agentId: 'agent-1',
|
||||
containerId: 'abc',
|
||||
containerState: 'created',
|
||||
},
|
||||
}),
|
||||
'start',
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('actionReadinessDisabledReason (exercised via orchestrator)', () => {
|
||||
it('prefers an explicit reason over the reasonCode switch', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
actionReadiness: [
|
||||
{
|
||||
name: 'restart',
|
||||
available: false,
|
||||
reasonCode: 'command_agent_disconnected',
|
||||
reason: 'Agent reboot in progress',
|
||||
},
|
||||
],
|
||||
}),
|
||||
'restart',
|
||||
),
|
||||
).toBe('Agent reboot in progress');
|
||||
});
|
||||
|
||||
it('maps command_agent_disconnected to the not-connected copy', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
actionReadiness: [
|
||||
{ name: 'restart', available: false, reasonCode: 'command_agent_disconnected' },
|
||||
],
|
||||
}),
|
||||
'restart',
|
||||
),
|
||||
).toBe('Docker / Podman command agent is not connected.');
|
||||
});
|
||||
|
||||
it('maps command_agent_unavailable to the execution-unavailable copy', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
actionReadiness: [
|
||||
{ name: 'restart', available: false, reasonCode: 'command_agent_unavailable' },
|
||||
],
|
||||
}),
|
||||
'restart',
|
||||
),
|
||||
).toBe('Docker / Podman command execution is not available.');
|
||||
});
|
||||
|
||||
it('maps stale_inventory to the not-fresh copy', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
actionReadiness: [
|
||||
{ name: 'restart', available: false, reasonCode: 'stale_inventory' },
|
||||
],
|
||||
}),
|
||||
'restart',
|
||||
),
|
||||
).toBe('Docker / Podman inventory is not fresh enough to run lifecycle actions.');
|
||||
});
|
||||
|
||||
it('maps host_policy_blocked to the policy-blocked copy', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
actionReadiness: [
|
||||
{ name: 'restart', available: false, reasonCode: 'host_policy_blocked' },
|
||||
],
|
||||
}),
|
||||
'restart',
|
||||
),
|
||||
).toBe('Docker / Podman host policy blocks mutating lifecycle actions.');
|
||||
});
|
||||
|
||||
it('maps unsupported_handler to the unsupported-executor copy', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
actionReadiness: [
|
||||
{ name: 'restart', available: false, reasonCode: 'unsupported_handler' },
|
||||
],
|
||||
}),
|
||||
'restart',
|
||||
),
|
||||
).toBe('This container action is not routed through the supported lifecycle executor.');
|
||||
});
|
||||
|
||||
it('falls through the default switch arm without blocking when a capability is advertised', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
actionReadiness: [
|
||||
{ name: 'restart', available: false, reasonCode: 'totally_unknown_code' },
|
||||
],
|
||||
}),
|
||||
'restart',
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not block when the matched readiness item is still available', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
actionReadiness: [
|
||||
{ name: 'restart', available: true, reasonCode: 'command_agent_disconnected' },
|
||||
],
|
||||
}),
|
||||
'restart',
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not block when readiness is reported for a different action', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
actionReadiness: [
|
||||
{ name: 'start', available: false, reasonCode: 'command_agent_disconnected' },
|
||||
],
|
||||
}),
|
||||
'restart',
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('matches readiness names case-insensitively', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(
|
||||
resource({
|
||||
actionReadiness: [
|
||||
{ name: 'RESTART', available: false, reasonCode: 'stale_inventory' },
|
||||
],
|
||||
}),
|
||||
'restart',
|
||||
),
|
||||
).toBe('Docker / Podman inventory is not fresh enough to run lifecycle actions.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('capability gating', () => {
|
||||
it('reports the missing capability using the requested action name', () => {
|
||||
expect(
|
||||
getDockerContainerLifecycleDisabledReason(resource({ capabilities: [] }), 'stop'),
|
||||
).toBe(
|
||||
'Pulse does not currently advertise a fresh stop command capability for this container.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('happy path', () => {
|
||||
it('returns undefined for stop and restart on a fully healthy running container', () => {
|
||||
const healthy = resource();
|
||||
|
||||
expect(getDockerContainerLifecycleDisabledReason(healthy, 'stop')).toBeUndefined();
|
||||
expect(getDockerContainerLifecycleDisabledReason(healthy, 'restart')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for start on a stopped container with healthy inventory', () => {
|
||||
const stopped = resource({
|
||||
status: 'offline',
|
||||
docker: {
|
||||
runtime: 'docker',
|
||||
agentId: 'agent-1',
|
||||
containerId: 'abc',
|
||||
containerState: 'stopped',
|
||||
},
|
||||
});
|
||||
|
||||
expect(getDockerContainerLifecycleDisabledReason(stopped, 'start')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+668
@@ -0,0 +1,668 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PatrolRunStatus } from '@/api/patrol';
|
||||
|
||||
import {
|
||||
getMonitorContextPatrolProtectionPosture,
|
||||
getPatrolQueueWorkspaceDescription,
|
||||
getPatrolSetupIssueReason,
|
||||
getPatrolWorkspaceWorkGroups,
|
||||
isPatrolCoverageStale,
|
||||
} from '../patrolControlPresentation';
|
||||
|
||||
// Five of the nine target functions (`normalizeStatus`, `getPatrolCoverageLabel`,
|
||||
// `formatMonitorCoverageLabel`, `getPatrolQueueActionDetail`,
|
||||
// `shouldSuppressMonitorContextPatrolPosture`) are module-private, so this file
|
||||
// drives them through their exported callers and asserts on the concrete
|
||||
// observable outputs that those private branches produce.
|
||||
|
||||
describe('patrolControlPresentation branch coverage (set 2)', () => {
|
||||
const NOW_MS = Date.parse('2026-06-30T15:00:00Z');
|
||||
|
||||
describe('getMonitorContextPatrolProtectionPosture', () => {
|
||||
it('returns no summaries when neither a latest run nor patrol status exists', () => {
|
||||
// Branch: `(!input.latestRun && !patrolStatus)` early-return [].
|
||||
expect(getMonitorContextPatrolProtectionPosture({ monitoredResourceCount: 4 })).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses warning tones throughout when the latest Patrol status is unhealthy', () => {
|
||||
// Branch: `patrolStatus?.healthy === false` -> healthyTone 'warning'.
|
||||
expect(
|
||||
getMonitorContextPatrolProtectionPosture({
|
||||
findingCount: 0,
|
||||
latestRun: {
|
||||
error_count: 0,
|
||||
resources_checked: 2,
|
||||
status: 'healthy',
|
||||
},
|
||||
monitoredResourceCount: 3,
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: {
|
||||
enabled: true,
|
||||
error_count: 0,
|
||||
findings_count: 0,
|
||||
healthy: false,
|
||||
next_patrol_at: '2026-07-01T10:00:00Z',
|
||||
resources_checked: 2,
|
||||
running: false,
|
||||
runtime_state: 'active',
|
||||
},
|
||||
pendingApprovalCount: 0,
|
||||
workTypeComposition: {
|
||||
total: 0,
|
||||
approval: 0,
|
||||
failed: 0,
|
||||
inProgress: 0,
|
||||
recurring: 0,
|
||||
newIssues: 0,
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
detail: 'Latest Patrol evidence is available while you review this monitor view.',
|
||||
id: 'coverage',
|
||||
label: 'Patrol checked 2 resources',
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
detail: 'Current Patrol findings and approvals stay in Patrol; none are waiting now.',
|
||||
id: 'open-work',
|
||||
label: 'No Patrol work waiting',
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
detail: 'Patrol is scheduled to check monitored resources again.',
|
||||
id: 'schedule',
|
||||
label: 'Next check scheduled',
|
||||
tone: 'info',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('surfaces a paused-schedule summary when scheduled checks are disabled', () => {
|
||||
// Branch: `patrolStatus?.enabled === false` schedule arm. next_patrol_at is
|
||||
// intentionally set to prove the enabled===false check wins.
|
||||
expect(
|
||||
getMonitorContextPatrolProtectionPosture({
|
||||
findingCount: 0,
|
||||
latestRun: {
|
||||
error_count: 0,
|
||||
resources_checked: 2,
|
||||
status: 'healthy',
|
||||
},
|
||||
monitoredResourceCount: 3,
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: {
|
||||
enabled: false,
|
||||
error_count: 0,
|
||||
findings_count: 0,
|
||||
healthy: true,
|
||||
next_patrol_at: '2026-07-01T10:00:00Z',
|
||||
resources_checked: 2,
|
||||
running: false,
|
||||
runtime_state: 'active',
|
||||
},
|
||||
pendingApprovalCount: 0,
|
||||
workTypeComposition: {
|
||||
total: 0,
|
||||
approval: 0,
|
||||
failed: 0,
|
||||
inProgress: 0,
|
||||
recurring: 0,
|
||||
newIssues: 0,
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
detail: 'Latest Patrol evidence is available while you review this monitor view.',
|
||||
id: 'coverage',
|
||||
label: 'Patrol checked 2 resources',
|
||||
tone: 'success',
|
||||
},
|
||||
{
|
||||
detail: 'Current Patrol findings and approvals stay in Patrol; none are waiting now.',
|
||||
id: 'open-work',
|
||||
label: 'No Patrol work waiting',
|
||||
tone: 'success',
|
||||
},
|
||||
{
|
||||
detail: 'Run Patrol manually or enable scheduled checks to keep coverage fresh.',
|
||||
id: 'schedule',
|
||||
label: 'Scheduled checks paused',
|
||||
tone: 'warning',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the ready-to-run schedule summary when no next check is set', () => {
|
||||
// Branch: schedule `else` arm (enabled !== false && no next_patrol_at).
|
||||
expect(
|
||||
getMonitorContextPatrolProtectionPosture({
|
||||
findingCount: 0,
|
||||
latestRun: null,
|
||||
monitoredResourceCount: 3,
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: {
|
||||
enabled: true,
|
||||
error_count: 0,
|
||||
findings_count: 0,
|
||||
healthy: true,
|
||||
next_patrol_at: undefined,
|
||||
resources_checked: 0,
|
||||
running: false,
|
||||
runtime_state: 'active',
|
||||
},
|
||||
pendingApprovalCount: 0,
|
||||
workTypeComposition: {
|
||||
total: 0,
|
||||
approval: 0,
|
||||
failed: 0,
|
||||
inProgress: 0,
|
||||
recurring: 0,
|
||||
newIssues: 0,
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
detail: 'Run Patrol to refresh current coverage for monitored resources.',
|
||||
id: 'coverage',
|
||||
label: 'Patrol coverage needs refresh',
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
detail: 'Current Patrol findings and approvals stay in Patrol; none are waiting now.',
|
||||
id: 'open-work',
|
||||
label: 'No Patrol work waiting',
|
||||
tone: 'success',
|
||||
},
|
||||
{
|
||||
detail: 'Run Patrol from the Patrol page any time to refresh coverage.',
|
||||
id: 'schedule',
|
||||
label: 'Ready to run Patrol',
|
||||
tone: 'info',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSuppressMonitorContextPatrolPosture (via getMonitorContextPatrolProtectionPosture)', () => {
|
||||
const baseMonitorInput = {
|
||||
findingCount: 0,
|
||||
latestRun: {
|
||||
error_count: 0,
|
||||
resources_checked: 4,
|
||||
status: 'healthy' as const,
|
||||
},
|
||||
monitoredResourceCount: 4,
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: {
|
||||
enabled: true,
|
||||
error_count: 0,
|
||||
findings_count: 0,
|
||||
healthy: true,
|
||||
next_patrol_at: '2026-07-01T10:00:00Z',
|
||||
resources_checked: 4,
|
||||
running: false,
|
||||
runtime_state: 'active' as const,
|
||||
},
|
||||
pendingApprovalCount: 0,
|
||||
workTypeComposition: {
|
||||
total: 0,
|
||||
approval: 0,
|
||||
failed: 0,
|
||||
inProgress: 0,
|
||||
recurring: 0,
|
||||
newIssues: 0,
|
||||
},
|
||||
};
|
||||
|
||||
it('does not suppress when every work, runtime, and schedule signal is clear', () => {
|
||||
// Baseline: every OR operand in shouldSuppress... evaluates false, so the
|
||||
// monitor-context posture is returned in full.
|
||||
expect(getMonitorContextPatrolProtectionPosture(baseMonitorInput)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['failed work-type composition', { failed: 1 }],
|
||||
['approval work-type composition', { approval: 1 }],
|
||||
['in-progress work-type composition', { inProgress: 1 }],
|
||||
['recurring work-type composition', { recurring: 1 }],
|
||||
] as const)(
|
||||
'suppresses the posture when %s is present',
|
||||
(_label, compositionOverride) => {
|
||||
expect(
|
||||
getMonitorContextPatrolProtectionPosture({
|
||||
...baseMonitorInput,
|
||||
workTypeComposition: {
|
||||
total: 1,
|
||||
approval: 0,
|
||||
failed: 0,
|
||||
inProgress: 0,
|
||||
recurring: 0,
|
||||
newIssues: 0,
|
||||
...compositionOverride,
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('suppresses the posture when the status reports open findings', () => {
|
||||
// OR operand: `statusFindingCount > 0`.
|
||||
expect(
|
||||
getMonitorContextPatrolProtectionPosture({
|
||||
...baseMonitorInput,
|
||||
patrolStatus: { ...baseMonitorInput.patrolStatus, findings_count: 1 },
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('suppresses the posture while a Patrol run is in flight', () => {
|
||||
// OR operand: `patrolStatus?.running`.
|
||||
expect(
|
||||
getMonitorContextPatrolProtectionPosture({
|
||||
...baseMonitorInput,
|
||||
patrolStatus: { ...baseMonitorInput.patrolStatus, running: true },
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('suppresses the posture when the status carries runtime errors', () => {
|
||||
// OR operand: `statusErrorCount > 0` (latest run stays healthy).
|
||||
expect(
|
||||
getMonitorContextPatrolProtectionPosture({
|
||||
...baseMonitorInput,
|
||||
patrolStatus: { ...baseMonitorInput.patrolStatus, error_count: 1 },
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('suppresses the posture when the runtime is not active', () => {
|
||||
// OR operand: `!isActivePatrolRuntime(patrolStatus)`.
|
||||
expect(
|
||||
getMonitorContextPatrolProtectionPosture({
|
||||
...baseMonitorInput,
|
||||
patrolStatus: { ...baseMonitorInput.patrolStatus, runtime_state: 'disabled' },
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('suppresses the posture when a scheduled patrol is overdue', () => {
|
||||
// OR operand: `isScheduledPatrolOverdue(patrolStatus, nowMs)`.
|
||||
expect(
|
||||
getMonitorContextPatrolProtectionPosture({
|
||||
...baseMonitorInput,
|
||||
patrolStatus: {
|
||||
...baseMonitorInput.patrolStatus,
|
||||
next_patrol_at: '2026-06-30T14:00:00Z',
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPatrolCoverageLabel (via getMonitorContextPatrolProtectionPosture)', () => {
|
||||
it('derives the coverage label from status resources when no latest run is available', () => {
|
||||
// Branch: latestRun null -> latestRunCoverage '' -> status resources > 0 ->
|
||||
// `Checked N resource(s)`.
|
||||
const summaries = getMonitorContextPatrolProtectionPosture({
|
||||
findingCount: 0,
|
||||
latestRun: null,
|
||||
monitoredResourceCount: 3,
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: {
|
||||
enabled: true,
|
||||
error_count: 0,
|
||||
findings_count: 0,
|
||||
healthy: true,
|
||||
next_patrol_at: '2026-07-01T10:00:00Z',
|
||||
resources_checked: 3,
|
||||
running: false,
|
||||
runtime_state: 'active',
|
||||
},
|
||||
pendingApprovalCount: 0,
|
||||
workTypeComposition: {
|
||||
total: 0,
|
||||
approval: 0,
|
||||
failed: 0,
|
||||
inProgress: 0,
|
||||
recurring: 0,
|
||||
newIssues: 0,
|
||||
},
|
||||
});
|
||||
expect(summaries[0]).toStrictEqual({
|
||||
detail: 'Latest Patrol evidence is available while you review this monitor view.',
|
||||
id: 'coverage',
|
||||
label: 'Patrol checked 3 resources',
|
||||
tone: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined coverage when neither latest run nor status has checked resources', () => {
|
||||
// Branch: latestRun null AND statusResourcesChecked === 0 -> undefined.
|
||||
const summaries = getMonitorContextPatrolProtectionPosture({
|
||||
findingCount: 0,
|
||||
latestRun: null,
|
||||
monitoredResourceCount: 3,
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: {
|
||||
enabled: true,
|
||||
error_count: 0,
|
||||
findings_count: 0,
|
||||
healthy: true,
|
||||
next_patrol_at: undefined,
|
||||
resources_checked: 0,
|
||||
running: false,
|
||||
runtime_state: 'active',
|
||||
},
|
||||
pendingApprovalCount: 0,
|
||||
workTypeComposition: {
|
||||
total: 0,
|
||||
approval: 0,
|
||||
failed: 0,
|
||||
inProgress: 0,
|
||||
recurring: 0,
|
||||
newIssues: 0,
|
||||
},
|
||||
});
|
||||
expect(summaries[0]).toStrictEqual({
|
||||
detail: 'Run Patrol to refresh current coverage for monitored resources.',
|
||||
id: 'coverage',
|
||||
label: 'Patrol coverage needs refresh',
|
||||
tone: 'warning',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMonitorCoverageLabel (via getMonitorContextPatrolProtectionPosture)', () => {
|
||||
it('lowercases the first character of a non-empty coverage label', () => {
|
||||
// Branch: coverageLabel truthy -> `Patrol ` + lowercase-first + rest.
|
||||
// 'Checked 3 resources' (status-derived) becomes 'Patrol checked 3 resources'.
|
||||
const [coverage] = getMonitorContextPatrolProtectionPosture({
|
||||
findingCount: 0,
|
||||
latestRun: null,
|
||||
monitoredResourceCount: 3,
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: {
|
||||
enabled: true,
|
||||
error_count: 0,
|
||||
findings_count: 0,
|
||||
healthy: true,
|
||||
next_patrol_at: '2026-07-01T10:00:00Z',
|
||||
resources_checked: 5,
|
||||
running: false,
|
||||
runtime_state: 'active',
|
||||
},
|
||||
pendingApprovalCount: 0,
|
||||
workTypeComposition: {
|
||||
total: 0,
|
||||
approval: 0,
|
||||
failed: 0,
|
||||
inProgress: 0,
|
||||
recurring: 0,
|
||||
newIssues: 0,
|
||||
},
|
||||
});
|
||||
expect(coverage?.label).toBe('Patrol checked 5 resources');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeStatus (via getPatrolWorkspaceWorkGroups)', () => {
|
||||
it('treats a whitespace-padded, mixed-case "error" status as a failed check', () => {
|
||||
// normalizeStatus(' Error ') -> 'error', which feeds hasFailedPatrolCheck.
|
||||
// resources_checked 0 also exercises the generic failed-check detail arm.
|
||||
expect(
|
||||
getPatrolWorkspaceWorkGroups({
|
||||
latestRun: {
|
||||
error_count: 0,
|
||||
resources_checked: 0,
|
||||
status: ' Error ' as unknown as PatrolRunStatus,
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
detail: 'The last Patrol check ended with runtime issues.',
|
||||
id: 'failed-check',
|
||||
label: 'Latest check needs review',
|
||||
tone: 'danger',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not mark a multi-word non-error status as a failed check', () => {
|
||||
// normalizeStatus('Error Recovery') -> 'error_recovery' (!== 'error'), and
|
||||
// with error_count 0 the run is not failed.
|
||||
expect(
|
||||
getPatrolWorkspaceWorkGroups({
|
||||
latestRun: {
|
||||
error_count: 0,
|
||||
resources_checked: 5,
|
||||
status: 'Error Recovery' as unknown as PatrolRunStatus,
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPatrolCoverageStale', () => {
|
||||
it('never treats a running patrol as stale', () => {
|
||||
expect(
|
||||
isPatrolCoverageStale({
|
||||
latestRun: { completed_at: '2020-01-01T00:00:00Z' },
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: { running: true },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an overdue scheduled patrol as stale before checking freshness', () => {
|
||||
// isScheduledPatrolOverdue short-circuits ahead of the lastCheckAt logic.
|
||||
expect(
|
||||
isPatrolCoverageStale({
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: {
|
||||
next_patrol_at: '2026-06-30T14:05:00Z',
|
||||
running: false,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is not stale when there is no last-check timestamp to compare against', () => {
|
||||
// Branch: `!lastCheckAt` -> false.
|
||||
expect(
|
||||
isPatrolCoverageStale({
|
||||
latestRun: { completed_at: undefined },
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: { running: false },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is not stale when the only timestamp is in the future', () => {
|
||||
// Branch: `lastCheckMs > nowMs` -> false.
|
||||
expect(
|
||||
isPatrolCoverageStale({
|
||||
latestRun: { completed_at: '2026-07-02T00:00:00Z' },
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: { running: false },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is not stale when the last-check timestamp cannot be parsed', () => {
|
||||
// Branch: `!Number.isFinite(lastCheckMs)` -> false.
|
||||
expect(
|
||||
isPatrolCoverageStale({
|
||||
latestRun: { completed_at: 'not-a-real-date' },
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: { running: false },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to patrol status last_patrol_at when the run lacks completed_at', () => {
|
||||
// Branch: `completed_at || last_patrol_at` picks the status timestamp.
|
||||
expect(
|
||||
isPatrolCoverageStale({
|
||||
latestRun: { completed_at: undefined },
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: {
|
||||
interval_ms: 6 * 60 * 60 * 1000,
|
||||
last_patrol_at: '2026-06-28T12:00:00Z',
|
||||
running: false,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the 24h minimum freshness window when no interval is configured', () => {
|
||||
// 25h-old check exceeds the minimum window (interval_ms 0 -> max(24h, 0)).
|
||||
expect(
|
||||
isPatrolCoverageStale({
|
||||
latestRun: { completed_at: '2026-06-29T14:00:00Z' },
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: { running: false },
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
// 12h-old check is inside the 24h minimum window.
|
||||
expect(
|
||||
isPatrolCoverageStale({
|
||||
latestRun: { completed_at: '2026-06-30T03:00:00Z' },
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: { running: false },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPatrolSetupIssueReason', () => {
|
||||
it('returns the readiness summary verbatim when it has no tool-call wording', () => {
|
||||
// Branch: readinessSummary truthy but neither regex matches -> return it.
|
||||
expect(getPatrolSetupIssueReason({ readinessSummary: 'Provider rate limit hit.' })).toBe(
|
||||
'Provider rate limit hit.',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps a "run tools" readiness summary to the fixed tool-call reason', () => {
|
||||
// Branch: second regex /\brun tools?\b/i matches.
|
||||
expect(
|
||||
getPatrolSetupIssueReason({
|
||||
readinessSummary: 'The selected model cannot reliably run tools.',
|
||||
}),
|
||||
).toBe('Selected model cannot run Patrol tools.');
|
||||
});
|
||||
|
||||
it('falls through to the blocked reason when the trigger reason is blank', () => {
|
||||
// Branch: triggerDisabledReason empty -> blockedReason wins.
|
||||
expect(
|
||||
getPatrolSetupIssueReason({
|
||||
triggerDisabledReason: ' ',
|
||||
blockedReason: 'Patrol config is invalid',
|
||||
}),
|
||||
).toBe('Patrol config is invalid');
|
||||
});
|
||||
|
||||
it('skips a whitespace-only readiness summary and falls through to other reasons', () => {
|
||||
// Branch: normalizeText(readinessSummary) -> '' fails the truthy guard.
|
||||
expect(
|
||||
getPatrolSetupIssueReason({
|
||||
readinessSummary: ' ',
|
||||
triggerDisabledReason: 'Patrol is paused',
|
||||
}),
|
||||
).toBe('Patrol is paused');
|
||||
});
|
||||
|
||||
it('skips a whitespace-only setup finding title to reach the readiness summary', () => {
|
||||
// Branch: normalizeText(setupFindingTitle) -> '' fails the truthy guard.
|
||||
expect(
|
||||
getPatrolSetupIssueReason({
|
||||
setupFindingTitle: ' ',
|
||||
readinessSummary: 'Model quota exhausted',
|
||||
}),
|
||||
).toBe('Model quota exhausted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPatrolQueueActionDetail (via getPatrolQueueWorkspaceDescription)', () => {
|
||||
it('returns the locked-control copy even when autonomy would allow fixes', () => {
|
||||
// Branch: `input.autonomyLocked` short-circuits before the autonomy switch.
|
||||
expect(
|
||||
getPatrolQueueWorkspaceDescription({
|
||||
autonomyLevel: 'full',
|
||||
autonomyLocked: true,
|
||||
findingCount: 2,
|
||||
affectedResourceCount: 1,
|
||||
}),
|
||||
).toBe(
|
||||
'Patrol found 2 issues on 1 affected resource. Open a row to review evidence and record the outcome.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPatrolWorkspaceWorkGroups', () => {
|
||||
it('surfaces a failed-check group from status error count alone, with checked-resource detail', () => {
|
||||
// Branch: `latestRunFailed || statusErrorCount > 0` -> statusErrorCount path
|
||||
// (latest run healthy), and checkedResources > 0 -> detailed detail string.
|
||||
expect(
|
||||
getPatrolWorkspaceWorkGroups({
|
||||
latestRun: {
|
||||
error_count: 0,
|
||||
resources_checked: 3,
|
||||
status: 'healthy',
|
||||
},
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: {
|
||||
error_count: 1,
|
||||
next_patrol_at: '2026-07-01T10:00:00Z',
|
||||
running: false,
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
detail: 'Patrol checked 3 resources but ended with runtime issues.',
|
||||
id: 'failed-check',
|
||||
label: 'Latest check needs review',
|
||||
tone: 'danger',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the generic failed-check detail when no resources were checked', () => {
|
||||
// Branch: statusErrorCount > 0 with latestRun null -> checkedResources 0.
|
||||
expect(
|
||||
getPatrolWorkspaceWorkGroups({
|
||||
latestRun: null,
|
||||
nowMs: NOW_MS,
|
||||
patrolStatus: {
|
||||
error_count: 1,
|
||||
next_patrol_at: '2026-07-01T10:00:00Z',
|
||||
running: false,
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
detail: 'The last Patrol check ended with runtime issues.',
|
||||
id: 'failed-check',
|
||||
label: 'Latest check needs review',
|
||||
tone: 'danger',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not add a failed-check group for an error status that normalizes away from "error"', () => {
|
||||
// status 'errored_retry' normalizes to 'errored_retry' (!== 'error'); with
|
||||
// no status error count and no other triggers, no group is produced.
|
||||
expect(
|
||||
getPatrolWorkspaceWorkGroups({
|
||||
latestRun: {
|
||||
error_count: 0,
|
||||
resources_checked: 4,
|
||||
status: 'errored_retry' as unknown as PatrolRunStatus,
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PBSBackup } from '@/types/api';
|
||||
|
||||
import {
|
||||
classifyTaskStatus,
|
||||
cmpBool,
|
||||
cmpNumber,
|
||||
cmpString,
|
||||
pbsRepositoryLabel,
|
||||
pbsWorkloadLabel,
|
||||
} from '../proxmoxBackupsTableModel';
|
||||
|
||||
// Same loose fixture builder the sibling test file uses: Partial overrides
|
||||
// cast whole as PBSBackup so omitted required fields are undefined at runtime,
|
||||
// which is exactly what lets us drive the defensive `??` / `?.` / `||` arms.
|
||||
const pbs = (overrides: Partial<PBSBackup>): PBSBackup => overrides as PBSBackup;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pbsWorkloadLabel — sibling tests cover ct/vm and the host arms. These cover
|
||||
// the fallback branch (unknown/absent backupType) and the toLowerCase path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('pbsWorkloadLabel — uncovered branches', () => {
|
||||
it('falls back to "Backup <vmid>" when backupType is absent and vmid is set', () => {
|
||||
// backupType omitted -> runtime undefined -> `backup.backupType ?? ''`
|
||||
// right operand fires; `backup.backupType?.trim()` short-circuits to
|
||||
// undefined -> `|| 'Backup'` defaults the kind; vmid truthy -> `${kind} ${vmid}`.
|
||||
expect(pbsWorkloadLabel(pbs({ vmid: '500' }))).toBe('Backup 500');
|
||||
});
|
||||
|
||||
it('uppercases an unknown backupType and appends vmid', () => {
|
||||
// Fallback: trim().toUpperCase() yields a non-empty kind; vmid truthy arm.
|
||||
expect(pbsWorkloadLabel(pbs({ backupType: 'qemu', vmid: '555' }))).toBe('QEMU 555');
|
||||
});
|
||||
|
||||
it('returns just the uppercased kind when vmid is empty for an unknown type', () => {
|
||||
// Fallback return `: kind` arm (vmid falsy).
|
||||
expect(pbsWorkloadLabel(pbs({ backupType: 'qemu', vmid: '' }))).toBe('QEMU');
|
||||
});
|
||||
|
||||
it('defaults a whitespace-only backupType to "Backup" with no vmid', () => {
|
||||
// backupType present but trim() -> '' -> `|| 'Backup'`; empty vmid -> `: kind`.
|
||||
expect(pbsWorkloadLabel(pbs({ backupType: ' ', vmid: '' }))).toBe('Backup');
|
||||
});
|
||||
|
||||
it('matches backupType case-insensitively on the ct arm', () => {
|
||||
// Exercises `(backup.backupType ?? '').toLowerCase()` normalizing 'CT' -> 'ct'.
|
||||
expect(pbsWorkloadLabel(pbs({ backupType: 'CT', vmid: '707' }))).toBe('LXC 707');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cmpString — sibling tests cover the av<bv / av>bv and single-blank asc arms.
|
||||
// These cover both-blank, both-undefined, single-undefined, equal, and desc.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cmpString — uncovered branches', () => {
|
||||
it('returns 0 when both values are empty strings', () => {
|
||||
// `!av && !bv` arm.
|
||||
expect(cmpString('', '', 'asc')).toBe(0);
|
||||
expect(cmpString('', '', 'desc')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when both values are undefined', () => {
|
||||
// Both `(a ?? '')` and `(b ?? '')` right operands fire, then `!av && !bv`.
|
||||
expect(cmpString(undefined, undefined, 'asc')).toBe(0);
|
||||
expect(cmpString(undefined, undefined, 'desc')).toBe(0);
|
||||
});
|
||||
|
||||
it('pushes an undefined first value to the end regardless of direction', () => {
|
||||
// `if (!av) return 1` arm (a undefined -> av '').
|
||||
expect(cmpString(undefined, 'beta', 'asc')).toBe(1);
|
||||
expect(cmpString(undefined, 'beta', 'desc')).toBe(1);
|
||||
});
|
||||
|
||||
it('pushes an undefined second value to the end regardless of direction', () => {
|
||||
// `if (!bv) return -1` arm (b undefined -> bv '').
|
||||
expect(cmpString('alpha', undefined, 'asc')).toBe(-1);
|
||||
expect(cmpString('alpha', undefined, 'desc')).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns 0 for equal non-empty values (desc negation yields -0)', () => {
|
||||
// `av === bv ? 0` arm. asc returns +0; desc does `-cmp` = `-0`. The two are
|
||||
// equal under `===` (sort-safe) but distinct under Object.is — asserted
|
||||
// explicitly below to pin the real desc-arm output.
|
||||
const asc = cmpString('alpha', 'alpha', 'asc');
|
||||
const desc = cmpString('alpha', 'alpha', 'desc');
|
||||
expect(asc).toBe(0);
|
||||
expect(desc === 0).toBe(true);
|
||||
expect(Object.is(desc, -0)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps blank values last even under desc', () => {
|
||||
// Blank guards intentionally ignore direction; sibling only asserted asc.
|
||||
expect(cmpString('', 'beta', 'desc')).toBe(1);
|
||||
expect(cmpString('beta', '', 'desc')).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cmpNumber — sibling tests cover a<b asc/desc, a=undefined, and a=NaN. These
|
||||
// cover both-missing, b-missing, equal, non-finite coercion, and a>b desc.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cmpNumber — uncovered branches', () => {
|
||||
it('returns 0 when both values are undefined', () => {
|
||||
// `av === undefined && bv === undefined` arm.
|
||||
expect(cmpNumber(undefined, undefined, 'asc')).toBe(0);
|
||||
expect(cmpNumber(undefined, undefined, 'desc')).toBe(0);
|
||||
});
|
||||
|
||||
it('pushes an undefined second value to the end regardless of direction', () => {
|
||||
// `if (bv === undefined) return -1` arm (sibling only covers a undefined).
|
||||
expect(cmpNumber(5, undefined, 'asc')).toBe(-1);
|
||||
expect(cmpNumber(5, undefined, 'desc')).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns 0 for equal finite numbers (desc negation yields -0)', () => {
|
||||
// asc returns +0; desc does `-cmp` = `-0` for an equal pair.
|
||||
const asc = cmpNumber(5, 5, 'asc');
|
||||
const desc = cmpNumber(5, 5, 'desc');
|
||||
expect(asc).toBe(0);
|
||||
expect(desc === 0).toBe(true);
|
||||
expect(Object.is(desc, -0)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats +Infinity, -Infinity, and NaN as missing', () => {
|
||||
// `typeof === "number" && Number.isFinite(...)` rejects all three.
|
||||
expect(cmpNumber(Number.POSITIVE_INFINITY, 5, 'asc')).toBe(1);
|
||||
expect(cmpNumber(Number.NEGATIVE_INFINITY, 5, 'asc')).toBe(1);
|
||||
expect(cmpNumber(5, Number.NaN, 'asc')).toBe(-1);
|
||||
});
|
||||
|
||||
it('negates the comparison under desc when the first value is larger', () => {
|
||||
// a > b -> cmp = +3; desc -> -3. Sibling desc case only had a < b (>0).
|
||||
expect(cmpNumber(5, 2, 'asc')).toBe(3);
|
||||
expect(cmpNumber(5, 2, 'desc')).toBe(-3);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// classifyTaskStatus — sibling tests cover ok/SUCCESS, running, error, empty,
|
||||
// and a lowercase unknown. These cover 'completed', 'failed', case-insensitive
|
||||
// warning, null status, and original-case preservation in the fallback label.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('classifyTaskStatus — uncovered branches', () => {
|
||||
it('maps "completed" to the success variant with the exact full shape', () => {
|
||||
// Third OR operand of the success condition (sibling covers ok/SUCCESS).
|
||||
expect(classifyTaskStatus('completed')).toStrictEqual({
|
||||
variant: 'success',
|
||||
label: 'OK',
|
||||
toneClass: 'text-emerald-600 dark:text-emerald-300',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "failed" to the danger variant with the exact full shape', () => {
|
||||
// Other OR operand of the danger condition (sibling only covers 'error').
|
||||
expect(classifyTaskStatus('failed')).toStrictEqual({
|
||||
variant: 'danger',
|
||||
label: 'Failed',
|
||||
toneClass: 'text-red-600 dark:text-red-300',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps "RUNNING" (upper) to warning via toLowerCase normalization', () => {
|
||||
// `normalized === 'running'` arm reached only because of `.toLowerCase()`.
|
||||
expect(classifyTaskStatus('RUNNING')).toStrictEqual({
|
||||
variant: 'warning',
|
||||
label: 'Running',
|
||||
toneClass: 'text-amber-600 dark:text-amber-300',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a null status as empty and returns the muted em-dash', () => {
|
||||
// `(status ?? '')` right operand -> '' -> `!normalized` early-return arm.
|
||||
expect(
|
||||
classifyTaskStatus(null as unknown as Parameters<typeof classifyTaskStatus>[0]),
|
||||
).toStrictEqual({
|
||||
variant: 'muted',
|
||||
label: '—',
|
||||
toneClass: 'text-muted',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the original case in the fallback label for unknown statuses', () => {
|
||||
// Terminal fallback returns `label: status` (the original, not normalized).
|
||||
expect(classifyTaskStatus('Paused')).toStrictEqual({
|
||||
variant: 'muted',
|
||||
label: 'Paused',
|
||||
toneClass: 'text-muted',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pbsRepositoryLabel — sibling tests cover datastore+namespace and datastore
|
||||
// with an absent namespace. These cover the em-dash datastore fallback, the
|
||||
// whitespace/trim namespace paths, and the both-absent combination.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('pbsRepositoryLabel — uncovered branches', () => {
|
||||
it('falls back to an em-dash when datastore is absent', () => {
|
||||
// `backup.datastore || '—'` right operand.
|
||||
expect(pbsRepositoryLabel(pbs({ namespace: 'team' }))).toBe('— / team');
|
||||
});
|
||||
|
||||
it('falls back to an em-dash when datastore is an empty string', () => {
|
||||
expect(pbsRepositoryLabel(pbs({ datastore: '', namespace: 'team' }))).toBe('— / team');
|
||||
});
|
||||
|
||||
it('treats a whitespace-only namespace as root', () => {
|
||||
// `namespace?.trim()` -> '' (falsy) -> '(root)'.
|
||||
expect(pbsRepositoryLabel(pbs({ datastore: 'main', namespace: ' ' }))).toBe(
|
||||
'main / (root)',
|
||||
);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from a named namespace', () => {
|
||||
// `namespace?.trim()` -> 'team' (truthy, trimmed).
|
||||
expect(pbsRepositoryLabel(pbs({ datastore: 'main', namespace: ' team ' }))).toBe(
|
||||
'main / team',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back fully when both datastore and namespace are absent', () => {
|
||||
// namespace undefined -> `?.` short-circuits -> '(root)'; datastore -> '—'.
|
||||
expect(pbsRepositoryLabel(pbs({}))).toBe('— / (root)');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cmpBool — sibling tests cover true/false desc, true/false asc, and true/true
|
||||
// asc. These cover false/false, false/true (both directions), and true/true desc.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cmpBool — uncovered branches', () => {
|
||||
it('returns 0 for false vs false (desc negation yields -0)', () => {
|
||||
// Both `(a ? 1 : 0)` and `(b ? 1 : 0)` take their falsy arms -> cmp 0.
|
||||
const asc = cmpBool(false, false, 'asc');
|
||||
const desc = cmpBool(false, false, 'desc');
|
||||
expect(asc).toBe(0);
|
||||
expect(desc === 0).toBe(true);
|
||||
expect(Object.is(desc, -0)).toBe(true);
|
||||
});
|
||||
|
||||
it('sorts false ahead of true under asc and behind under desc', () => {
|
||||
// a falsy / b truthy: cmp = 0 - 1 = -1; asc keeps -1, desc negates to +1.
|
||||
expect(cmpBool(false, true, 'asc')).toBe(-1);
|
||||
expect(cmpBool(false, true, 'desc')).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 0 for true vs true under desc (desc negation yields -0)', () => {
|
||||
// Sibling covers true/true asc; this nails the desc arm, whose `-cmp` of a
|
||||
// zero produces -0.
|
||||
const desc = cmpBool(true, true, 'desc');
|
||||
expect(desc === 0).toBe(true);
|
||||
expect(Object.is(desc, -0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,795 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PlatformType, Resource, ResourceAlert, ResourceMetric, ResourceType } from '@/types/resource';
|
||||
|
||||
import {
|
||||
buildProxmoxPageModel,
|
||||
getMetricPercent,
|
||||
getResourceClusterLabel,
|
||||
getResourceLastBackup,
|
||||
getResourceNodeName,
|
||||
getResourceVersion,
|
||||
getResourceVmid,
|
||||
isProxmoxStorageResource,
|
||||
resolveProxmoxPlatformScope,
|
||||
} from '../proxmoxPageModel';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture builder — mirrors the sibling proxmoxPageModel.test.ts factory so
|
||||
// import style and default platform posture stay aligned.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'>): Resource => ({
|
||||
name: resource.id,
|
||||
displayName: resource.id,
|
||||
platformId: 'lab',
|
||||
platformType: 'proxmox-pve',
|
||||
sourceType: 'api',
|
||||
status: 'online',
|
||||
lastSeen: 1_700_000_000_000,
|
||||
...resource,
|
||||
});
|
||||
|
||||
const alert = (overrides: Partial<ResourceAlert> = {}): ResourceAlert => ({
|
||||
id: 'alert-1',
|
||||
type: 'cpu',
|
||||
level: 'warning',
|
||||
message: 'high load',
|
||||
value: 95,
|
||||
threshold: 90,
|
||||
startTime: 1_700_000_000_000,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getPlatformSources — module-private, exercised transitively through
|
||||
// resolveProxmoxPlatformScope (its only call site).
|
||||
// ===========================================================================
|
||||
|
||||
describe('getPlatformSources branches (via resolveProxmoxPlatformScope)', () => {
|
||||
it('falls back to resource.sources when platformData.sources is not an array', () => {
|
||||
// platformData.sources is a string -> not Array.isArray -> returns resource.sources.
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'agent',
|
||||
platformType: 'generic',
|
||||
sources: ['pbs'],
|
||||
platformData: { sources: 'not-an-array' },
|
||||
});
|
||||
expect(resolveProxmoxPlatformScope(resource)).toBe('proxmox-pbs');
|
||||
});
|
||||
|
||||
it('returns [] when platformData.sources is not an array and resource.sources is undefined', () => {
|
||||
// `resource.sources ?? []` -> [] -> no source match -> null.
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'agent',
|
||||
platformType: 'generic',
|
||||
platformData: { sources: 'not-an-array' },
|
||||
});
|
||||
expect(resolveProxmoxPlatformScope(resource)).toBeNull();
|
||||
});
|
||||
|
||||
it('filters platformData.sources to strings only (non-strings would crash toLowerCase)', () => {
|
||||
// If the filter did not strip the number/boolean/null, the downstream
|
||||
// `.toLowerCase()` in resolveProxmoxPlatformScope would throw.
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'agent',
|
||||
platformType: 'generic',
|
||||
platformData: { sources: ['proxmox-pmg', 123, true, null, 'noise'] },
|
||||
});
|
||||
expect(resolveProxmoxPlatformScope(resource)).toBe('proxmox-pmg');
|
||||
});
|
||||
|
||||
it('returns null when the filtered source list contains no known scope hint', () => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'agent',
|
||||
platformType: 'generic',
|
||||
platformData: { sources: ['docker', 'kubernetes'] },
|
||||
});
|
||||
expect(resolveProxmoxPlatformScope(resource)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// resolveProxmoxPlatformScope — direct arms beyond getPlatformSources.
|
||||
// ===========================================================================
|
||||
|
||||
describe('resolveProxmoxPlatformScope direct arms', () => {
|
||||
it.each<[string, ResourceType, PlatformType]>([
|
||||
['resolves platformType proxmox-pve', 'agent', 'proxmox-pve'],
|
||||
['resolves platformType proxmox-pbs for a datastore', 'datastore', 'proxmox-pbs'],
|
||||
['resolves platformType proxmox-pmg', 'agent', 'proxmox-pmg'],
|
||||
])('%s', (_label, type, platformType) => {
|
||||
const resource = makeResource({ id: 'r', type, platformType });
|
||||
expect(resolveProxmoxPlatformScope(resource)).toBe(platformType);
|
||||
});
|
||||
|
||||
it('resolves proxmox-pve when resource.proxmox is a truthy object', () => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'agent',
|
||||
platformType: 'generic',
|
||||
proxmox: { nodeName: 'n1' },
|
||||
});
|
||||
expect(resolveProxmoxPlatformScope(resource)).toBe('proxmox-pve');
|
||||
});
|
||||
|
||||
it('resolves proxmox-pve when platformData.proxmox is a record (resource.proxmox absent)', () => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'agent',
|
||||
platformType: 'generic',
|
||||
platformData: { proxmox: { hint: 'pve' } },
|
||||
});
|
||||
expect(resolveProxmoxPlatformScope(resource)).toBe('proxmox-pve');
|
||||
});
|
||||
|
||||
it.each<[string, string]>([
|
||||
['pbs short hint', 'pbs'],
|
||||
['PBS uppercase hint (case-insensitive)', 'PBS'],
|
||||
['proxmox-pbs long hint', 'proxmox-pbs'],
|
||||
])('resolves proxmox-pbs from source %s', (_label, source) => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'agent',
|
||||
platformType: 'generic',
|
||||
sources: [source],
|
||||
});
|
||||
expect(resolveProxmoxPlatformScope(resource)).toBe('proxmox-pbs');
|
||||
});
|
||||
|
||||
it.each<[string, string]>([
|
||||
['pmg short hint', 'pmg'],
|
||||
['proxmox-pmg long hint', 'proxmox-pmg'],
|
||||
])('resolves proxmox-pmg from source %s', (_label, source) => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'agent',
|
||||
platformType: 'generic',
|
||||
sources: [source],
|
||||
});
|
||||
expect(resolveProxmoxPlatformScope(resource)).toBe('proxmox-pmg');
|
||||
});
|
||||
|
||||
it.each<[string, string]>([
|
||||
['pve short hint', 'pve'],
|
||||
['proxmox-pve long hint', 'proxmox-pve'],
|
||||
])('resolves proxmox-pve from source %s', (_label, source) => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'agent',
|
||||
platformType: 'generic',
|
||||
sources: [source],
|
||||
});
|
||||
expect(resolveProxmoxPlatformScope(resource)).toBe('proxmox-pve');
|
||||
});
|
||||
|
||||
it('returns null when no scope signal is present', () => {
|
||||
const resource = makeResource({ id: 'r', type: 'agent', platformType: 'generic' });
|
||||
expect(resolveProxmoxPlatformScope(resource)).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers type pbs over a contradicting platformType', () => {
|
||||
const resource = makeResource({ id: 'r', type: 'pbs', platformType: 'proxmox-pve' });
|
||||
expect(resolveProxmoxPlatformScope(resource)).toBe('proxmox-pbs');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// isProxmoxStorageResource
|
||||
// ===========================================================================
|
||||
|
||||
describe('isProxmoxStorageResource branches', () => {
|
||||
it('returns false for a ceph-typed resource', () => {
|
||||
const resource = makeResource({ id: 'ceph-1', type: 'ceph' });
|
||||
expect(isProxmoxStorageResource(resource)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when storage.isCeph is true even on a storage type', () => {
|
||||
const resource = makeResource({
|
||||
id: 'stor-ceph',
|
||||
type: 'storage',
|
||||
storage: { isCeph: true },
|
||||
});
|
||||
expect(isProxmoxStorageResource(resource)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a storage resource under proxmox-pve', () => {
|
||||
const resource = makeResource({
|
||||
id: 'stor-1',
|
||||
type: 'storage',
|
||||
storage: { isCeph: false },
|
||||
});
|
||||
expect(isProxmoxStorageResource(resource)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for a datastore resource under proxmox-pbs', () => {
|
||||
const resource = makeResource({
|
||||
id: 'ds-1',
|
||||
type: 'datastore',
|
||||
platformType: 'proxmox-pbs',
|
||||
});
|
||||
expect(isProxmoxStorageResource(resource)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a storage resource under proxmox-pmg', () => {
|
||||
const resource = makeResource({
|
||||
id: 'stor-pmg',
|
||||
type: 'storage',
|
||||
platformType: 'proxmox-pmg',
|
||||
});
|
||||
expect(isProxmoxStorageResource(resource)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a storage resource with no resolved scope', () => {
|
||||
const resource = makeResource({
|
||||
id: 'stor-orphan',
|
||||
type: 'storage',
|
||||
platformType: 'generic',
|
||||
});
|
||||
expect(isProxmoxStorageResource(resource)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a non-storage proxmox-pve resource', () => {
|
||||
const resource = makeResource({ id: 'agent-1', type: 'agent' });
|
||||
expect(isProxmoxStorageResource(resource)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getMetricPercent
|
||||
// ===========================================================================
|
||||
|
||||
describe('getMetricPercent branches', () => {
|
||||
it('returns 0 when no metric is provided', () => {
|
||||
expect(getMetricPercent(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns the finite current value unchanged within range', () => {
|
||||
expect(getMetricPercent({ current: 42.7 })).toBe(42.7);
|
||||
});
|
||||
|
||||
it('clamps an over-range current to 100', () => {
|
||||
expect(getMetricPercent({ current: 150 })).toBe(100);
|
||||
});
|
||||
|
||||
it('clamps a negative current to 0', () => {
|
||||
expect(getMetricPercent({ current: -20 })).toBe(0);
|
||||
});
|
||||
|
||||
it('does not clamp the exact boundary 100', () => {
|
||||
expect(getMetricPercent({ current: 100 })).toBe(100);
|
||||
});
|
||||
|
||||
it('falls back to the used/total ratio when current is NaN', () => {
|
||||
const metric: ResourceMetric = { current: NaN, total: 200, used: 50 };
|
||||
expect(getMetricPercent(metric)).toBe(25);
|
||||
});
|
||||
|
||||
it('falls back to the used/total ratio clamped to 100', () => {
|
||||
const metric: ResourceMetric = { current: NaN, total: 10, used: 30 };
|
||||
expect(getMetricPercent(metric)).toBe(100);
|
||||
});
|
||||
|
||||
it('uses used/total when current is a non-number (defensive cast)', () => {
|
||||
const metric = {
|
||||
current: 'bad' as unknown as number,
|
||||
total: 100,
|
||||
used: 100,
|
||||
} satisfies ResourceMetric;
|
||||
expect(getMetricPercent(metric)).toBe(100);
|
||||
});
|
||||
|
||||
it('returns 0 when current is not a finite number and total/used are absent', () => {
|
||||
const metric: ResourceMetric = { current: NaN };
|
||||
expect(getMetricPercent(metric)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getResourceVmid
|
||||
// ===========================================================================
|
||||
|
||||
describe('getResourceVmid branches', () => {
|
||||
it('returns the proxmox.vmid as a string when it is a finite number', () => {
|
||||
const resource = makeResource({ id: 'vm-1', type: 'vm', proxmox: { vmid: 101 } });
|
||||
expect(getResourceVmid(resource)).toBe('101');
|
||||
});
|
||||
|
||||
it('ignores a non-finite proxmox.vmid (NaN) and reads platformData.proxmox.vmid', () => {
|
||||
const resource = makeResource({
|
||||
id: 'vm-2',
|
||||
type: 'vm',
|
||||
proxmox: { vmid: NaN },
|
||||
platformData: { proxmox: { vmid: 202 } },
|
||||
});
|
||||
expect(getResourceVmid(resource)).toBe('202');
|
||||
});
|
||||
|
||||
it('ignores a wrong-typed proxmox.vmid (string) when no platformData vmid exists', () => {
|
||||
const resource = makeResource({
|
||||
id: 'vm-3',
|
||||
type: 'vm',
|
||||
proxmox: { vmid: '103' as unknown as number },
|
||||
});
|
||||
expect(getResourceVmid(resource)).toBe('');
|
||||
});
|
||||
|
||||
it('reads vmid from platformData.proxmox when no meta proxmox block exists', () => {
|
||||
const resource = makeResource({
|
||||
id: 'vm-4',
|
||||
type: 'vm',
|
||||
platformData: { proxmox: { vmid: 303 } },
|
||||
});
|
||||
expect(getResourceVmid(resource)).toBe('303');
|
||||
});
|
||||
|
||||
it('returns empty string when neither source carries a numeric vmid', () => {
|
||||
const resource = makeResource({ id: 'vm-5', type: 'vm' });
|
||||
expect(getResourceVmid(resource)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when platformData.proxmox is not a record', () => {
|
||||
const resource = makeResource({
|
||||
id: 'vm-6',
|
||||
type: 'vm',
|
||||
platformData: { proxmox: 'not-a-record' },
|
||||
});
|
||||
expect(getResourceVmid(resource)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getResourceVersion
|
||||
// ===========================================================================
|
||||
|
||||
describe('getResourceVersion branches', () => {
|
||||
it('skips a non-formatable platformData.proxmox.pveVersion and falls through to pbs.version', () => {
|
||||
// formatProxmoxVersion('unknown') -> '' -> inner `if (version)` is false.
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'agent',
|
||||
platformData: { proxmox: { pveVersion: 'unknown' } },
|
||||
pbs: { version: '3.0' },
|
||||
});
|
||||
expect(getResourceVersion(resource)).toBe('3.0');
|
||||
});
|
||||
|
||||
it('returns resource.pbs.version when no pve signal is present', () => {
|
||||
const resource = makeResource({
|
||||
id: 'pbs-1',
|
||||
type: 'pbs',
|
||||
platformType: 'proxmox-pbs',
|
||||
pbs: { version: '3.2' },
|
||||
});
|
||||
expect(getResourceVersion(resource)).toBe('3.2');
|
||||
});
|
||||
|
||||
it('returns platformData.pbs.version when meta pbs.version is absent', () => {
|
||||
const resource = makeResource({
|
||||
id: 'pbs-2',
|
||||
type: 'pbs',
|
||||
platformType: 'proxmox-pbs',
|
||||
platformData: { pbs: { version: '3.3' } },
|
||||
});
|
||||
expect(getResourceVersion(resource)).toBe('3.3');
|
||||
});
|
||||
|
||||
it('returns platformData.pmg.version for a pmg resource', () => {
|
||||
const resource = makeResource({
|
||||
id: 'pmg-1',
|
||||
type: 'pmg',
|
||||
platformType: 'proxmox-pmg',
|
||||
platformData: { pmg: { version: '8.1' } },
|
||||
});
|
||||
expect(getResourceVersion(resource)).toBe('8.1');
|
||||
});
|
||||
|
||||
it('formats agent.osVersion when agent.osName mentions proxmox', () => {
|
||||
const resource = makeResource({
|
||||
id: 'agent-1',
|
||||
type: 'agent',
|
||||
agent: { osName: 'Proxmox VE', osVersion: 'pve-manager/9.0/abc' },
|
||||
});
|
||||
expect(getResourceVersion(resource)).toBe('9.0');
|
||||
});
|
||||
|
||||
it('falls back to the raw osVersion when it is not formatable', () => {
|
||||
// formatProxmoxVersion('unknown') === '' -> returns raw osVersion.
|
||||
const resource = makeResource({
|
||||
id: 'agent-2',
|
||||
type: 'agent',
|
||||
agent: { osName: 'proxmox', osVersion: 'unknown' },
|
||||
});
|
||||
expect(getResourceVersion(resource)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('does not take the agent branch when osName omits proxmox', () => {
|
||||
const resource = makeResource({
|
||||
id: 'agent-3',
|
||||
type: 'agent',
|
||||
agent: { osName: 'Debian', osVersion: '12' },
|
||||
});
|
||||
expect(getResourceVersion(resource)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when no version signal is present at all', () => {
|
||||
const resource = makeResource({ id: 'agent-4', type: 'agent' });
|
||||
expect(getResourceVersion(resource)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getResourceClusterLabel
|
||||
// ===========================================================================
|
||||
|
||||
describe('getResourceClusterLabel branches', () => {
|
||||
it('prefers proxmox.clusterName', () => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'agent',
|
||||
proxmox: { clusterName: 'alpha' },
|
||||
identity: { clusterName: 'shadow' },
|
||||
clusterId: 'cid',
|
||||
});
|
||||
expect(getResourceClusterLabel(resource)).toBe('alpha');
|
||||
});
|
||||
|
||||
it('falls back to identity.clusterName when proxmox.clusterName is absent', () => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'agent',
|
||||
identity: { clusterName: 'beta' },
|
||||
clusterId: 'cid',
|
||||
});
|
||||
expect(getResourceClusterLabel(resource)).toBe('beta');
|
||||
});
|
||||
|
||||
it('falls back to clusterId when no clusterName is present', () => {
|
||||
const resource = makeResource({ id: 'r', type: 'agent', clusterId: 'clus-1' });
|
||||
expect(getResourceClusterLabel(resource)).toBe('clus-1');
|
||||
});
|
||||
|
||||
it('returns "Standalone" when nothing is set', () => {
|
||||
const resource = makeResource({ id: 'r', type: 'agent' });
|
||||
expect(getResourceClusterLabel(resource)).toBe('Standalone');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getResourceNodeName
|
||||
// ===========================================================================
|
||||
|
||||
describe('getResourceNodeName branches', () => {
|
||||
it('prefers proxmox.nodeName', () => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'agent',
|
||||
proxmox: { nodeName: 'n1', node: 'shadow' },
|
||||
parentName: 'p',
|
||||
identity: { hostname: 'h' },
|
||||
});
|
||||
expect(getResourceNodeName(resource)).toBe('n1');
|
||||
});
|
||||
|
||||
it('falls back to proxmox.node when nodeName is absent', () => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'agent',
|
||||
proxmox: { node: 'n2' },
|
||||
parentName: 'p',
|
||||
});
|
||||
expect(getResourceNodeName(resource)).toBe('n2');
|
||||
});
|
||||
|
||||
it('falls back to parentName when no proxmox node field is set', () => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'vm',
|
||||
parentName: 'p1',
|
||||
identity: { hostname: 'h' },
|
||||
});
|
||||
expect(getResourceNodeName(resource)).toBe('p1');
|
||||
});
|
||||
|
||||
it('falls back to identity.hostname when no node/parent is set', () => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'vm',
|
||||
identity: { hostname: 'h1' },
|
||||
});
|
||||
expect(getResourceNodeName(resource)).toBe('h1');
|
||||
});
|
||||
|
||||
it('falls back to resource.name when nothing else is set', () => {
|
||||
const resource = makeResource({ id: 'fallback-id', type: 'vm' });
|
||||
// makeResource defaults name === id.
|
||||
expect(getResourceNodeName(resource)).toBe('fallback-id');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getResourceLastBackup
|
||||
// ===========================================================================
|
||||
|
||||
describe('getResourceLastBackup branches', () => {
|
||||
it('returns null when platformData is absent', () => {
|
||||
const resource = makeResource({ id: 'r', type: 'vm' });
|
||||
expect(getResourceLastBackup(resource)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when platformData.proxmox is not a record', () => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'vm',
|
||||
platformData: { proxmox: 'not-a-record' },
|
||||
});
|
||||
expect(getResourceLastBackup(resource)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the lastBackup string verbatim', () => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'vm',
|
||||
platformData: { proxmox: { lastBackup: '2024-01-01T00:00:00Z' } },
|
||||
});
|
||||
expect(getResourceLastBackup(resource)).toBe('2024-01-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('returns the lastBackup number verbatim', () => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'vm',
|
||||
platformData: { proxmox: { lastBackup: 1_700_000_000_000 } },
|
||||
});
|
||||
expect(getResourceLastBackup(resource)).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it('returns null when lastBackup is a boolean', () => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'vm',
|
||||
platformData: { proxmox: { lastBackup: true } },
|
||||
});
|
||||
expect(getResourceLastBackup(resource)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when lastBackup is an object', () => {
|
||||
const resource = makeResource({
|
||||
id: 'r',
|
||||
type: 'vm',
|
||||
platformData: { proxmox: { lastBackup: { ts: 1 } } },
|
||||
});
|
||||
expect(getResourceLastBackup(resource)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// buildProxmoxPageModel
|
||||
// ===========================================================================
|
||||
|
||||
describe('buildProxmoxPageModel empty input', () => {
|
||||
it('returns a fully empty model', () => {
|
||||
expect(buildProxmoxPageModel([])).toEqual({
|
||||
resources: [],
|
||||
pveNodes: [],
|
||||
guests: [],
|
||||
storage: [],
|
||||
pbs: [],
|
||||
pmg: [],
|
||||
ceph: [],
|
||||
physicalDisks: [],
|
||||
clusterGroups: [],
|
||||
summary: {
|
||||
clusterCount: 0,
|
||||
nodeCount: 0,
|
||||
guestCount: 0,
|
||||
runningGuestCount: 0,
|
||||
degradedGuestCount: 0,
|
||||
stoppedGuestCount: 0,
|
||||
storageCount: 0,
|
||||
pbsCount: 0,
|
||||
pmgCount: 0,
|
||||
cephCount: 0,
|
||||
alertCount: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProxmoxPageModel estate classification, status counts, and alert sum', () => {
|
||||
const nodeA = makeResource({
|
||||
id: 'node-a',
|
||||
type: 'agent',
|
||||
proxmox: { nodeName: 'node-a', clusterName: 'cluster-x' },
|
||||
alerts: [alert()],
|
||||
incidentCount: 2,
|
||||
});
|
||||
|
||||
const guests = [
|
||||
makeResource({ id: 'vm-r', type: 'vm', status: 'running', proxmox: { vmid: 1, nodeName: 'node-a' } }),
|
||||
makeResource({ id: 'vm-o', type: 'vm', status: 'online', proxmox: { vmid: 2, nodeName: 'node-a' } }),
|
||||
makeResource({
|
||||
id: 'vm-d',
|
||||
type: 'vm',
|
||||
status: 'degraded',
|
||||
proxmox: { vmid: 3, nodeName: 'node-a' },
|
||||
}),
|
||||
makeResource({
|
||||
id: 'ct-w',
|
||||
type: 'system-container',
|
||||
status: 'warning',
|
||||
proxmox: { vmid: 4, nodeName: 'node-a' },
|
||||
}),
|
||||
makeResource({
|
||||
id: 'vm-off',
|
||||
type: 'vm',
|
||||
status: 'offline',
|
||||
proxmox: { vmid: 5, nodeName: 'node-a' },
|
||||
}),
|
||||
makeResource({
|
||||
id: 'vm-st',
|
||||
type: 'vm',
|
||||
status: 'stopped',
|
||||
proxmox: { vmid: 6, nodeName: 'node-a' },
|
||||
}),
|
||||
];
|
||||
|
||||
it('classifies resources, excludes non-proxmox, and aggregates summary + alerts', () => {
|
||||
const model = buildProxmoxPageModel([
|
||||
makeResource({ id: 'dh', type: 'docker-host', platformType: 'docker' }),
|
||||
nodeA,
|
||||
...guests,
|
||||
]);
|
||||
|
||||
expect(model.resources.map((r) => r.id)).not.toContain('dh');
|
||||
expect(model.pveNodes.map((r) => r.id)).toEqual(['node-a']);
|
||||
expect(model.guests.map((r) => r.id)).toEqual([
|
||||
'vm-r',
|
||||
'vm-o',
|
||||
'vm-d',
|
||||
'ct-w',
|
||||
'vm-off',
|
||||
'vm-st',
|
||||
]);
|
||||
expect(model.summary).toMatchObject({
|
||||
guestCount: 6,
|
||||
runningGuestCount: 2,
|
||||
degradedGuestCount: 2,
|
||||
stoppedGuestCount: 2,
|
||||
nodeCount: 1,
|
||||
clusterCount: 1,
|
||||
alertCount: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProxmoxPageModel pbs / pmg / ceph / physicalDisks sections', () => {
|
||||
it('routes resources into the pbs, pmg, ceph, and physicalDisks arrays', () => {
|
||||
const model = buildProxmoxPageModel([
|
||||
makeResource({ id: 'pbs-1', type: 'pbs', platformType: 'proxmox-pbs' }),
|
||||
makeResource({ id: 'pmg-1', type: 'pmg', platformType: 'proxmox-pmg' }),
|
||||
makeResource({
|
||||
id: 'ceph-typed',
|
||||
type: 'ceph',
|
||||
platformData: { ceph: { healthStatus: 'healthy' } },
|
||||
}),
|
||||
makeResource({
|
||||
id: 'stor-ceph',
|
||||
type: 'storage',
|
||||
storage: { isCeph: true },
|
||||
}),
|
||||
makeResource({
|
||||
id: 'disk-1',
|
||||
type: 'physical_disk',
|
||||
parentName: 'node-a',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(model.pbs.map((r) => r.id)).toEqual(['pbs-1']);
|
||||
expect(model.pmg.map((r) => r.id)).toEqual(['pmg-1']);
|
||||
// ceph-typed (type ceph) and stor-ceph (storage.isCeph) both count as ceph.
|
||||
expect(model.ceph.map((r) => r.id)).toEqual(['ceph-typed', 'stor-ceph']);
|
||||
expect(model.physicalDisks.map((r) => r.id)).toEqual(['disk-1']);
|
||||
// A Ceph-backed storage is intentionally NOT a Proxmox storage resource.
|
||||
expect(model.storage.map((r) => r.id)).not.toContain('stor-ceph');
|
||||
// physical_disk is a Proxmox storage type, so it appears in storage too.
|
||||
expect(model.storage.map((r) => r.id)).toEqual(['disk-1']);
|
||||
expect(model.summary).toMatchObject({
|
||||
pbsCount: 1,
|
||||
pmgCount: 1,
|
||||
cephCount: 2,
|
||||
storageCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProxmoxPageModel cluster grouping', () => {
|
||||
it('assigns guests and storage to a named cluster via the owning node', () => {
|
||||
const nodeA = makeResource({
|
||||
id: 'node-a',
|
||||
type: 'agent',
|
||||
proxmox: { nodeName: 'node-a', clusterName: 'cluster-x' },
|
||||
});
|
||||
const guestOnNode = makeResource({
|
||||
id: 'vm-1',
|
||||
type: 'vm',
|
||||
status: 'running',
|
||||
proxmox: { vmid: 1, nodeName: 'node-a' },
|
||||
});
|
||||
const storageOnNode = makeResource({
|
||||
id: 'stor-1',
|
||||
type: 'storage',
|
||||
parentName: 'node-a',
|
||||
storage: { isCeph: false },
|
||||
});
|
||||
|
||||
const model = buildProxmoxPageModel([nodeA, guestOnNode, storageOnNode]);
|
||||
|
||||
expect(model.clusterGroups.map((g) => g.id)).toEqual(['cluster-x']);
|
||||
const group = model.clusterGroups[0];
|
||||
expect(group.nodes.map((r) => r.id)).toEqual(['node-a']);
|
||||
expect(group.guests.map((r) => r.id)).toEqual(['vm-1']);
|
||||
expect(group.storage.map((r) => r.id)).toEqual(['stor-1']);
|
||||
});
|
||||
|
||||
it('drops orphan guests/storage into the standalone bucket using their own cluster label', () => {
|
||||
const nodeA = makeResource({
|
||||
id: 'node-a',
|
||||
type: 'agent',
|
||||
proxmox: { nodeName: 'node-a', clusterName: 'cluster-x' },
|
||||
});
|
||||
const orphanGuest = makeResource({
|
||||
id: 'vm-orphan',
|
||||
type: 'vm',
|
||||
status: 'running',
|
||||
proxmox: { vmid: 9 },
|
||||
parentName: 'ghost-node',
|
||||
});
|
||||
const orphanStorage = makeResource({
|
||||
id: 'stor-orphan',
|
||||
type: 'storage',
|
||||
parentName: 'ghost-node',
|
||||
storage: {},
|
||||
});
|
||||
|
||||
const model = buildProxmoxPageModel([nodeA, orphanGuest, orphanStorage]);
|
||||
|
||||
expect(model.clusterGroups.map((g) => g.id)).toEqual(['cluster-x', '__standalone__']);
|
||||
const standalone = model.clusterGroups[1];
|
||||
expect(standalone.id).toBe('__standalone__');
|
||||
expect(standalone.label).toBe('Standalone');
|
||||
expect(standalone.nodes).toEqual([]);
|
||||
expect(standalone.guests.map((r) => r.id)).toEqual(['vm-orphan']);
|
||||
expect(standalone.storage.map((r) => r.id)).toEqual(['stor-orphan']);
|
||||
// Standalone never counts as a real cluster.
|
||||
expect(model.summary.clusterCount).toBe(1);
|
||||
});
|
||||
|
||||
it('sorts named clusters by label and keeps standalone last', () => {
|
||||
const nodeBravo = makeResource({
|
||||
id: 'node-bravo',
|
||||
type: 'agent',
|
||||
proxmox: { nodeName: 'node-bravo', clusterName: 'bravo' },
|
||||
});
|
||||
const nodeAlpha = makeResource({
|
||||
id: 'node-alpha',
|
||||
type: 'agent',
|
||||
proxmox: { nodeName: 'node-alpha', clusterName: 'alpha' },
|
||||
});
|
||||
const orphanGuest = makeResource({
|
||||
id: 'vm-solo',
|
||||
type: 'vm',
|
||||
status: 'running',
|
||||
proxmox: { vmid: 1 },
|
||||
parentName: 'solo-node',
|
||||
});
|
||||
|
||||
const model = buildProxmoxPageModel([nodeBravo, nodeAlpha, orphanGuest]);
|
||||
|
||||
expect(model.clusterGroups.map((g) => g.label)).toEqual(['alpha', 'bravo', 'Standalone']);
|
||||
expect(model.clusterGroups.map((g) => g.id)).toEqual(['alpha', 'bravo', '__standalone__']);
|
||||
});
|
||||
});
|
||||
+474
@@ -0,0 +1,474 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import {
|
||||
getStorageCapabilitiesForResource,
|
||||
getStorageCategoryFromType,
|
||||
isCanonicalDatastoreStorageResource,
|
||||
readResourceStorageMeta,
|
||||
type ResourceStorageMeta,
|
||||
type StorageClassificationContext,
|
||||
} from '@/features/storageBackups/resourceStorageMapping';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures — mirror the conventions in resourceStorageMapping.test.ts:
|
||||
// a `makeResource` factory cast as `Resource`, plus a `platformData` record.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const makeResource = (overrides: Partial<Resource> = {}): Resource =>
|
||||
({
|
||||
id: 'storage-1',
|
||||
type: 'storage',
|
||||
name: 'tank',
|
||||
platformType: 'truenas',
|
||||
sourceType: 'api',
|
||||
...overrides,
|
||||
}) as Resource;
|
||||
|
||||
// Sentinel used in platformData.storage so that, when resource.storage is
|
||||
// rejected by normalizeStorageMeta (returns null), readResourceStorageMeta
|
||||
// falls through and we can observe the fallback shape — proving the null arm.
|
||||
const FALLBACK_PLATFORM_STORAGE = { type: 'fallback-marker' } as const;
|
||||
|
||||
// Drive the (private) normalizeStorageMeta through the public
|
||||
// readResourceStorageMeta. resource.storage is the unit under test; the
|
||||
// sentinel in platformData.storage lets us detect when normalizeStorageMeta
|
||||
// returned null (fallthrough) vs. returned the normalized direct meta.
|
||||
const normalizeOf = (input: unknown): ResourceStorageMeta | undefined =>
|
||||
readResourceStorageMeta(
|
||||
{ ...makeResource(), storage: input } as unknown as Resource,
|
||||
{ storage: FALLBACK_PLATFORM_STORAGE },
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// normalizeStorageMeta (module-private — exercised via readResourceStorageMeta)
|
||||
// ===========================================================================
|
||||
|
||||
describe('normalizeStorageMeta (via readResourceStorageMeta)', () => {
|
||||
// ---- Branch: `!value || typeof value !== 'object'` → return null -------
|
||||
it('returns null for null/undefined/non-object inputs (observed via platformData fallthrough)', () => {
|
||||
// null → null
|
||||
expect(normalizeOf(null)).toEqual({ type: 'fallback-marker' });
|
||||
// undefined → null
|
||||
expect(normalizeOf(undefined)).toEqual({ type: 'fallback-marker' });
|
||||
// primitive number → null
|
||||
expect(normalizeOf(42)).toEqual({ type: 'fallback-marker' });
|
||||
// primitive string → null
|
||||
expect(normalizeOf('not-an-object')).toEqual({ type: 'fallback-marker' });
|
||||
// boolean → null
|
||||
expect(normalizeOf(true)).toEqual({ type: 'fallback-marker' });
|
||||
});
|
||||
|
||||
it('returns undefined when both direct and platform storage normalize to null', () => {
|
||||
// Both inputs non-object → both normalize to null → readResourceStorageMeta
|
||||
// resolves to `null || undefined` → undefined (the `nestedMeta || undefined`
|
||||
// arm of readResourceStorageMeta, exercised through normalizeStorageMeta).
|
||||
expect(
|
||||
readResourceStorageMeta(
|
||||
{ ...makeResource(), storage: null } as unknown as Resource,
|
||||
{ storage: 'not-an-object-either' },
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
// ---- Branch: each `typeof candidate.X === 'string' ? X : undefined` ----
|
||||
it('preserves every string-valued field and returns the full normalized shape', () => {
|
||||
const normalized = normalizeOf({
|
||||
type: 'rbd',
|
||||
platform: 'proxmox-pve',
|
||||
topology: 'pool',
|
||||
content: 'images',
|
||||
protection: 'protected',
|
||||
arrayState: 'online',
|
||||
syncAction: 'syncing',
|
||||
path: '/dev/sda',
|
||||
});
|
||||
|
||||
expect(normalized).toEqual({
|
||||
type: 'rbd',
|
||||
platform: 'proxmox-pve',
|
||||
topology: 'pool',
|
||||
content: 'images',
|
||||
protection: 'protected',
|
||||
arrayState: 'online',
|
||||
syncAction: 'syncing',
|
||||
path: '/dev/sda',
|
||||
contentTypes: undefined,
|
||||
shared: undefined,
|
||||
syncProgress: undefined,
|
||||
numProtected: undefined,
|
||||
numDisabled: undefined,
|
||||
numInvalid: undefined,
|
||||
numMissing: undefined,
|
||||
isCeph: undefined,
|
||||
isZfs: undefined,
|
||||
zfsPool: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('coerces wrong-typed string fields to undefined (defensive ternary false arm)', () => {
|
||||
const normalized = normalizeOf({
|
||||
type: 123,
|
||||
platform: 456,
|
||||
topology: true,
|
||||
content: { x: 1 },
|
||||
protection: ['no'],
|
||||
arrayState: 7,
|
||||
syncAction: 8,
|
||||
path: 9,
|
||||
});
|
||||
|
||||
// Each defensive `typeof X === 'string'` is false → undefined.
|
||||
expect(normalized?.type).toBeUndefined();
|
||||
expect(normalized?.platform).toBeUndefined();
|
||||
expect(normalized?.topology).toBeUndefined();
|
||||
expect(normalized?.content).toBeUndefined();
|
||||
expect(normalized?.protection).toBeUndefined();
|
||||
expect(normalized?.arrayState).toBeUndefined();
|
||||
expect(normalized?.syncAction).toBeUndefined();
|
||||
expect(normalized?.path).toBeUndefined();
|
||||
});
|
||||
|
||||
// ---- Branch: each `typeof candidate.X === 'boolean' ? X : undefined` ---
|
||||
it('preserves boolean fields and coerces non-booleans to undefined', () => {
|
||||
expect(normalizeOf({ shared: true, isCeph: false, isZfs: true })?.shared).toBe(true);
|
||||
expect(normalizeOf({ shared: true, isCeph: false, isZfs: true })?.isCeph).toBe(false);
|
||||
expect(normalizeOf({ shared: true, isCeph: false, isZfs: true })?.isZfs).toBe(true);
|
||||
|
||||
const wrongBool = normalizeOf({ shared: 'yes', isCeph: 1, isZfs: null });
|
||||
expect(wrongBool?.shared).toBeUndefined();
|
||||
expect(wrongBool?.isCeph).toBeUndefined();
|
||||
expect(wrongBool?.isZfs).toBeUndefined();
|
||||
});
|
||||
|
||||
// ---- Branch: each `typeof candidate.X === 'number' ? X : undefined` ----
|
||||
it('preserves numeric fields and coerces non-numbers to undefined', () => {
|
||||
const ok = normalizeOf({
|
||||
syncProgress: 50,
|
||||
numProtected: 3,
|
||||
numDisabled: 1,
|
||||
numInvalid: 2,
|
||||
numMissing: 0,
|
||||
});
|
||||
expect(ok?.syncProgress).toBe(50);
|
||||
expect(ok?.numProtected).toBe(3);
|
||||
expect(ok?.numDisabled).toBe(1);
|
||||
expect(ok?.numInvalid).toBe(2);
|
||||
expect(ok?.numMissing).toBe(0);
|
||||
|
||||
const wrong = normalizeOf({
|
||||
syncProgress: '50',
|
||||
numProtected: true,
|
||||
numDisabled: '1',
|
||||
numInvalid: null,
|
||||
numMissing: undefined,
|
||||
});
|
||||
expect(wrong?.syncProgress).toBeUndefined();
|
||||
expect(wrong?.numProtected).toBeUndefined();
|
||||
expect(wrong?.numDisabled).toBeUndefined();
|
||||
expect(wrong?.numInvalid).toBeUndefined();
|
||||
expect(wrong?.numMissing).toBeUndefined();
|
||||
});
|
||||
|
||||
// ---- Branch: `Array.isArray(candidate.contentTypes) ? filter : undefined`
|
||||
it('returns contentTypes undefined when the value is not an array', () => {
|
||||
expect(normalizeOf({ contentTypes: 'not-array' })?.contentTypes).toBeUndefined();
|
||||
expect(normalizeOf({ contentTypes: { 0: 'a' } })?.contentTypes).toBeUndefined();
|
||||
expect(normalizeOf({ contentTypes: 42 })?.contentTypes).toBeUndefined();
|
||||
expect(normalizeOf({})?.contentTypes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps only non-empty trimmed strings from contentTypes and drops everything else', () => {
|
||||
// The filter keeps `typeof item === 'string' && item.trim().length > 0`.
|
||||
// Mixed: valid string, empty string, whitespace-only, numbers, null,
|
||||
// booleans, object — only the genuinely-populated strings survive.
|
||||
expect(
|
||||
normalizeOf({ contentTypes: ['images', '', ' ', 42, null, false, {}, 'rootdir'] })
|
||||
?.contentTypes,
|
||||
).toEqual(['images', 'rootdir']);
|
||||
|
||||
// An array of only rejectable items collapses to an empty array
|
||||
// (NOT undefined — the array branch was taken).
|
||||
expect(normalizeOf({ contentTypes: ['', ' ', 1, null] })?.contentTypes).toEqual([]);
|
||||
});
|
||||
|
||||
// ---- Branch: `candidate.zfsPool && typeof ... === 'object' ? cast : undef`
|
||||
it('returns zfsPool as-is when it is an object, undefined otherwise', () => {
|
||||
const pool = { name: 'tank', state: 'ONLINE' };
|
||||
expect(normalizeOf({ zfsPool: pool })?.zfsPool).toEqual(pool);
|
||||
|
||||
// null → falsy short-circuit → undefined
|
||||
expect(normalizeOf({ zfsPool: null })?.zfsPool).toBeUndefined();
|
||||
// primitive → typeof !== 'object' → undefined
|
||||
expect(normalizeOf({ zfsPool: 'tank' })?.zfsPool).toBeUndefined();
|
||||
expect(normalizeOf({ zfsPool: 42 })?.zfsPool).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// isCanonicalDatastoreStorageResource
|
||||
// ===========================================================================
|
||||
|
||||
describe('isCanonicalDatastoreStorageResource', () => {
|
||||
// ---- Branch: isBackupRepositoryStorageResource(...) === true → false ----
|
||||
it('returns false when the resource is classified as a backup repository', () => {
|
||||
expect(isCanonicalDatastoreStorageResource('pbs')).toBe(false);
|
||||
// via context.resourceType
|
||||
expect(
|
||||
isCanonicalDatastoreStorageResource('whatever', undefined, { resourceType: 'pbs' }),
|
||||
).toBe(false);
|
||||
// via context.platform (substring match)
|
||||
expect(
|
||||
isCanonicalDatastoreStorageResource('whatever', undefined, { platform: 'proxmox-pbs' }),
|
||||
).toBe(false);
|
||||
// via context.topology exact match
|
||||
expect(
|
||||
isCanonicalDatastoreStorageResource('whatever', undefined, { topology: 'backup-target' }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// ---- Branch: resourceType === 'datastore' → true ----------------------
|
||||
it('returns true when context.resourceType is datastore', () => {
|
||||
expect(
|
||||
isCanonicalDatastoreStorageResource('mystery', undefined, { resourceType: 'datastore' }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// ---- Branch: topology === 'datastore' (context OR storageMeta) --------
|
||||
it('returns true when topology is datastore from context or storageMeta', () => {
|
||||
expect(
|
||||
isCanonicalDatastoreStorageResource('mystery', undefined, { topology: 'datastore' }),
|
||||
).toBe(true);
|
||||
expect(isCanonicalDatastoreStorageResource('mystery', { topology: 'datastore' })).toBe(true);
|
||||
});
|
||||
|
||||
// ---- Branch: entityType === 'datastore' → true -----------------------
|
||||
it('returns true when context.entityType is datastore', () => {
|
||||
expect(
|
||||
isCanonicalDatastoreStorageResource('mystery', undefined, { entityType: 'datastore' }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// ---- Branch: (platform.includes('vmware') && value.length > 0) -------
|
||||
it('returns true for vmware platform with a non-empty type, false when type is empty', () => {
|
||||
// context.platform path
|
||||
expect(
|
||||
isCanonicalDatastoreStorageResource('vmfs', undefined, { platform: 'vmware-vsphere' }),
|
||||
).toBe(true);
|
||||
// storageMeta.platform path
|
||||
expect(isCanonicalDatastoreStorageResource('vmfs', { platform: 'vmware-vsphere' })).toBe(true);
|
||||
// value.length === 0 → false even with vmware platform
|
||||
expect(
|
||||
isCanonicalDatastoreStorageResource('', undefined, { platform: 'vmware-vsphere' }),
|
||||
).toBe(false);
|
||||
expect(isCanonicalDatastoreStorageResource('', { platform: 'vmware-vsphere' })).toBe(false);
|
||||
});
|
||||
|
||||
// ---- Branch: `context?.platform || storageMeta?.platform` fallback ----
|
||||
it('uses context.platform when set, falling back to storageMeta.platform only when context is absent', () => {
|
||||
// context.platform truthy + non-vmware wins; storageMeta.platform ignored.
|
||||
expect(
|
||||
isCanonicalDatastoreStorageResource(
|
||||
'vmfs',
|
||||
{ platform: 'vmware-vsphere' },
|
||||
{ platform: 'proxmox-pve' },
|
||||
),
|
||||
).toBe(false);
|
||||
// context.platform undefined → fallback to storageMeta.platform.
|
||||
expect(
|
||||
isCanonicalDatastoreStorageResource('vmfs', { platform: 'vmware-vsphere' }, {}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// ---- Branch: fall-through → false ------------------------------------
|
||||
it('returns false when nothing matches', () => {
|
||||
expect(isCanonicalDatastoreStorageResource('mystery')).toBe(false);
|
||||
expect(isCanonicalDatastoreStorageResource(undefined)).toBe(false);
|
||||
expect(isCanonicalDatastoreStorageResource('mystery', undefined, {})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getStorageCategoryFromType
|
||||
// ===========================================================================
|
||||
|
||||
describe('getStorageCategoryFromType', () => {
|
||||
// ---- Branch: `!value` → 'other' --------------------------------------
|
||||
it('returns "other" for empty or undefined type', () => {
|
||||
expect(getStorageCategoryFromType(undefined)).toBe('other');
|
||||
expect(getStorageCategoryFromType('')).toBe('other');
|
||||
});
|
||||
|
||||
// ---- Branch: isBackupRepositoryStorageResource(type, undefined, ctx) --
|
||||
it('returns "backup-repository" via any backup signal in context', () => {
|
||||
// value.includes('pbs')
|
||||
expect(getStorageCategoryFromType('pbs')).toBe('backup-repository');
|
||||
// context.resourceType === 'pbs'
|
||||
expect(getStorageCategoryFromType('mystery', { resourceType: 'pbs' })).toBe('backup-repository');
|
||||
// context.platform includes 'pbs'
|
||||
expect(getStorageCategoryFromType('mystery', { platform: 'proxmox-pbs' })).toBe(
|
||||
'backup-repository',
|
||||
);
|
||||
// context.topology === 'backup-target'
|
||||
expect(getStorageCategoryFromType('mystery', { topology: 'backup-target' })).toBe(
|
||||
'backup-repository',
|
||||
);
|
||||
});
|
||||
|
||||
// ---- Branch: isCanonicalDatastoreStorageResource(type, undefined, ctx)
|
||||
it('returns "datastore" via each canonical signal in context', () => {
|
||||
expect(getStorageCategoryFromType('mystery', { resourceType: 'datastore' })).toBe('datastore');
|
||||
expect(getStorageCategoryFromType('mystery', { topology: 'datastore' })).toBe('datastore');
|
||||
expect(getStorageCategoryFromType('mystery', { entityType: 'datastore' })).toBe('datastore');
|
||||
// platform.includes('vmware') && value.length > 0
|
||||
expect(getStorageCategoryFromType('vmfs', { platform: 'vmware-vsphere' })).toBe('datastore');
|
||||
});
|
||||
|
||||
// ---- Branch: pool substring detection (zfs | lvm | ceph | pool) ------
|
||||
it('returns "pool" for each pool-indicating substring', () => {
|
||||
expect(getStorageCategoryFromType('myzfs')).toBe('pool');
|
||||
expect(getStorageCategoryFromType('mylvm')).toBe('pool');
|
||||
expect(getStorageCategoryFromType('myceph')).toBe('pool');
|
||||
expect(getStorageCategoryFromType('mypool')).toBe('pool');
|
||||
});
|
||||
|
||||
// ---- Branch: dataset / share / filesystem substrings -----------------
|
||||
it('returns the right category for dataset, share, and filesystem substrings', () => {
|
||||
expect(getStorageCategoryFromType('mydataset')).toBe('dataset');
|
||||
expect(getStorageCategoryFromType('mynfs')).toBe('share');
|
||||
expect(getStorageCategoryFromType('mycifs')).toBe('share');
|
||||
expect(getStorageCategoryFromType('mysmb')).toBe('share');
|
||||
expect(getStorageCategoryFromType('mydir')).toBe('filesystem');
|
||||
expect(getStorageCategoryFromType('myfilesystem')).toBe('filesystem');
|
||||
});
|
||||
|
||||
// ---- Branch: ordering — pool beats dataset when both substrings match
|
||||
it('prefers "pool" over "dataset" when the type contains both substrings', () => {
|
||||
// 'pooldataset' triggers the pool branch first.
|
||||
expect(getStorageCategoryFromType('pooldataset')).toBe('pool');
|
||||
});
|
||||
|
||||
// ---- Branch: fallback → 'other' --------------------------------------
|
||||
it('returns "other" for an unrecognized type with no matching context', () => {
|
||||
expect(getStorageCategoryFromType('mystery')).toBe('other');
|
||||
expect(getStorageCategoryFromType('mystery', {} as StorageClassificationContext)).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// getStorageCapabilitiesForResource
|
||||
// ===========================================================================
|
||||
|
||||
describe('getStorageCapabilitiesForResource', () => {
|
||||
// ---- Branch: base caps always present --------------------------------
|
||||
it('returns only the base caps when no signal matches', () => {
|
||||
expect(getStorageCapabilitiesForResource('mystery')).toEqual(['capacity', 'health']);
|
||||
expect(getStorageCapabilitiesForResource(undefined)).toEqual(['capacity', 'health']);
|
||||
});
|
||||
|
||||
// ---- Branch: isBackupRepositoryStorageResource(...) === true ----------
|
||||
it('adds backup-repository caps for a PBS-classified resource', () => {
|
||||
expect(getStorageCapabilitiesForResource('pbs')).toEqual([
|
||||
'capacity',
|
||||
'health',
|
||||
'backup-repository',
|
||||
'deduplication',
|
||||
'namespaces',
|
||||
]);
|
||||
// Same caps via context signal rather than the type substring.
|
||||
expect(getStorageCapabilitiesForResource('mystery', undefined, { resourceType: 'pbs' })).toEqual([
|
||||
'capacity',
|
||||
'health',
|
||||
'backup-repository',
|
||||
'deduplication',
|
||||
'namespaces',
|
||||
]);
|
||||
});
|
||||
|
||||
// ---- Branch: `storageMeta?.isZfs || value.includes('zfs')` ------------
|
||||
it('adds snapshots + compression when isZfs flag is set OR the type contains zfs', () => {
|
||||
// value.includes('zfs') arm
|
||||
expect(getStorageCapabilitiesForResource('zfsmirror')).toEqual([
|
||||
'capacity',
|
||||
'health',
|
||||
'snapshots',
|
||||
'compression',
|
||||
]);
|
||||
// storageMeta.isZfs arm
|
||||
expect(getStorageCapabilitiesForResource('mystery', { isZfs: true })).toEqual([
|
||||
'capacity',
|
||||
'health',
|
||||
'snapshots',
|
||||
'compression',
|
||||
]);
|
||||
});
|
||||
|
||||
// ---- Branch: `storageMeta?.isCeph || value.includes('ceph')` ----------
|
||||
it('adds replication + multi-node when isCeph flag is set OR the type contains ceph', () => {
|
||||
// value.includes('ceph') arm
|
||||
expect(getStorageCapabilitiesForResource('cephrbd')).toEqual([
|
||||
'capacity',
|
||||
'health',
|
||||
'replication',
|
||||
'multi-node',
|
||||
]);
|
||||
// storageMeta.isCeph arm
|
||||
expect(getStorageCapabilitiesForResource('mystery', { isCeph: true })).toEqual([
|
||||
'capacity',
|
||||
'health',
|
||||
'replication',
|
||||
'multi-node',
|
||||
]);
|
||||
});
|
||||
|
||||
// ---- Branch: `(context?.shared ?? storageMeta?.shared) === true` ------
|
||||
it('adds multi-node only when shared resolves to true via context or storageMeta', () => {
|
||||
// context.shared true
|
||||
expect(getStorageCapabilitiesForResource('mystery', undefined, { shared: true })).toEqual([
|
||||
'capacity',
|
||||
'health',
|
||||
'multi-node',
|
||||
]);
|
||||
// context.shared undefined → ?? fallback to storageMeta.shared true
|
||||
expect(getStorageCapabilitiesForResource('mystery', { shared: true }, {})).toEqual([
|
||||
'capacity',
|
||||
'health',
|
||||
'multi-node',
|
||||
]);
|
||||
// storageMeta only
|
||||
expect(getStorageCapabilitiesForResource('mystery', { shared: true })).toEqual([
|
||||
'capacity',
|
||||
'health',
|
||||
'multi-node',
|
||||
]);
|
||||
// context.shared === false is NOT nullish, so ?? does NOT fall back to
|
||||
// storageMeta.shared — multi-node must NOT be added.
|
||||
expect(
|
||||
getStorageCapabilitiesForResource('mystery', { shared: true }, { shared: false }),
|
||||
).toEqual(['capacity', 'health']);
|
||||
// shared === false explicitly → no multi-node
|
||||
expect(getStorageCapabilitiesForResource('mystery', undefined, { shared: false })).toEqual([
|
||||
'capacity',
|
||||
'health',
|
||||
]);
|
||||
});
|
||||
|
||||
// ---- Branch: dedupe collapses duplicate multi-node -------------------
|
||||
it('deduplicates multi-node when both ceph and shared branches add it', () => {
|
||||
const caps = getStorageCapabilitiesForResource('ceph', undefined, { shared: true });
|
||||
// ceph branch pushes 'multi-node'; shared branch pushes it again; the
|
||||
// Set-based dedupe must leave exactly one occurrence.
|
||||
expect(caps).toEqual(['capacity', 'health', 'replication', 'multi-node']);
|
||||
expect(caps.filter((c) => c === 'multi-node')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('combines backup-repository caps with shared multi-node without duplicates', () => {
|
||||
expect(getStorageCapabilitiesForResource('pbs', undefined, { shared: true })).toEqual([
|
||||
'capacity',
|
||||
'health',
|
||||
'backup-repository',
|
||||
'deduplication',
|
||||
'namespaces',
|
||||
'multi-node',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { StorageRecord } from '@/features/storageBackups/models';
|
||||
import {
|
||||
getCompactStoragePoolImpactLabel,
|
||||
getCompactStoragePoolIssueLabel,
|
||||
getCompactStoragePoolIssueSummary,
|
||||
getStoragePoolIssueTextClass,
|
||||
getStoragePoolProtectionTextClass,
|
||||
getStoragePoolStateLabel,
|
||||
getStoragePoolStateTextClass,
|
||||
getStoragePoolStateTitle,
|
||||
} from '@/features/storageBackups/rowPresentation';
|
||||
|
||||
const baseRecord = (): StorageRecord =>
|
||||
({
|
||||
id: 'storage-1',
|
||||
name: 'tank',
|
||||
source: {
|
||||
platform: 'truenas',
|
||||
type: 'storage',
|
||||
label: 'TrueNAS',
|
||||
},
|
||||
category: 'pool',
|
||||
statusLabel: 'Healthy',
|
||||
health: 'healthy',
|
||||
capacity: { totalBytes: 0, usedBytes: 0, freeBytes: 0 },
|
||||
location: { label: 'tower', scope: 'node' },
|
||||
capabilities: ['capacity'],
|
||||
observedAt: Date.now(),
|
||||
refs: {},
|
||||
}) as unknown as StorageRecord;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getStoragePoolIssueTextClass
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getStoragePoolIssueTextClass branch coverage', () => {
|
||||
it('returns red tone when incidentSeverity is "critical"', () => {
|
||||
const record = { ...baseRecord(), incidentSeverity: 'critical' };
|
||||
expect(getStoragePoolIssueTextClass(record)).toBe('text-red-700 dark:text-red-300');
|
||||
});
|
||||
|
||||
it('returns red tone when incidentSeverity is "offline"', () => {
|
||||
const record = { ...baseRecord(), incidentSeverity: 'offline' };
|
||||
expect(getStoragePoolIssueTextClass(record)).toBe('text-red-700 dark:text-red-300');
|
||||
});
|
||||
|
||||
it('falls back to record.health when incidentSeverity is absent and lowercases it', () => {
|
||||
// " WARNING " exercises both the `record.health` arm of the `||` chain and trim()/toLowerCase().
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
incidentSeverity: undefined,
|
||||
health: ' WARNING ',
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolIssueTextClass(record)).toBe('text-amber-700 dark:text-amber-300');
|
||||
});
|
||||
|
||||
it('falls back to the empty-string default when both incidentSeverity and health are falsy', () => {
|
||||
// Drives the `|| ''` tail of the `||` chain so severity becomes ''.
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
incidentSeverity: undefined,
|
||||
health: '' as unknown as StorageRecord['health'],
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolIssueTextClass(record)).toBe('text-base-content');
|
||||
});
|
||||
|
||||
it('returns base-content for an unmatched severity like "unknown"', () => {
|
||||
const record = { ...baseRecord(), incidentSeverity: 'info' };
|
||||
expect(getStoragePoolIssueTextClass(record)).toBe('text-base-content');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getCompactStoragePoolIssueSummary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getCompactStoragePoolIssueSummary branch coverage', () => {
|
||||
it('returns "" when the issue label resolves to "—"', () => {
|
||||
// Default healthy record: getCompactStoragePoolIssueLabel -> '—'.
|
||||
expect(getCompactStoragePoolIssueSummary(baseRecord())).toBe('');
|
||||
});
|
||||
|
||||
it('returns the canonical issue summary when it is present and not "healthy"', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Degraded',
|
||||
issueSummary: 'Pool is degraded',
|
||||
};
|
||||
expect(getCompactStoragePoolIssueSummary(record)).toBe('Pool is degraded');
|
||||
});
|
||||
|
||||
it('falls through a "healthy" summary to the zfs read-errors branch', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Healthy',
|
||||
issueSummary: 'Healthy',
|
||||
details: {
|
||||
zfsPool: {
|
||||
state: 'DEGRADED',
|
||||
devices: [],
|
||||
readErrors: 3,
|
||||
writeErrors: 0,
|
||||
checksumErrors: 0,
|
||||
},
|
||||
},
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolIssueSummary(record)).toBe('3 read errors');
|
||||
});
|
||||
|
||||
it('formats only write errors when read/checksum are zero', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Healthy',
|
||||
issueSummary: ' ',
|
||||
details: {
|
||||
zfsPool: {
|
||||
state: 'DEGRADED',
|
||||
devices: [],
|
||||
readErrors: 0,
|
||||
writeErrors: 4,
|
||||
checksumErrors: 0,
|
||||
},
|
||||
},
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolIssueSummary(record)).toBe('4 write errors');
|
||||
});
|
||||
|
||||
it('combines all three error kinds in the documented order', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Healthy',
|
||||
issueSummary: '',
|
||||
details: {
|
||||
zfsPool: {
|
||||
state: 'FAULTED',
|
||||
devices: [],
|
||||
readErrors: 1,
|
||||
writeErrors: 2,
|
||||
checksumErrors: 3,
|
||||
},
|
||||
},
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolIssueSummary(record)).toBe('1 read, 2 write, 3 checksum errors');
|
||||
});
|
||||
|
||||
it('returns "" when the pool is absent and the issue label is derived from a non-safe status', () => {
|
||||
// getCompactStoragePoolIssueLabel: empty issueLabel, no pool, statusLabel 'Failed' -> 'Failed' (not '—').
|
||||
// Then summary: issueSummary empty -> pool null -> ''.
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
statusLabel: 'Failed',
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolIssueSummary(record)).toBe('');
|
||||
});
|
||||
|
||||
it('returns "" when the pool exists but has no errors and the summary is "healthy"', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Healthy',
|
||||
issueSummary: 'Healthy',
|
||||
details: {
|
||||
zfsPool: {
|
||||
state: 'DEGRADED',
|
||||
devices: [],
|
||||
readErrors: 0,
|
||||
writeErrors: 0,
|
||||
checksumErrors: 0,
|
||||
},
|
||||
},
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolIssueSummary(record)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getStoragePoolProtectionTextClass
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getStoragePoolProtectionTextClass branch coverage', () => {
|
||||
it('returns the recoverability red tone via incidentCategory alone', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
incidentCategory: 'recoverability',
|
||||
};
|
||||
expect(getStoragePoolProtectionTextClass(record)).toBe('text-red-700 dark:text-red-300');
|
||||
});
|
||||
|
||||
it('returns base-content for a healthy, fully-protected pool', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
protectionLabel: 'Healthy',
|
||||
};
|
||||
expect(getStoragePoolProtectionTextClass(record)).toBe('text-base-content');
|
||||
});
|
||||
|
||||
it('returns the rebuild blue tone regardless of protection label', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
rebuildInProgress: true,
|
||||
protectionLabel: 'No parity',
|
||||
};
|
||||
expect(getStoragePoolProtectionTextClass(record)).toBe('text-blue-700 dark:text-blue-300');
|
||||
});
|
||||
|
||||
it('treats "no parity" as factual (base-content) even when protectionReduced is set', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
protectionLabel: 'No parity',
|
||||
protectionReduced: true,
|
||||
};
|
||||
expect(getStoragePoolProtectionTextClass(record)).toBe('text-base-content');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getStoragePoolStateLabel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getStoragePoolStateLabel branch coverage', () => {
|
||||
it('titleizes a present details.arrayState', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { arrayState: 'STARTED' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Started');
|
||||
});
|
||||
|
||||
it('returns "Online" when only the zfs pool state is "ONLINE"', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: {
|
||||
zfsPool: {
|
||||
state: 'ONLINE',
|
||||
devices: [],
|
||||
},
|
||||
},
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Online');
|
||||
});
|
||||
|
||||
it('returns the raw pool state when it is anything other than "ONLINE"', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: {
|
||||
zfsPool: {
|
||||
state: 'FAULTED',
|
||||
devices: [],
|
||||
},
|
||||
},
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('FAULTED');
|
||||
});
|
||||
|
||||
it('titleizes the derived status when arrayState and pool are both absent', () => {
|
||||
// No details.status; health 'critical' -> getStorageRecordStatus -> 'critical' -> 'Critical'.
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
health: 'critical',
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Critical');
|
||||
});
|
||||
|
||||
it('reads details.status (titleized) when arrayState and pool are absent', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { status: 'available' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Available');
|
||||
});
|
||||
|
||||
it('exercises the `details || {}` defensive arm when details is undefined', () => {
|
||||
// With no details at all, getRecordDetails returns {} via the `|| {}` branch;
|
||||
// arrayState -> '' -> pool none -> status from health 'warning' -> 'Degraded'.
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: undefined,
|
||||
health: 'warning',
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateLabel(record)).toBe('Degraded');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getCompactStoragePoolIssueLabel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getCompactStoragePoolIssueLabel branch coverage', () => {
|
||||
it('collapses to "—" when the issue label matches the derived protection label', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'No parity',
|
||||
protectionLabel: 'No parity',
|
||||
protectionReduced: true,
|
||||
};
|
||||
expect(getCompactStoragePoolIssueLabel(record)).toBe('—');
|
||||
});
|
||||
|
||||
it('returns the raw issue label when it differs from the protection label', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Scrub failed',
|
||||
protectionLabel: 'Healthy',
|
||||
};
|
||||
expect(getCompactStoragePoolIssueLabel(record)).toBe('Scrub failed');
|
||||
});
|
||||
|
||||
it('falls back to the zfs pool state when the issue label is empty and pool is not ONLINE', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: {
|
||||
zfsPool: {
|
||||
state: 'DEGRADED',
|
||||
devices: [],
|
||||
},
|
||||
},
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolIssueLabel(record)).toBe('DEGRADED');
|
||||
});
|
||||
|
||||
it('skips an ONLINE zfs pool and falls back to a non-safe statusLabel', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: {
|
||||
zfsPool: {
|
||||
state: 'ONLINE',
|
||||
devices: [],
|
||||
},
|
||||
},
|
||||
statusLabel: 'Faulted',
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolIssueLabel(record)).toBe('Faulted');
|
||||
});
|
||||
|
||||
it('returns "—" when issue label, non-ONLINE pool, and a safe status are all absent', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
statusLabel: 'online',
|
||||
} as unknown as StorageRecord;
|
||||
expect(getCompactStoragePoolIssueLabel(record)).toBe('—');
|
||||
});
|
||||
|
||||
it('returns "—" for a default healthy record with no issue signal', () => {
|
||||
expect(getCompactStoragePoolIssueLabel(baseRecord())).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getStoragePoolStateTextClass
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getStoragePoolStateTextClass branch coverage', () => {
|
||||
it('maps a "critical" state label to the red tone', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { arrayState: 'CRITICAL' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTextClass(record)).toBe('text-red-700 dark:text-red-300');
|
||||
});
|
||||
|
||||
it('maps a raw "FAULTED" pool state to the red tone', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: {
|
||||
zfsPool: {
|
||||
state: 'FAULTED',
|
||||
devices: [],
|
||||
},
|
||||
},
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTextClass(record)).toBe('text-red-700 dark:text-red-300');
|
||||
});
|
||||
|
||||
it('maps an "offline" derived status to the red tone', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
health: 'offline',
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTextClass(record)).toBe('text-red-700 dark:text-red-300');
|
||||
});
|
||||
|
||||
it('maps a "Warning" arrayState to the amber tone', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { arrayState: 'WARNING' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTextClass(record)).toBe('text-amber-700 dark:text-amber-300');
|
||||
});
|
||||
|
||||
it('maps a "Warn" arrayState (the warn alias) to the amber tone', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { arrayState: 'WARN' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTextClass(record)).toBe('text-amber-700 dark:text-amber-300');
|
||||
});
|
||||
|
||||
it('maps a "Degraded" derived status (health warning) to the amber tone', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
health: 'warning',
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTextClass(record)).toBe('text-amber-700 dark:text-amber-300');
|
||||
});
|
||||
|
||||
it('keeps a healthy/online state at base-content', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { arrayState: 'STARTED' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTextClass(record)).toBe('text-base-content');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getStoragePoolStateTitle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getStoragePoolStateTitle branch coverage', () => {
|
||||
it('prefers a non-healthy summary when the label is not "Started"', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Degraded',
|
||||
issueSummary: 'One disk is offline',
|
||||
details: { arrayState: 'DEGRADED' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTitle(record)).toBe('One disk is offline');
|
||||
});
|
||||
|
||||
it('returns the label when the summary equals the label "Started" (suppresses summary)', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Degraded',
|
||||
issueSummary: 'Some warning',
|
||||
details: { arrayState: 'STARTED' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTitle(record)).toBe('Started');
|
||||
});
|
||||
|
||||
it('falls back to record.issueSummary when getCompactStoragePoolIssueSummary is empty', () => {
|
||||
// issueLabel 'Healthy' -> getCompactStoragePoolIssueSummary returns '' (label is '—' upstream).
|
||||
// But issueSummary is non-healthy, so the `|| getStorageRecordIssueSummary` arm feeds the summary.
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
issueLabel: 'Healthy',
|
||||
issueSummary: 'Manual maintenance',
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTitle(record)).toBe('Manual maintenance');
|
||||
});
|
||||
|
||||
it('returns the resolved label when there is no summary at all', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
details: { arrayState: 'STARTED' },
|
||||
} as unknown as StorageRecord;
|
||||
expect(getStoragePoolStateTitle(record)).toBe('Started');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getCompactStoragePoolImpactLabel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getCompactStoragePoolImpactLabel branch coverage', () => {
|
||||
it('returns the impact summary when consumerCount > 0', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
consumerCount: 2,
|
||||
impactSummary: '2 consumers',
|
||||
};
|
||||
expect(getCompactStoragePoolImpactLabel(record)).toBe('2 consumers');
|
||||
});
|
||||
|
||||
it('falls back to "—" when protectedWorkloadCount > 0 but impactSummary is blank', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
protectedWorkloadCount: 1,
|
||||
impactSummary: ' ',
|
||||
};
|
||||
expect(getCompactStoragePoolImpactLabel(record)).toBe('—');
|
||||
});
|
||||
|
||||
it('returns the impact summary when affectedDatastoreCount > 0', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
affectedDatastoreCount: 3,
|
||||
impactSummary: '3 datastores affected',
|
||||
};
|
||||
expect(getCompactStoragePoolImpactLabel(record)).toBe('3 datastores affected');
|
||||
});
|
||||
|
||||
it('returns "—" when all three counts are zero', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
consumerCount: 0,
|
||||
protectedWorkloadCount: 0,
|
||||
affectedDatastoreCount: 0,
|
||||
impactSummary: 'ignored',
|
||||
};
|
||||
expect(getCompactStoragePoolImpactLabel(record)).toBe('—');
|
||||
});
|
||||
|
||||
it('returns "—" when all counts are absent and impactSummary is set', () => {
|
||||
const record = {
|
||||
...baseRecord(),
|
||||
impactSummary: 'No dependent resources',
|
||||
};
|
||||
expect(getCompactStoragePoolImpactLabel(record)).toBe('—');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatActionApprovalPolicyLabel,
|
||||
formatActionCapabilityLabel,
|
||||
getActionAuditRecordStatePresentation,
|
||||
getActionAuditRefusalPresentation,
|
||||
getActionAuditResultPresentation,
|
||||
getActionAuditStatePresentation,
|
||||
getActionAuditVerification,
|
||||
getActionAuditVerificationOutcomePresentation,
|
||||
shouldRenderActionAuditVerification,
|
||||
} from '@/utils/actionAuditPresentation';
|
||||
|
||||
// `getActionAuditResultMessage` is module-private (not exported); its branches
|
||||
// are exercised transitively through the failure path of
|
||||
// `getActionAuditResultPresentation`, which is the only caller.
|
||||
|
||||
describe('actionAuditPresentation branch coverage (supplemental)', () => {
|
||||
describe('getActionAuditStatePresentation', () => {
|
||||
it('resolves every canonical lifecycle state that the sibling test omits', () => {
|
||||
expect(getActionAuditStatePresentation('planned')).toStrictEqual({
|
||||
label: 'Planned',
|
||||
className: 'bg-surface-alt text-muted border-border',
|
||||
});
|
||||
expect(getActionAuditStatePresentation('approved')).toStrictEqual({
|
||||
label: 'Approved',
|
||||
className:
|
||||
'bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900 dark:text-blue-200 dark:border-blue-700',
|
||||
});
|
||||
expect(getActionAuditStatePresentation('rejected')).toStrictEqual({
|
||||
label: 'Rejected',
|
||||
className:
|
||||
'bg-red-100 text-red-800 border-red-200 dark:bg-red-900 dark:text-red-200 dark:border-red-700',
|
||||
});
|
||||
expect(getActionAuditStatePresentation('executing')).toStrictEqual({
|
||||
label: 'Executing',
|
||||
className:
|
||||
'bg-sky-100 text-sky-800 border-sky-200 dark:bg-sky-900 dark:text-sky-200 dark:border-sky-700',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the Unknown badge for undefined and unrecognised states (?? arm)', () => {
|
||||
const unknown = {
|
||||
label: 'Unknown',
|
||||
className: 'bg-surface-alt text-muted border-border',
|
||||
};
|
||||
expect(getActionAuditStatePresentation(undefined)).toStrictEqual(unknown);
|
||||
expect(getActionAuditStatePresentation('frobnicated')).toStrictEqual(unknown);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatActionCapabilityLabel', () => {
|
||||
it('returns the bare "Action" fallback for nullish and whitespace-only input', () => {
|
||||
// `(capabilityName || '')` short-circuit on undefined, then trim -> empty -> early return.
|
||||
expect(formatActionCapabilityLabel(undefined)).toBe('Action');
|
||||
expect(formatActionCapabilityLabel(' ')).toBe('Action');
|
||||
});
|
||||
|
||||
it('title-cases a single lowercase word without touching separators', () => {
|
||||
expect(formatActionCapabilityLabel('reboot')).toBe('Reboot');
|
||||
});
|
||||
|
||||
it('splits on hyphen runs the same way as underscores and dots', () => {
|
||||
// Exercises the `/[._-]+/g` replace arm for the hyphen class.
|
||||
expect(formatActionCapabilityLabel('restart-service')).toBe('Restart Service');
|
||||
});
|
||||
|
||||
it('collapses consecutive separators and trims surrounding whitespace', () => {
|
||||
// `a__b` -> replace -> `a b` -> split(/\s+/) -> ['a','b'].
|
||||
expect(formatActionCapabilityLabel(' a__b ')).toBe('A B');
|
||||
});
|
||||
|
||||
it('only re-cases the first character of each word, leaving the tail verbatim', () => {
|
||||
// Documents the behaviour: slice(0,1).toUpperCase() + slice(1) preserves the
|
||||
// rest verbatim, so 'updateContainer' becomes 'UpdateContainer' (camelCase
|
||||
// tail is kept, not lowercased).
|
||||
expect(formatActionCapabilityLabel('docker.updateContainer')).toBe(
|
||||
'Docker UpdateContainer',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatActionApprovalPolicyLabel', () => {
|
||||
it('maps the "none" policy arm the sibling test skips', () => {
|
||||
expect(formatActionApprovalPolicyLabel('none')).toBe('No approval');
|
||||
});
|
||||
|
||||
it('trims before matching, so a padded canonical value still hits its case', () => {
|
||||
expect(formatActionApprovalPolicyLabel(' none ')).toBe('No approval');
|
||||
});
|
||||
|
||||
it('routes nullish/empty policy through the default arm onto "Policy"', () => {
|
||||
// default -> formatActionCapabilityLabel(policy || 'Policy').
|
||||
expect(formatActionApprovalPolicyLabel(undefined)).toBe('Policy');
|
||||
expect(formatActionApprovalPolicyLabel('')).toBe('Policy');
|
||||
});
|
||||
|
||||
it('title-cases an unknown policy name via the default arm', () => {
|
||||
expect(formatActionApprovalPolicyLabel('break_glass')).toBe('Break Glass');
|
||||
expect(formatActionApprovalPolicyLabel('custom-policy')).toBe('Custom Policy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActionAuditResultMessage (via getActionAuditResultPresentation failure path)', () => {
|
||||
it('uses result.output when errorMessage is absent (|| arm)', () => {
|
||||
// Private helper branch: `result?.errorMessage || result?.output`.
|
||||
const presentation = getActionAuditResultPresentation({
|
||||
result: { success: false, output: 'stderr dump from executor' },
|
||||
});
|
||||
expect(presentation).toStrictEqual({
|
||||
kind: 'failure',
|
||||
label: 'Execution failed',
|
||||
detail: 'stderr dump from executor',
|
||||
className:
|
||||
'border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-300',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty message when neither errorMessage nor output is set', () => {
|
||||
// Final `|| ''` fallback inside the helper, then `|| undefined` on detail.
|
||||
const presentation = getActionAuditResultPresentation({
|
||||
result: { success: false },
|
||||
});
|
||||
expect(presentation).toStrictEqual({
|
||||
kind: 'failure',
|
||||
label: 'Execution failed',
|
||||
detail: undefined,
|
||||
className:
|
||||
'border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-300',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActionAuditRefusalPresentation', () => {
|
||||
it('returns undefined when there is no result at all (!audit.result arm)', () => {
|
||||
expect(getActionAuditRefusalPresentation({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the result was successful (audit.result.success arm)', () => {
|
||||
expect(
|
||||
getActionAuditRefusalPresentation({ result: { success: true, output: 'ok' } }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('matches a refusal prefix case-insensitively via the toLowerCase() normaliser', () => {
|
||||
// normalizedMessage = message.toLowerCase(); 'PLAN_DRIFT:' lowercases to the prefix.
|
||||
const presentation = getActionAuditRefusalPresentation({
|
||||
result: { success: false, errorMessage: 'PLAN_DRIFT: policy version changed' },
|
||||
});
|
||||
expect(presentation).toStrictEqual({
|
||||
prefix: 'plan_drift:',
|
||||
label: 'Plan changed',
|
||||
detail:
|
||||
'Pulse refused the action before dispatch because the approved plan no longer matched the current resource or policy state.',
|
||||
recordedDetail: 'policy version changed',
|
||||
className:
|
||||
'border-rose-200 bg-rose-50 text-rose-800 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-300',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops recordedDetail when the message has no detail after the prefix', () => {
|
||||
// `recordedDetail || undefined` falsy arm.
|
||||
const presentation = getActionAuditRefusalPresentation({
|
||||
result: { success: false, errorMessage: 'plan_drift:' },
|
||||
});
|
||||
expect(presentation?.recordedDetail).toBeUndefined();
|
||||
expect(presentation?.label).toBe('Plan changed');
|
||||
});
|
||||
|
||||
it('returns undefined when the failure message carries no recognised prefix', () => {
|
||||
// ACTION_REFUSAL_PREFIXES.find() returns undefined -> second early return.
|
||||
expect(
|
||||
getActionAuditRefusalPresentation({
|
||||
result: { success: false, errorMessage: 'totally unknown failure mode' },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActionAuditRecordStatePresentation', () => {
|
||||
it('keeps the generic Failed badge when a failed result is not a refusal', () => {
|
||||
// state === 'failed' && refusal -> falsey refusal -> falls through to state lookup.
|
||||
expect(
|
||||
getActionAuditRecordStatePresentation({
|
||||
state: 'failed',
|
||||
result: { success: false, errorMessage: 'executor exited with code 1' },
|
||||
}),
|
||||
).toStrictEqual({
|
||||
label: 'Failed',
|
||||
className:
|
||||
'bg-red-100 text-red-800 border-red-200 dark:bg-red-900 dark:text-red-200 dark:border-red-700',
|
||||
});
|
||||
});
|
||||
|
||||
it('delegates to the state lookup for non-failed states', () => {
|
||||
// state !== 'failed' -> skips the refusal check entirely.
|
||||
expect(
|
||||
getActionAuditRecordStatePresentation({ state: 'executing' }),
|
||||
).toStrictEqual({
|
||||
label: 'Executing',
|
||||
className:
|
||||
'bg-sky-100 text-sky-800 border-sky-200 dark:bg-sky-900 dark:text-sky-200 dark:border-sky-700',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActionAuditResultPresentation', () => {
|
||||
it('returns undefined when the record has no result (!result arm)', () => {
|
||||
expect(getActionAuditResultPresentation({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders the success branch with a trimmed output detail', () => {
|
||||
const presentation = getActionAuditResultPresentation({
|
||||
result: { success: true, output: ' reloaded ' },
|
||||
});
|
||||
expect(presentation).toStrictEqual({
|
||||
kind: 'success',
|
||||
label: 'Result',
|
||||
detail: 'reloaded',
|
||||
className: 'border-border bg-surface text-base-content',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits detail on the success branch when output is missing or blank', () => {
|
||||
// `result.output?.trim() || undefined` -> undefined for absent and whitespace-only.
|
||||
expect(
|
||||
getActionAuditResultPresentation({ result: { success: true } })?.detail,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getActionAuditResultPresentation({ result: { success: true, output: ' ' } })
|
||||
?.detail,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActionAuditVerificationOutcomePresentation', () => {
|
||||
it('returns undefined when there is no verification outcome (!status arm)', () => {
|
||||
// outcome undefined -> status '' -> early return.
|
||||
expect(getActionAuditVerificationOutcomePresentation({})).toBeUndefined();
|
||||
expect(
|
||||
getActionAuditVerificationOutcomePresentation({
|
||||
verificationOutcome: { status: '' },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
// whitespace-only status trims to '' and also returns undefined.
|
||||
expect(
|
||||
getActionAuditVerificationOutcomePresentation({
|
||||
verificationOutcome: { status: ' ' },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalises uppercase status to the canonical map key via toLowerCase()', () => {
|
||||
expect(
|
||||
getActionAuditVerificationOutcomePresentation({
|
||||
verificationOutcome: { status: 'VERIFIED' },
|
||||
}),
|
||||
).toStrictEqual({
|
||||
label: 'Verification confirmed',
|
||||
detail: 'Pulse confirmed the intended state after execution.',
|
||||
evidenceSummary: undefined,
|
||||
className:
|
||||
'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300',
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses a whitespace-only evidenceSummary to undefined', () => {
|
||||
// `evidenceSummary?.trim()` -> '' -> `|| undefined`.
|
||||
const presentation = getActionAuditVerificationOutcomePresentation({
|
||||
verificationOutcome: { status: 'failed', evidenceSummary: ' ' },
|
||||
});
|
||||
expect(presentation?.evidenceSummary).toBeUndefined();
|
||||
expect(presentation?.label).toBe('Verification failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActionAuditVerification', () => {
|
||||
it('returns undefined when neither top-level nor result verification exists', () => {
|
||||
// `audit.verification ?? audit.result?.verification` -> undefined ?? undefined.
|
||||
expect(getActionAuditVerification({})).toBeUndefined();
|
||||
expect(getActionAuditVerification({ result: { success: true } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers the top-level verification over the result-embedded one (?? left operand)', () => {
|
||||
const top = { ran: true, success: true, command: 'top-level' };
|
||||
const result = { ran: false, success: false, command: 'embedded' };
|
||||
expect(
|
||||
getActionAuditVerification({
|
||||
verification: top,
|
||||
result: { success: true, verification: result },
|
||||
}),
|
||||
).toStrictEqual(top);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRenderActionAuditVerification', () => {
|
||||
it('returns false when no verification object exists at all', () => {
|
||||
// `undefined?.ran === true` -> false.
|
||||
expect(shouldRenderActionAuditVerification({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true from the result-embedded fallback verification', () => {
|
||||
// Exercises the ?? right operand of getActionAuditVerification plus the true arm.
|
||||
expect(
|
||||
shouldRenderActionAuditVerification({
|
||||
result: { success: true, verification: { ran: true, success: true } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('uses strict equality against the boolean true (truthy non-boolean does not count)', () => {
|
||||
// `ran` is typed boolean; we deliberately coerce a non-boolean to prove the
|
||||
// `=== true` strictness rather than a truthiness check.
|
||||
expect(
|
||||
shouldRenderActionAuditVerification({
|
||||
result: {
|
||||
success: true,
|
||||
verification: {
|
||||
ran: 1 as unknown as boolean,
|
||||
success: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,585 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Resource, ResourceAvailabilityMeta } from '@/types/resource';
|
||||
import {
|
||||
getAvailabilityProbeMethodLabel,
|
||||
getAvailabilityProbePresentation,
|
||||
getAvailabilityProbeTargetLabel,
|
||||
} from '@/utils/availabilityProbePresentation';
|
||||
|
||||
// `normalizeAvailabilityProtocol`, `getAvailabilityProbeResultLabel`,
|
||||
// `getAvailabilityProbeToneClassName`, and `getFailureCountLabel` are module-private
|
||||
// (non-exported) helpers, so they are exercised indirectly through the three exported
|
||||
// entry points below, asserting on their observable outputs.
|
||||
|
||||
const makeAvailability = (
|
||||
overrides?: Partial<ResourceAvailabilityMeta>,
|
||||
): ResourceAvailabilityMeta => ({
|
||||
protocol: 'tcp',
|
||||
port: 443,
|
||||
available: true,
|
||||
latencyMillis: 5,
|
||||
lastChecked: '2026-05-06T13:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeResource = (overrides?: Partial<Resource>): Resource => ({
|
||||
id: 'availability:probe-1',
|
||||
type: 'network-endpoint',
|
||||
name: 'probe-target',
|
||||
displayName: 'probe-target',
|
||||
platformId: 'probe-1',
|
||||
platformType: 'availability',
|
||||
sourceType: 'api',
|
||||
status: 'online',
|
||||
lastSeen: 1,
|
||||
availability: makeAvailability(),
|
||||
platformData: { sources: ['availability'] },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const AT = new Date('2026-05-06T13:00:20Z').getTime();
|
||||
|
||||
describe('normalizeAvailabilityProtocol — branch coverage (via getAvailabilityProbeMethodLabel)', () => {
|
||||
// The helper is `(protocol ?? '').trim().toLowerCase()`; each case proves a distinct
|
||||
// input shape flows through the nullish-coalesce, trim, and lowercase steps.
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('coerces an undefined protocol to an empty (unknown) protocol', () => {
|
||||
expect(getAvailabilityProbeMethodLabel({ protocol: undefined })).toBe('Probe');
|
||||
});
|
||||
|
||||
it('coerces a null protocol to an empty (unknown) protocol', () => {
|
||||
expect(
|
||||
getAvailabilityProbeMethodLabel({ protocol: null as unknown as string }),
|
||||
).toBe('Probe');
|
||||
});
|
||||
|
||||
it('trims and lowercases a cased/whitespace-padded protocol before matching', () => {
|
||||
// ' IcMp ' -> normalize -> 'icmp' -> 'ICMP'
|
||||
expect(getAvailabilityProbeMethodLabel({ protocol: ' IcMp ' })).toBe('ICMP');
|
||||
});
|
||||
|
||||
it('treats a whitespace-only protocol as empty', () => {
|
||||
expect(getAvailabilityProbeMethodLabel({ protocol: ' ' })).toBe('Probe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailabilityProbeMethodLabel — branch coverage', () => {
|
||||
it('returns plain "TCP" when the protocol is tcp but no port is set', () => {
|
||||
expect(getAvailabilityProbeMethodLabel({ protocol: 'tcp' })).toBe('TCP');
|
||||
});
|
||||
|
||||
it('returns "TCP <port>" when tcp and a port is set', () => {
|
||||
expect(getAvailabilityProbeMethodLabel({ protocol: 'tcp', port: 22 })).toBe('TCP 22');
|
||||
});
|
||||
|
||||
it('returns bare "HTTP" when http has no path', () => {
|
||||
expect(getAvailabilityProbeMethodLabel({ protocol: 'http' })).toBe('HTTP');
|
||||
});
|
||||
|
||||
it('returns bare "HTTPS" when https has no path', () => {
|
||||
expect(getAvailabilityProbeMethodLabel({ protocol: 'https' })).toBe('HTTPS');
|
||||
});
|
||||
|
||||
it('returns "HTTPS <path>" when https has a path', () => {
|
||||
expect(getAvailabilityProbeMethodLabel({ protocol: 'https', path: '/healthz' })).toBe(
|
||||
'HTTPS /healthz',
|
||||
);
|
||||
});
|
||||
|
||||
it('uppercases an unrecognized-but-non-empty protocol', () => {
|
||||
expect(getAvailabilityProbeMethodLabel({ protocol: 'snmp' })).toBe('SNMP');
|
||||
});
|
||||
|
||||
it('falls back to "Probe" when the availability object has no protocol', () => {
|
||||
expect(getAvailabilityProbeMethodLabel({})).toBe('Probe');
|
||||
});
|
||||
|
||||
it('falls back to "Probe" when availability is undefined', () => {
|
||||
expect(getAvailabilityProbeMethodLabel(undefined)).toBe('Probe');
|
||||
});
|
||||
|
||||
it('falls back to "Probe" when availability is null', () => {
|
||||
expect(getAvailabilityProbeMethodLabel(null)).toBe('Probe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailabilityProbeTargetLabel — branch coverage', () => {
|
||||
it('returns the port string for tcp with a positive finite port', () => {
|
||||
expect(getAvailabilityProbeTargetLabel({ protocol: 'tcp', port: 443 })).toBe('443');
|
||||
});
|
||||
|
||||
it('returns null for tcp with port 0 (port > 0 guard)', () => {
|
||||
expect(getAvailabilityProbeTargetLabel({ protocol: 'tcp', port: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for tcp with a negative port', () => {
|
||||
expect(getAvailabilityProbeTargetLabel({ protocol: 'tcp', port: -1 })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for tcp with a non-finite port (Infinity)', () => {
|
||||
expect(getAvailabilityProbeTargetLabel({ protocol: 'tcp', port: Infinity })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for tcp with a non-finite port (NaN)', () => {
|
||||
expect(getAvailabilityProbeTargetLabel({ protocol: 'tcp', port: NaN })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for tcp when port is the wrong type (string)', () => {
|
||||
expect(
|
||||
getAvailabilityProbeTargetLabel({ protocol: 'tcp', port: 'abc' as unknown as number }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for tcp when no port is set', () => {
|
||||
expect(getAvailabilityProbeTargetLabel({ protocol: 'tcp' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the trimmed path for http with a path', () => {
|
||||
expect(getAvailabilityProbeTargetLabel({ protocol: 'http', path: '/status' })).toBe(
|
||||
'/status',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the trimmed path for https with a path', () => {
|
||||
expect(getAvailabilityProbeTargetLabel({ protocol: 'https', path: '/ready' })).toBe(
|
||||
'/ready',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for http with no path', () => {
|
||||
expect(getAvailabilityProbeTargetLabel({ protocol: 'http' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for http with a whitespace-only path', () => {
|
||||
expect(getAvailabilityProbeTargetLabel({ protocol: 'http', path: ' ' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for icmp (non-tcp/http protocol)', () => {
|
||||
expect(getAvailabilityProbeTargetLabel({ protocol: 'icmp' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an unrecognized protocol', () => {
|
||||
expect(getAvailabilityProbeTargetLabel({ protocol: 'snmp' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when availability is undefined', () => {
|
||||
expect(getAvailabilityProbeTargetLabel(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailabilityProbeResultLabel — branch coverage (via presentation.resultLabel)', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('returns "reachable" when available is true with no usable latency', () => {
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'online',
|
||||
availability: { protocol: 'icmp', available: true, lastChecked: '2026-05-06T13:00:00Z' },
|
||||
}),
|
||||
);
|
||||
expect(presentation?.resultLabel).toBe('reachable');
|
||||
});
|
||||
|
||||
it('returns "not checked" when not failed, no latency, and not reachable', () => {
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'unknown',
|
||||
availability: { protocol: 'icmp', lastChecked: '2026-05-06T13:00:00Z' },
|
||||
}),
|
||||
);
|
||||
expect(presentation?.resultLabel).toBe('not checked');
|
||||
});
|
||||
|
||||
it('returns "failed" on the failure path when lastError has no timeout/HTTP-status token', () => {
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'degraded',
|
||||
availability: {
|
||||
protocol: 'icmp',
|
||||
lastChecked: '2026-05-06T13:00:00Z',
|
||||
lastError: 'route unreachable',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(presentation?.resultLabel).toBe('failed');
|
||||
});
|
||||
|
||||
it('maps a "timeout" token (not "timed out") to "timed out"', () => {
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'online',
|
||||
availability: {
|
||||
protocol: 'http',
|
||||
available: false,
|
||||
lastChecked: '2026-05-06T13:00:00Z',
|
||||
lastError: 'Connection timeout exceeded',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(presentation?.resultLabel).toBe('timed out');
|
||||
});
|
||||
|
||||
it('extracts a 4xx HTTP status code from lastError', () => {
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'online',
|
||||
availability: {
|
||||
protocol: 'http',
|
||||
available: false,
|
||||
lastChecked: '2026-05-06T13:00:00Z',
|
||||
lastError: 'probe got 404 Not Found',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(presentation?.resultLabel).toBe('404');
|
||||
});
|
||||
|
||||
it('rounds fractional latency to the nearest millisecond', () => {
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'online',
|
||||
availability: { protocol: 'tcp', port: 443, latencyMillis: 7.4 },
|
||||
}),
|
||||
);
|
||||
expect(presentation?.resultLabel).toBe('7 ms');
|
||||
});
|
||||
|
||||
it('reports "0 ms" at the latency >= 0 boundary', () => {
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'online',
|
||||
availability: { protocol: 'tcp', port: 443, latencyMillis: 0 },
|
||||
}),
|
||||
);
|
||||
expect(presentation?.resultLabel).toBe('0 ms');
|
||||
});
|
||||
|
||||
it('falls through to "reachable" when latency is NaN (non-finite) but available is true', () => {
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'online',
|
||||
availability: { protocol: 'tcp', port: 443, available: true, latencyMillis: NaN },
|
||||
}),
|
||||
);
|
||||
expect(presentation?.resultLabel).toBe('reachable');
|
||||
});
|
||||
|
||||
it('falls through to "reachable" when latency is negative but available is true', () => {
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'online',
|
||||
availability: { protocol: 'tcp', port: 443, available: true, latencyMillis: -5 },
|
||||
}),
|
||||
);
|
||||
expect(presentation?.resultLabel).toBe('reachable');
|
||||
});
|
||||
|
||||
it('falls through to "reachable" when latency is the wrong type but available is true', () => {
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'online',
|
||||
availability: {
|
||||
protocol: 'tcp',
|
||||
port: 443,
|
||||
available: true,
|
||||
latencyMillis: 'fast' as unknown as number,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(presentation?.resultLabel).toBe('reachable');
|
||||
});
|
||||
|
||||
it('honours the latency path even when status is "online" and available is true', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'online',
|
||||
availability: makeAvailability({ latencyMillis: 9.5 }),
|
||||
}),
|
||||
);
|
||||
expect(presentation?.resultLabel).toBe('10 ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailabilityProbeToneClassName — branch coverage (via presentation.toneClassName)', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('returns the warning class for a degraded status that is not hard-failed', () => {
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'degraded',
|
||||
availability: { protocol: 'icmp', lastChecked: '2026-05-06T13:00:00Z' },
|
||||
}),
|
||||
);
|
||||
expect(presentation?.toneClassName).toBe('text-amber-600 dark:text-amber-300');
|
||||
});
|
||||
|
||||
it('returns the unknown class when not failed/degraded and not reachable', () => {
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'unknown',
|
||||
availability: { protocol: 'icmp', lastChecked: '2026-05-06T13:00:00Z' },
|
||||
}),
|
||||
);
|
||||
expect(presentation?.toneClassName).toBe('text-muted');
|
||||
});
|
||||
|
||||
it('returns the error class when available is false even if status is online', () => {
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'online',
|
||||
availability: {
|
||||
protocol: 'http',
|
||||
available: false,
|
||||
lastChecked: '2026-05-06T13:00:00Z',
|
||||
lastError: '503 Service Unavailable',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(presentation?.toneClassName).toBe('text-red-600 dark:text-red-300');
|
||||
});
|
||||
|
||||
it('returns the success class when available is true even if status is not online', () => {
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'unknown',
|
||||
availability: {
|
||||
protocol: 'tcp',
|
||||
port: 443,
|
||||
available: true,
|
||||
latencyMillis: 5,
|
||||
lastChecked: '2026-05-06T13:00:00Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(presentation?.toneClassName).toBe('text-emerald-600 dark:text-emerald-300');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFailureCountLabel — branch coverage (via presentation.detailLabel)', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('renders "1 failure" for a single failure with no threshold', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
availability: makeAvailability({ consecutiveFailures: 1 }),
|
||||
}),
|
||||
);
|
||||
expect(presentation?.detailLabel).toContain('1 failure');
|
||||
expect(presentation?.detailLabel).not.toContain('1 failures');
|
||||
expect(presentation?.detailLabel).not.toContain('/');
|
||||
});
|
||||
|
||||
it('renders "N failures" for multiple failures with no threshold', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
availability: makeAvailability({ consecutiveFailures: 2 }),
|
||||
}),
|
||||
);
|
||||
expect(presentation?.detailLabel).toContain('2 failures');
|
||||
expect(presentation?.detailLabel).not.toContain('/');
|
||||
});
|
||||
|
||||
it('omits any failure count when consecutiveFailures is 0', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
availability: makeAvailability({ consecutiveFailures: 0 }),
|
||||
}),
|
||||
);
|
||||
expect(presentation?.detailLabel).not.toMatch(/failure/);
|
||||
});
|
||||
|
||||
it('omits any failure count when consecutiveFailures is negative', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
availability: makeAvailability({ consecutiveFailures: -3 }),
|
||||
}),
|
||||
);
|
||||
expect(presentation?.detailLabel).not.toMatch(/failure/);
|
||||
});
|
||||
|
||||
it('omits any failure count when consecutiveFailures is NaN', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
availability: makeAvailability({ consecutiveFailures: NaN }),
|
||||
}),
|
||||
);
|
||||
expect(presentation?.detailLabel).not.toMatch(/failure/);
|
||||
});
|
||||
|
||||
it('omits any failure count when consecutiveFailures is the wrong type', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
availability: makeAvailability({
|
||||
consecutiveFailures: 'bad' as unknown as number,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(presentation?.detailLabel).not.toMatch(/failure/);
|
||||
});
|
||||
|
||||
it('falls back to "N failures" when failureThreshold is present but invalid (0)', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
availability: makeAvailability({ consecutiveFailures: 3, failureThreshold: 0 }),
|
||||
}),
|
||||
);
|
||||
expect(presentation?.detailLabel).toContain('3 failures');
|
||||
expect(presentation?.detailLabel).not.toContain('/');
|
||||
});
|
||||
|
||||
it('renders the "N/threshold failures" form when both are positive finite numbers', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
availability: makeAvailability({ consecutiveFailures: 3, failureThreshold: 4 }),
|
||||
}),
|
||||
);
|
||||
expect(presentation?.detailLabel).toContain('3/4 failures');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailabilityProbePresentation — branch coverage', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('returns null when neither availability nor platformData.availability is set', () => {
|
||||
expect(
|
||||
getAvailabilityProbePresentation(
|
||||
makeResource({ availability: undefined, platformData: { sources: ['availability'] } }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when availability is unset and platformData itself is missing (optional chain)', () => {
|
||||
expect(
|
||||
getAvailabilityProbePresentation(
|
||||
makeResource({ availability: undefined, platformData: undefined }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers resource.availability over platformData.availability (?? left operand wins)', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
availability: makeAvailability({ port: 443 }),
|
||||
platformData: {
|
||||
sources: ['availability'],
|
||||
availability: makeAvailability({ port: 8080 }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(presentation?.methodLabel).toBe('TCP 443');
|
||||
expect(presentation?.targetLabel).toBe('443');
|
||||
});
|
||||
|
||||
it('builds netIoLabel as "<target>: <result>" when a target exists', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(makeResource());
|
||||
expect(presentation?.netIoLabel).toBe('443: 5 ms');
|
||||
});
|
||||
|
||||
it('builds netIoLabel from just the result when there is no target (icmp)', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
availability: { protocol: 'icmp', available: true, lastChecked: '2026-05-06T13:00:00Z' },
|
||||
}),
|
||||
);
|
||||
expect(presentation?.targetLabel).toBeNull();
|
||||
expect(presentation?.netIoLabel).toBe(presentation?.resultLabel);
|
||||
expect(presentation?.netIoLabel).toBe('reachable');
|
||||
});
|
||||
|
||||
it('drops the "checked ..." segment from both rowLabel and detailLabel when lastChecked is missing', () => {
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
availability: makeAvailability({ lastChecked: undefined }),
|
||||
}),
|
||||
);
|
||||
expect(presentation?.rowLabel).toBe(presentation?.netIoLabel);
|
||||
expect(presentation?.rowLabel).toBe('443: 5 ms');
|
||||
expect(presentation?.detailLabel).not.toContain('checked');
|
||||
});
|
||||
|
||||
it('includes a deterministic "checked ..." segment when lastChecked is set', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(makeResource());
|
||||
expect(presentation?.rowLabel).toBe('443: 5 ms - checked 20s ago');
|
||||
expect(presentation?.detailLabel).toContain('checked 20s ago');
|
||||
});
|
||||
|
||||
it('appends "last success ..." when available is false and lastSuccess parses to a relative time', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'offline',
|
||||
availability: {
|
||||
protocol: 'icmp',
|
||||
available: false,
|
||||
lastChecked: '2026-05-06T13:00:00Z',
|
||||
lastSuccess: '2026-05-06T12:51:20Z',
|
||||
consecutiveFailures: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(presentation?.detailLabel).toContain('last success 9 mins ago');
|
||||
});
|
||||
|
||||
it('omits "last success ..." when lastSuccess is set but does not parse (sub-guard false)', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'offline',
|
||||
availability: {
|
||||
protocol: 'icmp',
|
||||
available: false,
|
||||
lastChecked: '2026-05-06T13:00:00Z',
|
||||
lastSuccess: 'not-a-real-date',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(presentation?.detailLabel).not.toContain('last success');
|
||||
});
|
||||
|
||||
it('omits "last success ..." when lastSuccess is set but available is not false (guard skipped)', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'online',
|
||||
availability: {
|
||||
protocol: 'icmp',
|
||||
available: true,
|
||||
latencyMillis: 4,
|
||||
lastChecked: '2026-05-06T13:00:00Z',
|
||||
lastSuccess: '2026-05-06T12:51:20Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(presentation?.detailLabel).not.toContain('last success');
|
||||
});
|
||||
|
||||
it('appends lastError verbatim to detailLabel when present', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(AT);
|
||||
const presentation = getAvailabilityProbePresentation(
|
||||
makeResource({
|
||||
status: 'offline',
|
||||
availability: {
|
||||
protocol: 'icmp',
|
||||
available: false,
|
||||
lastChecked: '2026-05-06T13:00:00Z',
|
||||
lastError: 'dial tcp: i/o timeout',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(presentation?.detailLabel).toContain('dial tcp: i/o timeout');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RecoveryOutcome } from '@/types/recovery';
|
||||
import {
|
||||
getRecoveryOutcomeBadgeClass,
|
||||
getRecoveryOutcomeBarClass,
|
||||
getRecoveryOutcomeLabel,
|
||||
getRecoveryOutcomeTextClass,
|
||||
normalizeRecoveryOutcome,
|
||||
} from '@/utils/recoveryOutcomePresentation';
|
||||
|
||||
// A value that is not part of the canonical outcome set; used to drive the
|
||||
// `default` arms of the switch statements and the fallthrough branch of
|
||||
// normalizeRecoveryOutcome. Cast through `unknown` to satisfy strict typing.
|
||||
const NON_CANONICAL = 'partial' as unknown as RecoveryOutcome;
|
||||
|
||||
const BADGE_BASE = 'inline-flex items-center rounded-full px-2 py-1 text-xs font-medium';
|
||||
|
||||
describe('recoveryOutcomePresentation — branch coverage (branchcov2)', () => {
|
||||
describe('normalizeRecoveryOutcome', () => {
|
||||
it('treats null/undefined/empty as falsy and falls back to unknown', () => {
|
||||
// Exercises the `(value || '')` defensive branch for every falsy input.
|
||||
expect(normalizeRecoveryOutcome(null)).toBe('unknown');
|
||||
expect(normalizeRecoveryOutcome(undefined)).toBe('unknown');
|
||||
expect(normalizeRecoveryOutcome('')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('treats whitespace-only input as unknown after trim', () => {
|
||||
expect(normalizeRecoveryOutcome(' ')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('matches the exact canonical tokens (case-insensitive)', () => {
|
||||
// Each of these hits a distinct early-return arm.
|
||||
expect(normalizeRecoveryOutcome('SUCCESS')).toBe('success');
|
||||
expect(normalizeRecoveryOutcome('Warning')).toBe('warning');
|
||||
expect(normalizeRecoveryOutcome('RUNNING')).toBe('running');
|
||||
expect(normalizeRecoveryOutcome('Unknown')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('maps the "error" alias onto failed', () => {
|
||||
// "error" is the only failed-alias not exercised by the sibling test.
|
||||
expect(normalizeRecoveryOutcome('error')).toBe('failed');
|
||||
expect(normalizeRecoveryOutcome(' ERROR ')).toBe('failed');
|
||||
});
|
||||
|
||||
it('hits the explicit unknown arm distinctly from the default fallthrough', () => {
|
||||
// 'unknown' takes the explicit `if (normalized === 'unknown')` arm while
|
||||
// a non-canonical token reaches the final default return.
|
||||
expect(normalizeRecoveryOutcome('unknown')).toBe('unknown');
|
||||
expect(normalizeRecoveryOutcome('nope')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecoveryOutcomeLabel', () => {
|
||||
it('returns a human label for every canonical outcome', () => {
|
||||
expect(getRecoveryOutcomeLabel('success')).toBe('Healthy');
|
||||
expect(getRecoveryOutcomeLabel('warning')).toBe('Warning');
|
||||
expect(getRecoveryOutcomeLabel('failed')).toBe('Failed');
|
||||
expect(getRecoveryOutcomeLabel('running')).toBe('Running');
|
||||
expect(getRecoveryOutcomeLabel('unknown')).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('routes non-canonical outcomes through the default arm', () => {
|
||||
expect(getRecoveryOutcomeLabel(NON_CANONICAL)).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecoveryOutcomeBadgeClass', () => {
|
||||
it('renders the full badge class for success and warning', () => {
|
||||
expect(getRecoveryOutcomeBadgeClass('success')).toBe(
|
||||
`${BADGE_BASE} bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300`,
|
||||
);
|
||||
expect(getRecoveryOutcomeBadgeClass('warning')).toBe(
|
||||
`${BADGE_BASE} bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300`,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the full badge class for failed and running (strict equality)', () => {
|
||||
// Sibling test only asserted substring membership; pin the whole string.
|
||||
expect(getRecoveryOutcomeBadgeClass('failed')).toBe(
|
||||
`${BADGE_BASE} bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300`,
|
||||
);
|
||||
expect(getRecoveryOutcomeBadgeClass('running')).toBe(
|
||||
`${BADGE_BASE} bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300`,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the neutral badge for the default arm', () => {
|
||||
expect(getRecoveryOutcomeBadgeClass('unknown')).toBe(
|
||||
`${BADGE_BASE} bg-surface-alt text-muted`,
|
||||
);
|
||||
expect(getRecoveryOutcomeBadgeClass(NON_CANONICAL)).toBe(
|
||||
`${BADGE_BASE} bg-surface-alt text-muted`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecoveryOutcomeBarClass', () => {
|
||||
it('returns a solid bar color for each canonical outcome', () => {
|
||||
expect(getRecoveryOutcomeBarClass('success')).toBe('bg-emerald-500');
|
||||
expect(getRecoveryOutcomeBarClass('warning')).toBe('bg-amber-400');
|
||||
expect(getRecoveryOutcomeBarClass('failed')).toBe('bg-red-500');
|
||||
expect(getRecoveryOutcomeBarClass('running')).toBe('bg-blue-500');
|
||||
});
|
||||
|
||||
it('falls back to gray for the default arm', () => {
|
||||
expect(getRecoveryOutcomeBarClass('unknown')).toBe('bg-gray-400');
|
||||
expect(getRecoveryOutcomeBarClass(NON_CANONICAL)).toBe('bg-gray-400');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecoveryOutcomeTextClass', () => {
|
||||
it('returns a dark-mode-aware text tone for each canonical outcome', () => {
|
||||
expect(getRecoveryOutcomeTextClass('success')).toBe('text-emerald-600 dark:text-emerald-400');
|
||||
expect(getRecoveryOutcomeTextClass('warning')).toBe('text-amber-600 dark:text-amber-400');
|
||||
expect(getRecoveryOutcomeTextClass('failed')).toBe('text-red-600 dark:text-red-400');
|
||||
expect(getRecoveryOutcomeTextClass('running')).toBe('text-blue-600 dark:text-blue-400');
|
||||
});
|
||||
|
||||
it('falls back to text-muted for the default arm', () => {
|
||||
expect(getRecoveryOutcomeTextClass('unknown')).toBe('text-muted');
|
||||
expect(getRecoveryOutcomeTextClass(NON_CANONICAL)).toBe('text-muted');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getRecoveryPointPlatform,
|
||||
getRecoveryRollupPlatforms,
|
||||
normalizeRecoveryPoint,
|
||||
normalizeRecoveryPointsResponse,
|
||||
normalizeRecoveryRollup,
|
||||
normalizeRecoveryRollupsResponse,
|
||||
} from '@/utils/recoveryPlatformModel';
|
||||
|
||||
/**
|
||||
* Branch-coverage companion to recoveryPlatformModel.test.ts.
|
||||
*
|
||||
* The non-exported helpers (toTrimmedString, normalizeRecoveryDisplay,
|
||||
* getRecoveryItemResourceId, getRecoveryItemRef, normalizeRecoveryMeta) are
|
||||
* exercised indirectly through the exported normalizers, since they are
|
||||
* module-private. Each test below targets specific arms of the conditionals
|
||||
* documented in recoveryPlatformModel.ts.
|
||||
*/
|
||||
describe('recoveryPlatformModel.branchcov2', () => {
|
||||
describe('toTrimmedString (via exported wrappers)', () => {
|
||||
it('trims surrounding whitespace from string inputs', () => {
|
||||
// Branch: typeof value === 'string' -> value.trim()
|
||||
expect(getRecoveryPointPlatform({ platform: ' truenas ' })).toBe('truenas');
|
||||
expect(getRecoveryPointPlatform({ provider: '\tproxmox-pbs\n' })).toBe('proxmox-pbs');
|
||||
});
|
||||
|
||||
it('returns empty string for non-string values', () => {
|
||||
// Branch: typeof value !== 'string' -> '' (hit via a numeric subject id).
|
||||
const malformed = {
|
||||
id: 'p',
|
||||
kind: 'snapshot',
|
||||
mode: 'snapshot',
|
||||
outcome: 'success',
|
||||
subjectResourceId: 12345,
|
||||
} as unknown as Parameters<typeof normalizeRecoveryPoint>[0];
|
||||
const result = normalizeRecoveryPoint(malformed);
|
||||
expect(result).toStrictEqual({
|
||||
id: 'p',
|
||||
kind: 'snapshot',
|
||||
mode: 'snapshot',
|
||||
outcome: 'success',
|
||||
});
|
||||
expect('itemResourceId' in result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecoveryPointPlatform', () => {
|
||||
it('returns empty string when both platform and provider are absent', () => {
|
||||
expect(getRecoveryPointPlatform(null)).toBe('');
|
||||
expect(getRecoveryPointPlatform(undefined)).toBe('');
|
||||
expect(getRecoveryPointPlatform({})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecoveryRollupPlatforms', () => {
|
||||
it('falls back to providers when platforms is an empty array', () => {
|
||||
expect(getRecoveryRollupPlatforms({ platforms: [], providers: ['x'] })).toEqual(['x']);
|
||||
});
|
||||
|
||||
it('falls back to providers when platforms is not an array', () => {
|
||||
const malformed = {
|
||||
platforms: 'nope',
|
||||
providers: ['kubernetes'],
|
||||
} as unknown as Parameters<typeof getRecoveryRollupPlatforms>[0];
|
||||
expect(getRecoveryRollupPlatforms(malformed)).toEqual(['kubernetes']);
|
||||
});
|
||||
|
||||
it('returns an empty array when both platforms and providers are absent', () => {
|
||||
expect(getRecoveryRollupPlatforms({})).toEqual([]);
|
||||
expect(getRecoveryRollupPlatforms(null)).toEqual([]);
|
||||
expect(getRecoveryRollupPlatforms(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('trims and filters blank entries from the resolved values', () => {
|
||||
expect(
|
||||
getRecoveryRollupPlatforms({ platforms: [' a ', '', ' ', 'b'] }),
|
||||
).toEqual(['a', 'b']);
|
||||
// All entries normalize to empty -> filter(Boolean) yields [].
|
||||
expect(getRecoveryRollupPlatforms({ platforms: [' ', ''] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRecoveryDisplay (via normalizeRecoveryPoint)', () => {
|
||||
const basePoint = {
|
||||
id: 'p1',
|
||||
kind: 'snapshot' as const,
|
||||
mode: 'snapshot' as const,
|
||||
outcome: 'success' as const,
|
||||
};
|
||||
|
||||
it('passes a null display through unchanged', () => {
|
||||
// Branch: display == null -> return display (null).
|
||||
expect(normalizeRecoveryPoint({ ...basePoint, display: null })).toStrictEqual({
|
||||
...basePoint,
|
||||
display: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the display key entirely when display is undefined', () => {
|
||||
// Branch: display !== undefined === false -> display key not spread.
|
||||
const result = normalizeRecoveryPoint(basePoint);
|
||||
expect(result).toStrictEqual(basePoint);
|
||||
expect('display' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('uses canonical itemLabel/itemType when present without falling back', () => {
|
||||
expect(
|
||||
normalizeRecoveryPoint({
|
||||
...basePoint,
|
||||
display: { itemLabel: 'IL', itemType: 'IT', isWorkload: true },
|
||||
}),
|
||||
).toStrictEqual({
|
||||
...basePoint,
|
||||
display: { itemLabel: 'IL', itemType: 'IT', isWorkload: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to subjectLabel/subjectType when itemLabel/itemType are absent', () => {
|
||||
expect(
|
||||
normalizeRecoveryPoint({
|
||||
...basePoint,
|
||||
display: { subjectLabel: 'SL', subjectType: 'ST', detailsSummary: 'ds' },
|
||||
}),
|
||||
).toStrictEqual({
|
||||
...basePoint,
|
||||
display: { itemLabel: 'SL', itemType: 'ST', detailsSummary: 'ds' },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to subjectLabel when itemLabel is blank but subjectLabel is set', () => {
|
||||
expect(
|
||||
normalizeRecoveryPoint({
|
||||
...basePoint,
|
||||
display: { itemLabel: ' ', subjectLabel: 'SL2' },
|
||||
}),
|
||||
).toStrictEqual({
|
||||
...basePoint,
|
||||
display: { itemLabel: 'SL2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('omits itemLabel and itemType keys when both canonical and subject fields are blank', () => {
|
||||
// Exercises the falsy arms of the `...(x ? {itemLabel} : {})` spreads.
|
||||
expect(
|
||||
normalizeRecoveryPoint({
|
||||
...basePoint,
|
||||
display: { itemLabel: ' ', subjectLabel: '', itemType: '', subjectType: ' ' },
|
||||
}),
|
||||
).toStrictEqual({
|
||||
...basePoint,
|
||||
display: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecoveryItemResourceId (via normalizeRecoveryPoint)', () => {
|
||||
const basePoint = {
|
||||
id: 'p2',
|
||||
kind: 'backup' as const,
|
||||
mode: 'remote' as const,
|
||||
outcome: 'success' as const,
|
||||
};
|
||||
|
||||
it('prefers itemResourceId over subjectResourceId', () => {
|
||||
expect(
|
||||
normalizeRecoveryPoint({ ...basePoint, itemResourceId: 'a', subjectResourceId: 'b' }),
|
||||
).toStrictEqual({ ...basePoint, itemResourceId: 'a' });
|
||||
});
|
||||
|
||||
it('falls back to subjectResourceId when itemResourceId is absent', () => {
|
||||
expect(normalizeRecoveryPoint({ ...basePoint, subjectResourceId: 'b' })).toStrictEqual({
|
||||
...basePoint,
|
||||
itemResourceId: 'b',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits itemResourceId when both ids are absent', () => {
|
||||
// Branch: toTrimmedString(undefined) || toTrimmedString(undefined) -> ''.
|
||||
const result = normalizeRecoveryPoint(basePoint);
|
||||
expect(result).toStrictEqual(basePoint);
|
||||
expect('itemResourceId' in result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecoveryItemRef (via normalizeRecoveryPoint)', () => {
|
||||
const basePoint = {
|
||||
id: 'p3',
|
||||
kind: 'snapshot' as const,
|
||||
mode: 'snapshot' as const,
|
||||
outcome: 'success' as const,
|
||||
};
|
||||
|
||||
it('prefers itemRef over subjectRef', () => {
|
||||
expect(
|
||||
normalizeRecoveryPoint({
|
||||
...basePoint,
|
||||
itemRef: { type: 'item' },
|
||||
subjectRef: { type: 'subject' },
|
||||
}),
|
||||
).toStrictEqual({ ...basePoint, itemRef: { type: 'item' } });
|
||||
});
|
||||
|
||||
it('falls back to subjectRef when itemRef is absent', () => {
|
||||
expect(
|
||||
normalizeRecoveryPoint({ ...basePoint, subjectRef: { type: 'subject' } }),
|
||||
).toStrictEqual({ ...basePoint, itemRef: { type: 'subject' } });
|
||||
});
|
||||
|
||||
it('omits itemRef when both refs are absent (resolves to null)', () => {
|
||||
// Branch: value?.itemRef || value?.subjectRef || null -> null (falsy, not spread).
|
||||
const result = normalizeRecoveryPoint(basePoint);
|
||||
expect(result).toStrictEqual(basePoint);
|
||||
expect('itemRef' in result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRecoveryPoint', () => {
|
||||
it('returns a minimal point unchanged when optional fields are absent', () => {
|
||||
const minimal = {
|
||||
id: 'min',
|
||||
kind: 'snapshot' as const,
|
||||
mode: 'local' as const,
|
||||
outcome: 'failed' as const,
|
||||
};
|
||||
expect(normalizeRecoveryPoint(minimal)).toStrictEqual(minimal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRecoveryRollup', () => {
|
||||
it('returns a minimal rollup unchanged when optional fields are absent', () => {
|
||||
const minimal = { rollupId: 'r-min', lastOutcome: 'warning' as const };
|
||||
expect(normalizeRecoveryRollup(minimal)).toStrictEqual(minimal);
|
||||
});
|
||||
|
||||
it('passes a null display through unchanged', () => {
|
||||
expect(
|
||||
normalizeRecoveryRollup({ rollupId: 'r1', lastOutcome: 'success', display: null }),
|
||||
).toStrictEqual({ rollupId: 'r1', lastOutcome: 'success', display: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRecoveryMeta (via response normalizers)', () => {
|
||||
it('passes valid finite numeric meta through unchanged', () => {
|
||||
const meta = { page: 2, limit: 50, total: 7, totalPages: 1 };
|
||||
expect(normalizeRecoveryPointsResponse({ data: [], meta })).toStrictEqual({
|
||||
data: [],
|
||||
meta,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies defaults when meta is null or undefined', () => {
|
||||
const defaults = { page: 1, limit: 0, total: 0, totalPages: 1 };
|
||||
expect(
|
||||
normalizeRecoveryPointsResponse({
|
||||
data: [],
|
||||
} as unknown as Parameters<typeof normalizeRecoveryPointsResponse>[0]),
|
||||
).toStrictEqual({ data: [], meta: defaults });
|
||||
expect(
|
||||
normalizeRecoveryRollupsResponse({
|
||||
data: [],
|
||||
meta: null,
|
||||
} as unknown as Parameters<typeof normalizeRecoveryRollupsResponse>[0]),
|
||||
).toStrictEqual({ data: [], meta: defaults });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRecoveryPointsResponse', () => {
|
||||
it('coerces non-finite and non-number meta fields to defaults and non-array data to []', () => {
|
||||
expect(
|
||||
normalizeRecoveryPointsResponse({
|
||||
data: 'not-an-array',
|
||||
meta: { page: NaN, limit: Infinity, total: 'twenty', totalPages: undefined },
|
||||
} as unknown as Parameters<typeof normalizeRecoveryPointsResponse>[0]),
|
||||
).toStrictEqual({
|
||||
data: [],
|
||||
meta: { page: 1, limit: 0, total: 0, totalPages: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRecoveryRollupsResponse', () => {
|
||||
it('normalizes an array of rollups and preserves finite meta', () => {
|
||||
expect(
|
||||
normalizeRecoveryRollupsResponse({
|
||||
data: [
|
||||
{
|
||||
rollupId: 'r1',
|
||||
lastOutcome: 'success',
|
||||
providers: ['truenas'],
|
||||
},
|
||||
],
|
||||
meta: { page: 1, limit: 10, total: 1, totalPages: 1 },
|
||||
}),
|
||||
).toStrictEqual({
|
||||
data: [{ rollupId: 'r1', lastOutcome: 'success', platforms: ['truenas'] }],
|
||||
meta: { page: 1, limit: 10, total: 1, totalPages: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty data and default meta for a malformed payload', () => {
|
||||
expect(
|
||||
normalizeRecoveryRollupsResponse({
|
||||
data: null,
|
||||
meta: undefined,
|
||||
} as unknown as Parameters<typeof normalizeRecoveryRollupsResponse>[0]),
|
||||
).toStrictEqual({
|
||||
data: [],
|
||||
meta: { page: 1, limit: 0, total: 0, totalPages: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getContainerRuntimeBadgeForRuntime,
|
||||
getInfrastructurePlatformBadges,
|
||||
getInfrastructureSystemIdentityBadges,
|
||||
getInfrastructureSystemIdentitySortLabel,
|
||||
getPlatformBadge,
|
||||
getTypeBadge,
|
||||
getUnifiedSourceBadges,
|
||||
} from '@/utils/resourceBadgePresentation';
|
||||
import type { Resource } from '@/types/resource';
|
||||
|
||||
const makeResource = (overrides: Partial<Resource> = {}): Resource =>
|
||||
({
|
||||
id: 'resource-1',
|
||||
type: 'agent',
|
||||
name: 'host-1',
|
||||
displayName: 'host-1',
|
||||
platformId: 'host-1',
|
||||
platformType: 'agent',
|
||||
sourceType: 'agent',
|
||||
status: 'online',
|
||||
lastSeen: 1,
|
||||
...overrides,
|
||||
}) as Resource;
|
||||
|
||||
describe('getPlatformBadge (branch coverage)', () => {
|
||||
it('returns null when no platform type is supplied', () => {
|
||||
expect(getPlatformBadge()).toBeNull();
|
||||
expect(getPlatformBadge(undefined)).toBeNull();
|
||||
expect(getPlatformBadge('' as never)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the shared availability badge for the availability platform type', () => {
|
||||
expect(getPlatformBadge('availability')).toStrictEqual({
|
||||
label: 'Availability',
|
||||
classes:
|
||||
'inline-flex items-center rounded px-2 py-0.5 text-[10px] font-medium whitespace-nowrap bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300',
|
||||
title: 'Availability',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns shared presentation badges for non-availability platform types', () => {
|
||||
expect(getPlatformBadge('kubernetes')?.label).toBe('K8s');
|
||||
expect(getPlatformBadge('truenas')?.label).toBe('TrueNAS');
|
||||
expect(getPlatformBadge('vmware-vsphere')?.label).toBe('vSphere');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTypeBadge (branch coverage)', () => {
|
||||
it('returns null when no resource type is supplied', () => {
|
||||
expect(getTypeBadge()).toBeNull();
|
||||
expect(getTypeBadge('')).toBeNull();
|
||||
});
|
||||
|
||||
it('emits a canonical label and title for a known resource type', () => {
|
||||
const badge = getTypeBadge('host');
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge?.label).toBe('Agent');
|
||||
expect(badge?.title).toBe('agent');
|
||||
expect(badge?.classes).toContain('inline-flex');
|
||||
expect(badge?.classes).toContain('bg-orange-100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUnifiedSourceBadges via getUnifiedSourceBadges (branch coverage)', () => {
|
||||
it('maps canonical aliases through the shared platform badge presentation', () => {
|
||||
expect(
|
||||
getUnifiedSourceBadges(['kubernetes', 'vmware', 'synology-dsm']).map((b) => b.label),
|
||||
).toEqual(['K8s', 'vSphere', 'Synology']);
|
||||
});
|
||||
|
||||
it('renders the availability and generic source presentations', () => {
|
||||
expect(getUnifiedSourceBadges(['availability']).map((b) => b.label)).toEqual(['Availability']);
|
||||
expect(getUnifiedSourceBadges(['generic']).map((b) => b.label)).toEqual(['Generic']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInfrastructurePlatformBadges (branch coverage)', () => {
|
||||
it('returns an empty array when no sources normalize to a known platform', () => {
|
||||
expect(getInfrastructurePlatformBadges([])).toEqual([]);
|
||||
expect(getInfrastructurePlatformBadges(undefined)).toEqual([]);
|
||||
expect(getInfrastructurePlatformBadges(['totally-unknown-source'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps every non-agent platform source when multiple infrastructure platforms remain', () => {
|
||||
expect(
|
||||
getInfrastructurePlatformBadges(['docker', 'kubernetes']).map((b) => b.label),
|
||||
).toEqual(['Docker / Podman', 'K8s']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContainerRuntimeTone via getContainerRuntimeBadgeForRuntime (branch coverage)', () => {
|
||||
it('returns null for an empty or whitespace-only runtime', () => {
|
||||
expect(getContainerRuntimeBadgeForRuntime('')).toBeNull();
|
||||
expect(getContainerRuntimeBadgeForRuntime(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('normalizes Docker casing before selecting the docker tone', () => {
|
||||
const badge = getContainerRuntimeBadgeForRuntime('DOCKER');
|
||||
expect(badge?.label).toBe('Docker');
|
||||
expect(badge?.title).toBe('Runtime: Docker');
|
||||
expect(badge?.classes).toContain('bg-sky-100');
|
||||
});
|
||||
|
||||
it('falls back to the neutral type tone for an unrecognized runtime label', () => {
|
||||
const badge = getContainerRuntimeBadgeForRuntime('containerd');
|
||||
expect(badge?.label).toBe('containerd');
|
||||
expect(badge?.title).toBe('Runtime: containerd');
|
||||
expect(badge?.classes).toContain('bg-surface-alt');
|
||||
expect(badge?.classes).toContain('text-base-content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxmoxLxcDockerVmid via getInfrastructureSystemIdentityBadges (branch coverage)', () => {
|
||||
it('surfaces the trailing VMID when the host source id has multiple colon segments', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
sourceType: 'agent',
|
||||
docker: {
|
||||
hostSourceId: 'proxmox-lxc-docker:pve-a:node-a:250',
|
||||
hostname: 'svc',
|
||||
runtime: 'docker',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['LXC']);
|
||||
expect(badges[0]?.title).toBe('Docker running inside Proxmox LXC 250');
|
||||
});
|
||||
|
||||
it('omits the VMID from the tooltip when the trailing segment is zero', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
sourceType: 'agent',
|
||||
docker: {
|
||||
hostSourceId: 'proxmox-lxc-docker:0',
|
||||
hostname: 'svc',
|
||||
runtime: 'docker',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['LXC']);
|
||||
expect(badges[0]?.title).toBe('Docker running inside a Proxmox LXC');
|
||||
});
|
||||
|
||||
it('omits the VMID from the tooltip when the trailing segment is a negative integer', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
sourceType: 'agent',
|
||||
docker: {
|
||||
hostSourceId: 'proxmox-lxc-docker:-5',
|
||||
hostname: 'svc',
|
||||
runtime: 'docker',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['LXC']);
|
||||
expect(badges[0]?.title).toBe('Docker running inside a Proxmox LXC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailabilitySystemIdentityBadge via getInfrastructureSystemIdentityBadges (branch coverage)', () => {
|
||||
it('maps http and https protocols to uppercased labels with port suffixes', () => {
|
||||
const http = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'network-endpoint',
|
||||
platformType: 'generic',
|
||||
sourceType: 'api',
|
||||
platformData: {
|
||||
sources: ['availability'],
|
||||
availability: { protocol: 'http', address: 'health.local', port: 80 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(http.map((b) => b.label)).toEqual(['HTTP']);
|
||||
expect(http[0]?.title).toBe('HTTP availability probe health.local:80');
|
||||
|
||||
const https = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'network-endpoint',
|
||||
platformType: 'generic',
|
||||
sourceType: 'api',
|
||||
platformData: {
|
||||
sources: ['availability'],
|
||||
availability: { protocol: 'https', address: 'secure.local' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(https.map((b) => b.label)).toEqual(['HTTPS']);
|
||||
expect(https[0]?.title).toBe('HTTPS availability probe secure.local');
|
||||
});
|
||||
|
||||
it('uppercases an unrecognized non-empty protocol', () => {
|
||||
const snmp = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'network-endpoint',
|
||||
platformType: 'generic',
|
||||
sourceType: 'api',
|
||||
platformData: {
|
||||
sources: ['availability'],
|
||||
availability: { protocol: 'snmp', address: 'switch-1' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(snmp.map((b) => b.label)).toEqual(['SNMP']);
|
||||
expect(snmp[0]?.title).toBe('SNMP availability probe switch-1');
|
||||
});
|
||||
|
||||
it('drops the port suffix when the port is zero and falls back to the Probe label when protocol is empty', () => {
|
||||
const tcp = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'network-endpoint',
|
||||
platformType: 'generic',
|
||||
sourceType: 'api',
|
||||
platformData: {
|
||||
sources: ['availability'],
|
||||
availability: { protocol: 'tcp', address: 'host-a', port: 0 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(tcp.map((b) => b.label)).toEqual(['TCP']);
|
||||
expect(tcp[0]?.title).toBe('TCP availability probe host-a');
|
||||
});
|
||||
|
||||
it('treats a raw availability source as an availability endpoint even without a facet', () => {
|
||||
const probe = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
sourceType: 'api',
|
||||
sources: ['availability'],
|
||||
platformData: { sources: ['availability'] },
|
||||
}),
|
||||
);
|
||||
expect(probe.map((b) => b.label)).toEqual(['Probe']);
|
||||
expect(probe[0]?.title).toBe('Probe availability probe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStorageSystemIdentityBadge via getInfrastructureSystemIdentityBadges (branch coverage)', () => {
|
||||
it('appends a TrueNAS storage version and uses storageType in the title when topology is absent', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'storage',
|
||||
platformType: 'truenas',
|
||||
sourceType: 'api',
|
||||
sources: ['truenas'],
|
||||
platformData: {
|
||||
sources: ['truenas'],
|
||||
storage: { platform: 'truenas', type: 'zfs-pool' },
|
||||
truenas: { version: '24.10.0' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['TrueNAS 24.10.0']);
|
||||
expect(badges[0]?.title).toBe('TrueNAS zfs-pool 24.10.0');
|
||||
});
|
||||
|
||||
it('resolves the unraid storage version from the agent osVersion', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'storage',
|
||||
platformType: 'agent',
|
||||
sourceType: 'agent',
|
||||
sources: ['agent'],
|
||||
platformData: {
|
||||
sources: ['agent'],
|
||||
storage: { platform: 'unraid' },
|
||||
agent: { hostProfile: 'unraid', osVersion: '7.0.0' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Unraid 7.0.0']);
|
||||
});
|
||||
|
||||
it('resolves the docker version arm when storage is owned by a docker platform', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'storage',
|
||||
platformType: 'agent',
|
||||
sourceType: 'agent',
|
||||
sources: ['agent'],
|
||||
platformData: {
|
||||
sources: ['agent'],
|
||||
storage: { platform: 'docker' },
|
||||
docker: { dockerVersion: '27.0.0' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Docker / Podman 27.0.0']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentSystemIdentityBadge via getInfrastructureSystemIdentityBadges (branch coverage)', () => {
|
||||
it('uses the known platform source branch when the reported OS text matches a governed platform token', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
sourceType: 'agent',
|
||||
sources: ['agent'],
|
||||
platformData: {
|
||||
sources: ['agent'],
|
||||
agent: { platform: 'linux', osName: 'VMware ESXi' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['vSphere']);
|
||||
expect(badges[0]?.title).toBe('VMware ESXi');
|
||||
});
|
||||
|
||||
it('keeps the agent host-profile identity ahead of a co-reported docker runtime', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
sourceType: 'hybrid',
|
||||
sources: ['agent'],
|
||||
platformData: {
|
||||
sources: ['agent', 'docker'],
|
||||
docker: { runtime: 'docker' },
|
||||
agent: { platform: 'linux', hostProfile: 'unraid', osName: 'Unraid', osVersion: '7.3.0' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Unraid 7.3.0']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDockerHostOsIdentityBadge via getInfrastructureSystemIdentityBadges (branch coverage)', () => {
|
||||
it('derives a host OS label from docker metadata when no agent identity is present', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
sourceType: 'api',
|
||||
platformData: { sources: ['docker'] },
|
||||
docker: { os: 'Ubuntu 24.04.2 LTS' },
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Ubuntu']);
|
||||
expect(badges[0]?.title).toBe('Ubuntu 24.04.2 LTS');
|
||||
});
|
||||
|
||||
it('maps a docker OS string that matches a governed platform to the shared platform badge', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
sourceType: 'api',
|
||||
platformData: { sources: ['docker'] },
|
||||
docker: { os: 'TrueNAS SCALE' },
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['TrueNAS']);
|
||||
});
|
||||
|
||||
it('falls back to the docker runtime badge when the docker OS string is unrecognized', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
sourceType: 'api',
|
||||
platformData: { sources: ['docker'] },
|
||||
docker: { os: 'acme-custom-os' },
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Docker / Podman']);
|
||||
});
|
||||
|
||||
it('falls back to the docker runtime badge when docker metadata has no os field', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'docker-host',
|
||||
platformType: 'docker',
|
||||
sourceType: 'api',
|
||||
platformData: { sources: ['docker'] },
|
||||
docker: {},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Docker / Podman']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVersionedSourceBadge via getInfrastructureSystemIdentityBadges (branch coverage)', () => {
|
||||
it('unwraps a Proxmox pve-manager version wrapper into a dotted version segment', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'proxmox-pve',
|
||||
sourceType: 'api',
|
||||
sources: ['proxmox'],
|
||||
platformData: {
|
||||
sources: ['proxmox'],
|
||||
proxmox: { pveVersion: 'pve-manager/9.1.9/ee7bad0a3d1546c9' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['PVE 9.1.9']);
|
||||
});
|
||||
|
||||
it('drops an unknown sentinel version instead of appending it', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'proxmox-pve',
|
||||
sourceType: 'api',
|
||||
sources: ['proxmox'],
|
||||
platformData: {
|
||||
sources: ['proxmox'],
|
||||
proxmox: { pveVersion: 'unknown' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['PVE']);
|
||||
});
|
||||
|
||||
it('versions PBS and PMG identities from their respective facet versions', () => {
|
||||
const pbs = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'pbs',
|
||||
platformType: 'proxmox-pbs',
|
||||
sourceType: 'api',
|
||||
sources: ['pbs'],
|
||||
platformData: { sources: ['pbs'], pbs: { version: '3.3.2' } },
|
||||
}),
|
||||
);
|
||||
expect(pbs.map((b) => b.label)).toEqual(['PBS 3.3.2']);
|
||||
|
||||
const pmg = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'pmg',
|
||||
platformType: 'proxmox-pmg',
|
||||
sourceType: 'api',
|
||||
sources: ['pmg'],
|
||||
platformData: { sources: ['pmg'], pmg: { version: '9.1.2' } },
|
||||
}),
|
||||
);
|
||||
expect(pmg.map((b) => b.label)).toEqual(['PMG 9.1.2']);
|
||||
});
|
||||
|
||||
it('versions TrueNAS, vSphere and K8s identities from their facet versions', () => {
|
||||
const truenas = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'truenas',
|
||||
sourceType: 'api',
|
||||
sources: ['truenas'],
|
||||
platformData: { sources: ['truenas'], truenas: { version: '24.10.0' } },
|
||||
}),
|
||||
);
|
||||
expect(truenas.map((b) => b.label)).toEqual(['TrueNAS 24.10.0']);
|
||||
|
||||
const vsphere = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'vm',
|
||||
platformType: 'vmware-vsphere',
|
||||
sourceType: 'api',
|
||||
sources: ['vmware'],
|
||||
platformData: { sources: ['vmware'], vmware: { version: '8.0.3' } },
|
||||
}),
|
||||
);
|
||||
expect(vsphere.map((b) => b.label)).toEqual(['vSphere 8.0.3']);
|
||||
|
||||
const k8s = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'k8s-cluster',
|
||||
platformType: 'kubernetes',
|
||||
sourceType: 'api',
|
||||
sources: ['kubernetes'],
|
||||
platformData: { sources: ['kubernetes'], kubernetes: { version: '1.31.0' } },
|
||||
}),
|
||||
);
|
||||
expect(k8s.map((b) => b.label)).toEqual(['K8s 1.31.0']);
|
||||
});
|
||||
|
||||
it('versions a presentation-only platform through the default version-resolution arm', () => {
|
||||
const badges = getInfrastructureSystemIdentityBadges(
|
||||
makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
sourceType: 'api',
|
||||
sources: ['synology-dsm'],
|
||||
platformData: {
|
||||
sources: ['synology-dsm'],
|
||||
'synology-dsm': { version: '7.2.1' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(badges.map((b) => b.label)).toEqual(['Synology 7.2.1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInfrastructureSystemIdentityBadges and getInfrastructureSystemIdentitySortLabel (branch coverage)', () => {
|
||||
it('falls back to the platform badge for a bare agent resource with no other identity', () => {
|
||||
const resource = makeResource({
|
||||
type: 'agent',
|
||||
platformType: 'agent',
|
||||
sourceType: 'agent',
|
||||
sources: [],
|
||||
});
|
||||
expect(getInfrastructureSystemIdentityBadges(resource).map((b) => b.label)).toEqual(['Agent']);
|
||||
expect(getInfrastructureSystemIdentitySortLabel(resource)).toBe('Agent');
|
||||
});
|
||||
|
||||
it('returns an empty badge set and empty sort label when nothing resolves', () => {
|
||||
const resource = makeResource({
|
||||
id: 'lonely',
|
||||
name: 'lonely',
|
||||
type: 'vm',
|
||||
platformType: undefined,
|
||||
sourceType: 'api',
|
||||
sources: [],
|
||||
});
|
||||
expect(getInfrastructureSystemIdentityBadges(resource)).toEqual([]);
|
||||
expect(getInfrastructureSystemIdentitySortLabel(resource)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,421 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type {
|
||||
ResourceRedactionHint,
|
||||
ResourceRoutingScope,
|
||||
ResourceSensitivity,
|
||||
} from '@/types/resource';
|
||||
import {
|
||||
getResourcePolicyDisplayLabel,
|
||||
getResourcePolicyGovernedSummary,
|
||||
getResourcePolicyTableBadges,
|
||||
getResourceRedactionHintLabel,
|
||||
getResourceRoutingScopeLabel,
|
||||
getResourceSensitivityLabel,
|
||||
hasDefaultResourcePolicyPosture,
|
||||
shouldShowResourceAlternateName,
|
||||
} from '@/utils/resourcePolicyPresentation';
|
||||
import type { ResourcePolicyDisplayResource } from '@/utils/resourcePolicyPresentation';
|
||||
|
||||
/**
|
||||
* Branch-coverage supplement for `resourcePolicyPresentation.ts`.
|
||||
*
|
||||
* The existing sibling test (`resourcePolicyPresentation.test.ts`) exercises the
|
||||
* happy paths. This file targets the defensive / fallback arms of each named
|
||||
* function: undefined inputs, non-blocking postures, empty/whitespace strings,
|
||||
* the `?? hint` redaction fallback, the `?? 0` redact-length arm, the
|
||||
* sensitivity-vs-routing primary selection, and the concise-summary status
|
||||
* absence / all-empty-parts / no-semicolon branches.
|
||||
*
|
||||
* NOTE: `getConciseGovernedDisplaySummary` is a module-private (non-exported)
|
||||
* helper, so it is covered indirectly through `getResourcePolicyDisplayLabel`,
|
||||
* which is the only call site — matching the existing test's approach.
|
||||
*/
|
||||
|
||||
const governedPolicy = (overrides: {
|
||||
sensitivity?: ResourceSensitivity;
|
||||
scope?: ResourceRoutingScope;
|
||||
redact?: ResourceRedactionHint[];
|
||||
} = {}): NonNullable<ResourcePolicyDisplayResource['policy']> => ({
|
||||
sensitivity: overrides.sensitivity ?? 'restricted',
|
||||
routing: {
|
||||
scope: overrides.scope ?? 'local-only',
|
||||
redact: overrides.redact,
|
||||
},
|
||||
});
|
||||
|
||||
// The static type marks `name` and `displayName` as required, but the source
|
||||
// functions defendively optional-chain (`?.trim()`) on both. This helper lets
|
||||
// us hand in deliberately-partial resources to exercise those fallback arms.
|
||||
const asResource = (
|
||||
r: Partial<ResourcePolicyDisplayResource>,
|
||||
): ResourcePolicyDisplayResource => r as unknown as ResourcePolicyDisplayResource;
|
||||
|
||||
describe('getResourceSensitivityLabel — branch coverage', () => {
|
||||
it('returns the canonical label for every known sensitivity', () => {
|
||||
expect(getResourceSensitivityLabel('public')).toBe('Public');
|
||||
expect(getResourceSensitivityLabel('internal')).toBe('Internal');
|
||||
expect(getResourceSensitivityLabel('sensitive')).toBe('Sensitive');
|
||||
expect(getResourceSensitivityLabel('restricted')).toBe('Restricted');
|
||||
});
|
||||
|
||||
it('returns "Unclassified" when no sensitivity is supplied (falsy arm)', () => {
|
||||
expect(getResourceSensitivityLabel(undefined)).toBe('Unclassified');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResourceRoutingScopeLabel — branch coverage', () => {
|
||||
it('returns the canonical label for every known routing scope', () => {
|
||||
expect(getResourceRoutingScopeLabel('cloud-summary')).toBe('Cloud Summary');
|
||||
expect(getResourceRoutingScopeLabel('local-first')).toBe('Local First');
|
||||
expect(getResourceRoutingScopeLabel('local-only')).toBe('Local Only');
|
||||
});
|
||||
|
||||
it('returns "Unrouted" when no scope is supplied (falsy arm)', () => {
|
||||
expect(getResourceRoutingScopeLabel(undefined)).toBe('Unrouted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResourceRedactionHintLabel — branch coverage', () => {
|
||||
it('returns the canonical label for every known redaction hint', () => {
|
||||
expect(getResourceRedactionHintLabel('hostname')).toBe('Hostname');
|
||||
expect(getResourceRedactionHintLabel('ip-address')).toBe('IP Address');
|
||||
expect(getResourceRedactionHintLabel('platform-id')).toBe('Platform ID');
|
||||
expect(getResourceRedactionHintLabel('alias')).toBe('Alias');
|
||||
expect(getResourceRedactionHintLabel('path')).toBe('Path');
|
||||
});
|
||||
|
||||
it('returns "Unclassified" when no hint is supplied (falsy arm)', () => {
|
||||
expect(getResourceRedactionHintLabel(undefined)).toBe('Unclassified');
|
||||
});
|
||||
|
||||
it('falls back to the raw hint string when the hint is not in the label map (?? hint arm)', () => {
|
||||
expect(
|
||||
getResourceRedactionHintLabel('custom-redaction' as ResourceRedactionHint),
|
||||
).toBe('custom-redaction');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasDefaultResourcePolicyPosture — branch coverage', () => {
|
||||
it('returns false when no policy is supplied (short-circuit before fields)', () => {
|
||||
expect(hasDefaultResourcePolicyPosture(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when sensitivity is not "internal"', () => {
|
||||
expect(
|
||||
hasDefaultResourcePolicyPosture({
|
||||
sensitivity: 'restricted',
|
||||
routing: { scope: 'cloud-summary' },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when scope is not "cloud-summary" even with internal sensitivity', () => {
|
||||
expect(
|
||||
hasDefaultResourcePolicyPosture({
|
||||
sensitivity: 'internal',
|
||||
routing: { scope: 'local-first' },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when redact is a non-empty array (exercises redact?.length with defined array)', () => {
|
||||
expect(
|
||||
hasDefaultResourcePolicyPosture({
|
||||
sensitivity: 'internal',
|
||||
routing: { scope: 'cloud-summary', redact: ['hostname'] },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when redact is an empty array (defined but length 0)', () => {
|
||||
expect(
|
||||
hasDefaultResourcePolicyPosture({
|
||||
sensitivity: 'internal',
|
||||
routing: { scope: 'cloud-summary', redact: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for the canonical default posture (redact undefined hits ?? 0)', () => {
|
||||
expect(
|
||||
hasDefaultResourcePolicyPosture({
|
||||
sensitivity: 'internal',
|
||||
routing: { scope: 'cloud-summary' },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResourcePolicyTableBadges — branch coverage', () => {
|
||||
it('returns an empty array when no policy is supplied', () => {
|
||||
expect(getResourcePolicyTableBadges(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array for a non-blocking public posture', () => {
|
||||
expect(
|
||||
getResourcePolicyTableBadges({
|
||||
sensitivity: 'public',
|
||||
routing: { scope: 'cloud-summary' },
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('uses the sensitivity badge as primary when scope is not local-only (restricted + cloud-summary)', () => {
|
||||
const badges = getResourcePolicyTableBadges({
|
||||
sensitivity: 'restricted',
|
||||
routing: { scope: 'cloud-summary' },
|
||||
});
|
||||
expect(badges).toHaveLength(1);
|
||||
expect(badges[0]?.label).toBe('Restricted');
|
||||
// No redactions -> redactionTitle is undefined and filtered out of the title.
|
||||
expect(badges[0]?.title).toBe(
|
||||
'Restricted: Resource data is tightly restricted and requires guarded handling. Cloud Summary: This resource may use cloud summarization within policy limits.',
|
||||
);
|
||||
expect(badges[0]?.title).not.toContain('Redacts');
|
||||
});
|
||||
|
||||
it('uses the sensitivity badge as primary when restricted with local-first scope and redactions', () => {
|
||||
const badges = getResourcePolicyTableBadges({
|
||||
sensitivity: 'restricted',
|
||||
routing: { scope: 'local-first', redact: ['hostname', 'path'] },
|
||||
});
|
||||
expect(badges).toHaveLength(1);
|
||||
expect(badges[0]?.label).toBe('Restricted');
|
||||
expect(badges[0]?.title).toContain('Local First');
|
||||
expect(badges[0]?.title).toContain('Redacts Hostname, Path.');
|
||||
});
|
||||
|
||||
it('uses the routing badge as primary when scope is local-only even with non-restricted sensitivity', () => {
|
||||
const badges = getResourcePolicyTableBadges({
|
||||
sensitivity: 'sensitive',
|
||||
routing: { scope: 'local-only', redact: ['alias'] },
|
||||
});
|
||||
expect(badges).toHaveLength(1);
|
||||
expect(badges[0]?.label).toBe('Local Only');
|
||||
expect(badges[0]?.title).toContain('Sensitive');
|
||||
expect(badges[0]?.title).toContain('Redacts Alias.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResourcePolicyGovernedSummary — branch coverage', () => {
|
||||
it('returns empty string when no resource is supplied', () => {
|
||||
expect(getResourcePolicyGovernedSummary(undefined)).toBe('');
|
||||
expect(getResourcePolicyGovernedSummary(null)).toBe('');
|
||||
});
|
||||
|
||||
it('falls back to displayName/name when policy is absent', () => {
|
||||
expect(
|
||||
getResourcePolicyGovernedSummary({
|
||||
name: 'host-1',
|
||||
displayName: 'Host One',
|
||||
}),
|
||||
).toBe('Host One');
|
||||
});
|
||||
|
||||
it('falls back to name when displayName is absent and policy is not governed', () => {
|
||||
expect(
|
||||
getResourcePolicyGovernedSummary(
|
||||
asResource({
|
||||
name: 'host-1',
|
||||
policy: { sensitivity: 'internal', routing: { scope: 'cloud-summary' } },
|
||||
}),
|
||||
),
|
||||
).toBe('host-1');
|
||||
});
|
||||
|
||||
it('returns empty string when neither displayName nor name is present and policy is not governed', () => {
|
||||
expect(
|
||||
getResourcePolicyGovernedSummary(
|
||||
asResource({
|
||||
name: '',
|
||||
displayName: ' ',
|
||||
policy: { sensitivity: 'internal', routing: { scope: 'cloud-summary' } },
|
||||
}),
|
||||
),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('returns the trimmed aiSafeSummary for a governed resource', () => {
|
||||
expect(
|
||||
getResourcePolicyGovernedSummary(
|
||||
asResource({
|
||||
name: 'n',
|
||||
policy: governedPolicy(),
|
||||
aiSafeSummary: ' governed summary text ',
|
||||
}),
|
||||
),
|
||||
).toBe('governed summary text');
|
||||
});
|
||||
|
||||
it('returns "redacted by policy" for a governed resource with an empty aiSafeSummary', () => {
|
||||
expect(
|
||||
getResourcePolicyGovernedSummary(
|
||||
asResource({
|
||||
name: 'n',
|
||||
policy: governedPolicy(),
|
||||
aiSafeSummary: ' ',
|
||||
}),
|
||||
),
|
||||
).toBe('redacted by policy');
|
||||
});
|
||||
|
||||
it('returns "redacted by policy" for a governed resource with no aiSafeSummary at all', () => {
|
||||
expect(
|
||||
getResourcePolicyGovernedSummary(
|
||||
asResource({
|
||||
name: 'n',
|
||||
policy: governedPolicy(),
|
||||
}),
|
||||
),
|
||||
).toBe('redacted by policy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResourcePolicyDisplayLabel — branch coverage', () => {
|
||||
it('returns empty string when no resource is supplied', () => {
|
||||
expect(getResourcePolicyDisplayLabel(undefined)).toBe('');
|
||||
expect(getResourcePolicyDisplayLabel(null)).toBe('');
|
||||
});
|
||||
|
||||
it('falls back to displayName when policy is absent', () => {
|
||||
expect(
|
||||
getResourcePolicyDisplayLabel({
|
||||
name: 'host-1',
|
||||
displayName: 'Host One',
|
||||
}),
|
||||
).toBe('Host One');
|
||||
});
|
||||
|
||||
it('falls back to name when displayName is absent and policy is absent', () => {
|
||||
expect(getResourcePolicyDisplayLabel(asResource({ name: 'host-1' }))).toBe('host-1');
|
||||
});
|
||||
|
||||
it('returns empty string when neither name nor displayName is present and policy is absent', () => {
|
||||
expect(getResourcePolicyDisplayLabel({ name: ' ', displayName: '' })).toBe('');
|
||||
});
|
||||
|
||||
it('falls back to displayName/name when policy is present but not governed', () => {
|
||||
expect(
|
||||
getResourcePolicyDisplayLabel({
|
||||
name: 'host-1',
|
||||
displayName: 'Host One',
|
||||
policy: { sensitivity: 'public', routing: { scope: 'cloud-summary' } },
|
||||
}),
|
||||
).toBe('Host One');
|
||||
});
|
||||
|
||||
// The following cases drive the private getConciseGovernedDisplaySummary helper.
|
||||
|
||||
it('concise: returns the trimmed summary unchanged when it has no semicolon', () => {
|
||||
expect(
|
||||
getResourcePolicyDisplayLabel(
|
||||
asResource({
|
||||
name: 'n',
|
||||
policy: governedPolicy(),
|
||||
aiSafeSummary: ' plain governed label ',
|
||||
}),
|
||||
),
|
||||
).toBe('plain governed label');
|
||||
});
|
||||
|
||||
it('concise: strips a trailing " resource" suffix from the base label and appends a status', () => {
|
||||
expect(
|
||||
getResourcePolicyDisplayLabel(
|
||||
asResource({
|
||||
name: 'n',
|
||||
policy: governedPolicy(),
|
||||
aiSafeSummary: 'backup server resource; status online; sources pbs',
|
||||
}),
|
||||
),
|
||||
).toBe('backup server (online)');
|
||||
});
|
||||
|
||||
it('concise: returns only the base label when no status part is present', () => {
|
||||
expect(
|
||||
getResourcePolicyDisplayLabel(
|
||||
asResource({
|
||||
name: 'n',
|
||||
policy: governedPolicy(),
|
||||
aiSafeSummary: 'web node; count 5; sources api',
|
||||
}),
|
||||
),
|
||||
).toBe('web node');
|
||||
});
|
||||
|
||||
it('concise: returns empty string when every semicolon-delimited part is empty', () => {
|
||||
expect(
|
||||
getResourcePolicyDisplayLabel(
|
||||
asResource({
|
||||
name: 'n',
|
||||
policy: governedPolicy(),
|
||||
// governed summary trims to "; ;", which has no non-empty parts -> ''
|
||||
aiSafeSummary: ' ; ; ',
|
||||
}),
|
||||
),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('concise: keeps the base label verbatim when the " resource" regex does not match', () => {
|
||||
expect(
|
||||
getResourcePolicyDisplayLabel(
|
||||
asResource({
|
||||
name: 'n',
|
||||
policy: governedPolicy(),
|
||||
aiSafeSummary: 'storage array; status degraded',
|
||||
}),
|
||||
),
|
||||
).toBe('storage array (degraded)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowResourceAlternateName — branch coverage', () => {
|
||||
it('returns false when no resource is supplied', () => {
|
||||
expect(shouldShowResourceAlternateName(undefined)).toBe(false);
|
||||
expect(shouldShowResourceAlternateName(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when displayName is absent', () => {
|
||||
expect(shouldShowResourceAlternateName(asResource({ name: 'host-1' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when name is absent', () => {
|
||||
expect(shouldShowResourceAlternateName(asResource({ displayName: 'Host One' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a whitespace-only displayName as present (guard does not trim) and thus differing from name', () => {
|
||||
// The guard `!resource?.displayName` does not trim, so ' ' is truthy and
|
||||
// passes; then ''.toLowerCase() !== 'host-1' -> true.
|
||||
expect(
|
||||
shouldShowResourceAlternateName({ name: 'host-1', displayName: ' ' }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the policy requires governed handling', () => {
|
||||
expect(
|
||||
shouldShowResourceAlternateName({
|
||||
name: 'host-1',
|
||||
displayName: 'Host One',
|
||||
policy: governedPolicy(),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when displayName and name match case-insensitively after trimming', () => {
|
||||
expect(
|
||||
shouldShowResourceAlternateName({
|
||||
name: 'HOST-1',
|
||||
displayName: ' host-1 ',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when displayName and name differ', () => {
|
||||
expect(
|
||||
shouldShowResourceAlternateName({
|
||||
name: 'host-1',
|
||||
displayName: 'Primary Node',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Branch-coverage tests for status.ts — second pass.
|
||||
*
|
||||
* Focused on branches of the named indicator/label helpers that the sibling
|
||||
* status.test.ts does not yet reach: the `?? `/`||` coalescing arms, the
|
||||
* `connection || status` label-priority ternaries, the unknown/empty fallthrough
|
||||
* arms, and the badge-tone class table lookup (including its `|| muted` guard).
|
||||
*
|
||||
* Fixtures use `Partial<Node>`/`Partial<...>` exactly like the public types the
|
||||
* functions accept; a single deliberately-malformed badge-variant input is cast
|
||||
* via `as unknown as StatusIndicatorVariant` to exercise the defensive `||`.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { StatusIndicatorVariant } from '@/utils/status';
|
||||
import {
|
||||
formatStatusLabel,
|
||||
getCanonicalStatusLabel,
|
||||
getStatusIndicatorBadgeToneClasses,
|
||||
isNodeOnline,
|
||||
getNodeStatusIndicator,
|
||||
getDockerHostStatusIndicator,
|
||||
getDockerContainerStatusIndicator,
|
||||
getDockerServiceStatusIndicator,
|
||||
getAgentStatusIndicator,
|
||||
getPBSStatusIndicator,
|
||||
getReplicationJobStatusIndicator,
|
||||
} from '@/utils/status';
|
||||
|
||||
describe('formatStatusLabel (branch coverage)', () => {
|
||||
it('returns the fallback when value is null', () => {
|
||||
// `if (!value)` arm — null is falsy.
|
||||
expect(formatStatusLabel(null, 'N/A')).toBe('N/A');
|
||||
});
|
||||
|
||||
it('returns the fallback for a whitespace-only value (post-trim guard)', () => {
|
||||
// `if (!normalized)` arm — trim() yields '' which is falsy.
|
||||
expect(formatStatusLabel(' ', 'Unavailable')).toBe('Unavailable');
|
||||
});
|
||||
|
||||
it('uses the default "Unknown" fallback when none is provided for empty input', () => {
|
||||
expect(formatStatusLabel('')).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('capitalizes the first character without lowercasing the rest of a multi-word value', () => {
|
||||
// capitalize arm: only charAt(0).toUpperCase() is applied; the tail keeps case.
|
||||
expect(formatStatusLabel('sync IN progress')).toBe('Sync IN progress');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCanonicalStatusLabel (branch coverage)', () => {
|
||||
it('returns the default fallback for undefined and the custom fallback for whitespace', () => {
|
||||
// `if (!normalized)` arm via normalize('') on undefined and whitespace.
|
||||
expect(getCanonicalStatusLabel(undefined)).toBe('Unknown');
|
||||
expect(getCanonicalStatusLabel(' ', 'Pending')).toBe('Pending');
|
||||
});
|
||||
|
||||
it('resolves every canonical label in STATUS_LABELS through the lowercase lookup', () => {
|
||||
// STATUS_LABELS[normalized] hit arm, including mixed-case normalization.
|
||||
expect(getCanonicalStatusLabel('ONLINE')).toBe('Online');
|
||||
expect(getCanonicalStatusLabel('Degraded')).toBe('Degraded');
|
||||
expect(getCanonicalStatusLabel('PAUSED')).toBe('Paused');
|
||||
expect(getCanonicalStatusLabel('Stopped')).toBe('Stopped');
|
||||
expect(getCanonicalStatusLabel('UNKNOWN')).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('falls back to the trimmed raw value (original case preserved) when the status is not canonical', () => {
|
||||
// `|| raw` arm — `raw` is the pre-lowercase trim(), so case is preserved.
|
||||
expect(getCanonicalStatusLabel('CustomState')).toBe('CustomState');
|
||||
expect(getCanonicalStatusLabel('waiting-for-quorum')).toBe('waiting-for-quorum');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatusIndicatorBadgeToneClasses (branch coverage)', () => {
|
||||
it('returns the exact tone class string for every declared variant', () => {
|
||||
// Direct table-hit arm for all five variants, with full string assertion
|
||||
// (the sibling test only uses toContain for four of them).
|
||||
expect(getStatusIndicatorBadgeToneClasses('success')).toBe(
|
||||
'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||
);
|
||||
expect(getStatusIndicatorBadgeToneClasses('warning')).toBe(
|
||||
'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
|
||||
);
|
||||
expect(getStatusIndicatorBadgeToneClasses('danger')).toBe(
|
||||
'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||
);
|
||||
expect(getStatusIndicatorBadgeToneClasses('info')).toBe(
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||
);
|
||||
expect(getStatusIndicatorBadgeToneClasses('muted')).toBe(
|
||||
'bg-surface-alt text-base-content',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the muted tone class for a variant not present in the table', () => {
|
||||
// `|| STATUS_INDICATOR_BADGE_TONE_CLASSES.muted` defensive arm — driven by a
|
||||
// deliberately-malformed variant cast to satisfy the nominal type.
|
||||
const invalid = 'chartreuse' as unknown as StatusIndicatorVariant;
|
||||
expect(getStatusIndicatorBadgeToneClasses(invalid)).toBe(
|
||||
'bg-surface-alt text-base-content',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNodeOnline (branch coverage)', () => {
|
||||
it('treats an undefined uptime as zero via the `?? 0` coalescing (returns false)', () => {
|
||||
// `(node.uptime ?? 0) <= 0` arm when uptime is absent.
|
||||
expect(isNodeOnline({ status: 'online' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when connectionHealth is absent (normalize("") is neither offline nor error)', () => {
|
||||
// Happy-path fall-through: connection is undefined -> normalize('') -> ''.
|
||||
expect(isNodeOnline({ status: 'online', uptime: 1000, connectionHealth: undefined })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes connectionHealth case-insensitively before the offline/error check', () => {
|
||||
// normalize() lowercases, so 'OFFLINE' / 'ERROR' hit the reject arm.
|
||||
expect(isNodeOnline({ status: 'online', uptime: 1000, connectionHealth: 'OFFLINE' })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isNodeOnline({ status: 'online', uptime: 1000, connectionHealth: 'Error' })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a whitespace-only connectionHealth neither matches offline nor error, but is still treated as connected', () => {
|
||||
// ' ' normalizes to '' — not rejected, so an otherwise-healthy node is online.
|
||||
expect(isNodeOnline({ status: 'online', uptime: 1000, connectionHealth: ' ' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeStatusIndicator (branch coverage)', () => {
|
||||
it('flags danger from an offline connectionHealth even when status is online', () => {
|
||||
// OFFLINE_HEALTH_STATUSES.has(connection) arm; label prefers connection.
|
||||
const result = getNodeStatusIndicator({
|
||||
status: 'online',
|
||||
uptime: 1000,
|
||||
connectionHealth: 'disconnected',
|
||||
});
|
||||
expect(result).toEqual({ variant: 'danger', label: 'Disconnected' });
|
||||
});
|
||||
|
||||
it('prefers connection over status when both are offline for the danger label', () => {
|
||||
// `formatStatusLabel(connection || status, 'Offline')` — connection wins.
|
||||
const result = getNodeStatusIndicator({
|
||||
status: 'offline',
|
||||
uptime: 0,
|
||||
connectionHealth: 'timeout',
|
||||
});
|
||||
expect(result).toEqual({ variant: 'danger', label: 'Timeout' });
|
||||
});
|
||||
|
||||
it('falls back to the status for the danger label when connection is absent', () => {
|
||||
// connection '' -> `'' || status` -> status drives the label.
|
||||
const result = getNodeStatusIndicator({ status: 'unreachable', uptime: 1000 });
|
||||
expect(result).toEqual({ variant: 'danger', label: 'Unreachable' });
|
||||
});
|
||||
|
||||
it('flags warning from a degraded connectionHealth that is not in the offline set', () => {
|
||||
// DEGRADED_HEALTH_STATUSES.has(connection) arm; label prefers connection.
|
||||
const result = getNodeStatusIndicator({
|
||||
status: 'online',
|
||||
uptime: 1000,
|
||||
connectionHealth: 'syncing',
|
||||
});
|
||||
expect(result).toEqual({ variant: 'warning', label: 'Syncing' });
|
||||
});
|
||||
|
||||
it('returns muted default indicator when status is neither offline, degraded, nor online', () => {
|
||||
// Final `return defaultIndicator` arm — 'paused' is in no health-status set
|
||||
// and isNodeOnline is false because status !== 'online'.
|
||||
const result = getNodeStatusIndicator({ status: 'paused', uptime: 1000 });
|
||||
expect(result).toEqual({ variant: 'muted', label: 'Unknown' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDockerHostStatusIndicator (branch coverage)', () => {
|
||||
it('returns warning for a degraded status via the host object', () => {
|
||||
// DEGRADED_HEALTH_STATUSES.has(status) arm.
|
||||
expect(getDockerHostStatusIndicator({ status: 'degraded' })).toEqual({
|
||||
variant: 'warning',
|
||||
label: 'Degraded',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a muted indicator with a formatted label for an unknown non-empty status string', () => {
|
||||
// `status ? { muted, label }` truthy arm — 'paused' is in no status set and
|
||||
// not a connected-health status, so it falls to the muted-with-label branch.
|
||||
expect(getDockerHostStatusIndicator('paused')).toEqual({
|
||||
variant: 'muted',
|
||||
label: 'Paused',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the muted default indicator when no status is available', () => {
|
||||
// `: defaultIndicator` arm — empty normalized status is falsy.
|
||||
expect(getDockerHostStatusIndicator(undefined)).toEqual({
|
||||
variant: 'muted',
|
||||
label: 'Unknown',
|
||||
});
|
||||
expect(getDockerHostStatusIndicator({ status: undefined })).toEqual({
|
||||
variant: 'muted',
|
||||
label: 'Unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDockerContainerStatusIndicator (branch coverage)', () => {
|
||||
it('falls to the warning branch when a running container has a non-healthy, non-unhealthy health', () => {
|
||||
// The running-success guard requires (!health || health === 'healthy');
|
||||
// 'starting' is truthy and not 'healthy', so it skips success and falls to
|
||||
// the trailing `if (state)` warning arm.
|
||||
expect(getDockerContainerStatusIndicator({ state: 'running', health: 'starting' })).toEqual({
|
||||
variant: 'warning',
|
||||
label: 'Running',
|
||||
});
|
||||
});
|
||||
|
||||
it('labels an error state with a capitalized version of the state name', () => {
|
||||
// ERROR_CONTAINER_STATES.has(state) arm where health !== 'unhealthy', so the
|
||||
// label is formatStatusLabel(state, 'Error').
|
||||
expect(getDockerContainerStatusIndicator({ state: 'oomkilled' })).toEqual({
|
||||
variant: 'danger',
|
||||
label: 'Oomkilled',
|
||||
});
|
||||
});
|
||||
|
||||
it('prioritizes the unhealthy label over a stopped state', () => {
|
||||
// 'created' is in STOPPED_CONTAINER_STATES, but the unhealthy check comes
|
||||
// first and forces the 'Unhealthy' label.
|
||||
expect(getDockerContainerStatusIndicator({ state: 'created', health: 'unhealthy' })).toEqual({
|
||||
variant: 'danger',
|
||||
label: 'Unhealthy',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a warning indicator from a health-only signal when state is empty', () => {
|
||||
// `if (!state && health)` arm — state absent, health present.
|
||||
expect(getDockerContainerStatusIndicator({ state: undefined, health: 'starting' })).toEqual({
|
||||
variant: 'warning',
|
||||
label: 'Starting',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a warning indicator for a custom state that is in no state set', () => {
|
||||
// `if (state)` trailing warning arm — 'deploying' is not running, not in the
|
||||
// error set, not in the stopped set.
|
||||
expect(getDockerContainerStatusIndicator({ state: 'deploying' })).toEqual({
|
||||
variant: 'warning',
|
||||
label: 'Deploying',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the muted default indicator when neither state nor health is present', () => {
|
||||
// Final `return defaultIndicator` arm.
|
||||
expect(getDockerContainerStatusIndicator({})).toEqual({
|
||||
variant: 'muted',
|
||||
label: 'Unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDockerServiceStatusIndicator (branch coverage)', () => {
|
||||
it('coalesces undefined task counts to zero and reports "No tasks"', () => {
|
||||
// `service.desiredTasks ?? 0` and `service.runningTasks ?? 0` defaulting arms.
|
||||
expect(getDockerServiceStatusIndicator({})).toEqual({
|
||||
variant: 'muted',
|
||||
label: 'No tasks',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the singular "task" form when exactly one task is running with none desired', () => {
|
||||
// `running === 1 ? '' : 's'` singular arm inside the desired<=0 warning.
|
||||
expect(getDockerServiceStatusIndicator({ desiredTasks: 0, runningTasks: 1 })).toEqual({
|
||||
variant: 'warning',
|
||||
label: 'Running 1 task',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports healthy when running exceeds desired (>= arm, strictly-greater path)', () => {
|
||||
// `if (running >= desired)` arm with running > desired.
|
||||
expect(getDockerServiceStatusIndicator({ desiredTasks: 2, runningTasks: 5 })).toEqual({
|
||||
variant: 'success',
|
||||
label: 'Healthy',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentStatusIndicator (branch coverage)', () => {
|
||||
it('returns success with the "Online" label for a running agent', () => {
|
||||
// `status === RUNNING_STATUS` arm of the online/running check.
|
||||
expect(getAgentStatusIndicator({ status: 'running' })).toEqual({
|
||||
variant: 'success',
|
||||
label: 'Online',
|
||||
});
|
||||
});
|
||||
|
||||
it('labels offline statuses from the OFFLINE set with a capitalized status', () => {
|
||||
// OFFLINE arm with a non-'offline' member to exercise formatStatusLabel.
|
||||
expect(getAgentStatusIndicator({ status: 'unreachable' })).toEqual({
|
||||
variant: 'danger',
|
||||
label: 'Unreachable',
|
||||
});
|
||||
});
|
||||
|
||||
it('labels degraded statuses from the DEGRADED set with a capitalized status', () => {
|
||||
// DEGRADED arm with a non-'degraded' member.
|
||||
expect(getAgentStatusIndicator({ status: 'syncing' })).toEqual({
|
||||
variant: 'warning',
|
||||
label: 'Syncing',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a muted indicator with a formatted label for an unknown non-empty status', () => {
|
||||
// `status ? { muted, label }` truthy arm — 'idle' is in no status set.
|
||||
expect(getAgentStatusIndicator({ status: 'idle' })).toEqual({
|
||||
variant: 'muted',
|
||||
label: 'Idle',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the muted default indicator when agent status is empty', () => {
|
||||
// `: defaultIndicator` arm — normalized status is '' (falsy).
|
||||
expect(getAgentStatusIndicator({ status: '' })).toEqual({
|
||||
variant: 'muted',
|
||||
label: 'Unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPBSStatusIndicator (branch coverage)', () => {
|
||||
it('flags warning from a degraded connectionHealth with a non-canonical status', () => {
|
||||
// DEGRADED arm driven by connection alone (status 'paused' is not healthy/
|
||||
// online and not in the degraded set, so without connection it would fall
|
||||
// through to defaultIndicator). Label prefers connection.
|
||||
expect(
|
||||
getPBSStatusIndicator({ status: 'paused', connectionHealth: 'maintenance' }),
|
||||
).toEqual({ variant: 'warning', label: 'Maintenance' });
|
||||
});
|
||||
|
||||
it('prefers connection over status when both are degraded for the warning label', () => {
|
||||
// `formatStatusLabel(connection || status, 'Degraded')` — connection wins.
|
||||
expect(
|
||||
getPBSStatusIndicator({ status: 'degraded', connectionHealth: 'recovering' }),
|
||||
).toEqual({ variant: 'warning', label: 'Recovering' });
|
||||
});
|
||||
|
||||
it('returns the muted default indicator for a status that is neither offline, healthy/online, nor degraded', () => {
|
||||
// Final `return defaultIndicator` arm.
|
||||
expect(getPBSStatusIndicator({ status: 'paused' })).toEqual({
|
||||
variant: 'muted',
|
||||
label: 'Unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getReplicationJobStatusIndicator (branch coverage)', () => {
|
||||
it('coalesces status from the state field when status is absent', () => {
|
||||
// `job.status || job.state` arm — status undefined falls through to state.
|
||||
expect(getReplicationJobStatusIndicator({ state: 'syncing' })).toEqual({
|
||||
variant: 'warning',
|
||||
label: 'Syncing',
|
||||
});
|
||||
expect(getReplicationJobStatusIndicator({ state: 'idle' })).toEqual({
|
||||
variant: 'success',
|
||||
label: 'Idle',
|
||||
});
|
||||
});
|
||||
|
||||
it('flags danger from lastSyncStatus alone when status is empty, labelling from lastStatus', () => {
|
||||
// lastStatus.includes('error') arm; status is '' so `status || lastStatus`
|
||||
// yields lastStatus for the label.
|
||||
expect(getReplicationJobStatusIndicator({ lastSyncStatus: 'backup-error' })).toEqual({
|
||||
variant: 'danger',
|
||||
label: 'Backup-error',
|
||||
});
|
||||
});
|
||||
|
||||
it('prioritizes the error check over the sync check for a status containing both', () => {
|
||||
// Order of checks: error is evaluated before sync.
|
||||
expect(getReplicationJobStatusIndicator({ status: 'sync-error' })).toEqual({
|
||||
variant: 'danger',
|
||||
label: 'Sync-error',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the success/idle indicator for an empty status with no lastSyncStatus', () => {
|
||||
// Trailing success arm with status '' -> formatStatusLabel('', 'Idle') -> 'Idle'.
|
||||
expect(getReplicationJobStatusIndicator({})).toEqual({
|
||||
variant: 'success',
|
||||
label: 'Idle',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user