From 1294695d6aeecd3ebdfe41f6da9689ecab5eaef9 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 12 Jul 2026 01:04:46 +0100 Subject: [PATCH] test(frontend): branch-coverage tests for 28 pure model/presentation modules --- .../assistantOutputHygiene.branchcov2.test.ts | 472 +++++++++ ...alertResourceTableModel.branchcov2.test.ts | 634 ++++++++++++ ...lDrawerOperationalModel.branchcov2.test.ts | 335 +++++++ .../apiTokenManagerModel.branchcov2.test.ts | 601 +++++++++++ ...gentUpdateCommandsModel.branchcov2.test.ts | 492 +++++++++ ...reportingSchedulesModel.branchcov2.test.ts | 609 +++++++++++ .../settingsNavVisibility.branchcov2.test.ts | 551 ++++++++++ .../ssoProvidersModel.branchcov2.test.ts | 523 ++++++++++ .../guestDrawerModel.branchcov2.test.ts | 657 ++++++++++++ ...adInventorySourceIssues.branchcov2.test.ts | 460 +++++++++ .../workloadSelectors.branchcov2.test.ts | 407 ++++++++ ...ntainerUpdateBadgeModel.branchcov2.test.ts | 394 ++++++++ .../historyChartModel.branchcov2.test.ts | 385 +++++++ .../alertHistoryModel.branchcov2.test.ts | 947 ++++++++++++++++++ .../__tests__/helpers.branchcov2.test.ts | 176 ++++ ...ntainerLifecycleActions.branchcov2.test.ts | 475 +++++++++ ...trolControlPresentation.branchcov2.test.ts | 668 ++++++++++++ ...roxmoxBackupsTableModel.branchcov2.test.ts | 261 +++++ .../proxmoxPageModel.branchcov2.test.ts | 795 +++++++++++++++ .../resourceStorageMapping.branchcov2.test.ts | 474 +++++++++ .../rowPresentation.branchcov2.test.ts | 510 ++++++++++ ...actionAuditPresentation.branchcov2.test.ts | 328 ++++++ ...bilityProbePresentation.branchcov2.test.ts | 585 +++++++++++ ...veryOutcomePresentation.branchcov2.test.ts | 124 +++ .../recoveryPlatformModel.branchcov2.test.ts | 314 ++++++ ...sourceBadgePresentation.branchcov2.test.ts | 524 ++++++++++ ...ourcePolicyPresentation.branchcov2.test.ts | 421 ++++++++ .../utils/__tests__/status.branchcov2.test.ts | 396 ++++++++ 28 files changed, 13518 insertions(+) create mode 100644 frontend-modern/src/components/AI/Chat/__tests__/assistantOutputHygiene.branchcov2.test.ts create mode 100644 frontend-modern/src/components/Alerts/__tests__/alertResourceTableModel.branchcov2.test.ts create mode 100644 frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerOperationalModel.branchcov2.test.ts create mode 100644 frontend-modern/src/components/Settings/__tests__/apiTokenManagerModel.branchcov2.test.ts create mode 100644 frontend-modern/src/components/Settings/__tests__/infrastructureAgentUpdateCommandsModel.branchcov2.test.ts create mode 100644 frontend-modern/src/components/Settings/__tests__/reportingSchedulesModel.branchcov2.test.ts create mode 100644 frontend-modern/src/components/Settings/__tests__/settingsNavVisibility.branchcov2.test.ts create mode 100644 frontend-modern/src/components/Settings/__tests__/ssoProvidersModel.branchcov2.test.ts create mode 100644 frontend-modern/src/components/Workloads/__tests__/guestDrawerModel.branchcov2.test.ts create mode 100644 frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.branchcov2.test.ts create mode 100644 frontend-modern/src/components/Workloads/__tests__/workloadSelectors.branchcov2.test.ts create mode 100644 frontend-modern/src/components/shared/__tests__/containerUpdateBadgeModel.branchcov2.test.ts create mode 100644 frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts create mode 100644 frontend-modern/src/features/alerts/__tests__/alertHistoryModel.branchcov2.test.ts create mode 100644 frontend-modern/src/features/alerts/__tests__/helpers.branchcov2.test.ts create mode 100644 frontend-modern/src/features/docker/__tests__/dockerContainerLifecycleActions.branchcov2.test.ts create mode 100644 frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.branchcov2.test.ts create mode 100644 frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableModel.branchcov2.test.ts create mode 100644 frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.branchcov2.test.ts create mode 100644 frontend-modern/src/features/storageBackups/__tests__/resourceStorageMapping.branchcov2.test.ts create mode 100644 frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov2.test.ts create mode 100644 frontend-modern/src/utils/__tests__/actionAuditPresentation.branchcov2.test.ts create mode 100644 frontend-modern/src/utils/__tests__/availabilityProbePresentation.branchcov2.test.ts create mode 100644 frontend-modern/src/utils/__tests__/recoveryOutcomePresentation.branchcov2.test.ts create mode 100644 frontend-modern/src/utils/__tests__/recoveryPlatformModel.branchcov2.test.ts create mode 100644 frontend-modern/src/utils/__tests__/resourceBadgePresentation.branchcov2.test.ts create mode 100644 frontend-modern/src/utils/__tests__/resourcePolicyPresentation.branchcov2.test.ts create mode 100644 frontend-modern/src/utils/__tests__/status.branchcov2.test.ts diff --git a/frontend-modern/src/components/AI/Chat/__tests__/assistantOutputHygiene.branchcov2.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/assistantOutputHygiene.branchcov2.test.ts new file mode 100644 index 000000000..00cb3d02f --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/__tests__/assistantOutputHygiene.branchcov2.test.ts @@ -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 '<|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, + }); + }); +}); diff --git a/frontend-modern/src/components/Alerts/__tests__/alertResourceTableModel.branchcov2.test.ts b/frontend-modern/src/components/Alerts/__tests__/alertResourceTableModel.branchcov2.test.ts new file mode 100644 index 000000000..6a0d9af05 --- /dev/null +++ b/frontend-modern/src/components/Alerts/__tests__/alertResourceTableModel.branchcov2.test.ts @@ -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 { + 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, '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; + 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); + }); +}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerOperationalModel.branchcov2.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerOperationalModel.branchcov2.test.ts new file mode 100644 index 000000000..e45a853ad --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerOperationalModel.branchcov2.test.ts @@ -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; + +const baseResource = (overrides: Partial): 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); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/apiTokenManagerModel.branchcov2.test.ts b/frontend-modern/src/components/Settings/__tests__/apiTokenManagerModel.branchcov2.test.ts new file mode 100644 index 000000000..3d0cffc02 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/apiTokenManagerModel.branchcov2.test.ts @@ -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 => ({ + 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 => ({ + 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 }), + ), + ).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. when not at the top level', () => { + expect( + tokenRevokedAtForResource( + makeResource({ platformData: { agent: { tokenRevokedAt: 222 } } }), + ), + ).toBe(222); + }); + + it('falls through to platformData.docker. 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); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentUpdateCommandsModel.branchcov2.test.ts b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentUpdateCommandsModel.branchcov2.test.ts new file mode 100644 index 000000000..ad698a7e7 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentUpdateCommandsModel.branchcov2.test.ts @@ -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 => ({ + 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; + +const emptyFleetMember = { + fleetSignals: [], + fleetHighlights: [], +} satisfies Pick; + +const row = (overrides: Partial = {}): 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 => ({ + 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({ + 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'); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/reportingSchedulesModel.branchcov2.test.ts b/frontend-modern/src/components/Settings/__tests__/reportingSchedulesModel.branchcov2.test.ts new file mode 100644 index 000000000..8d24ee2ff --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/reportingSchedulesModel.branchcov2.test.ts @@ -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 => ({ + 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 => ({ + ...DEFAULT_REPORT_SCHEDULE_FORM(), + ...overrides, +}); + +const makeResource = (overrides: Partial = {}): 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).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(''); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsNavVisibility.branchcov2.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsNavVisibility.branchcov2.test.ts new file mode 100644 index 000000000..e1d724dd0 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/settingsNavVisibility.branchcov2.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/ssoProvidersModel.branchcov2.test.ts b/frontend-modern/src/components/Settings/__tests__/ssoProvidersModel.branchcov2.test.ts new file mode 100644 index 000000000..bcbea2a68 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/ssoProvidersModel.branchcov2.test.ts @@ -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 => ({ + ...createEmptyProviderForm(), + type: 'oidc' as const, + ...overrides, +}); + +const samlForm = (overrides: Partial = {}): 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: '', + samlIdpSsoUrl: 'https://idp.example.com/sso', + samlIdpCertificate: 'cert-data', + }), + ); + expect(payload).toStrictEqual({ + type: 'saml', + saml: { + idpMetadataUrl: 'https://idp.example.com/metadata', + idpMetadataXml: '', + 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: '', + 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: '', + 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: '', + 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: '', + 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: '' }))).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); + }); +}); diff --git a/frontend-modern/src/components/Workloads/__tests__/guestDrawerModel.branchcov2.test.ts b/frontend-modern/src/components/Workloads/__tests__/guestDrawerModel.branchcov2.test.ts new file mode 100644 index 000000000..1739a0dd9 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/guestDrawerModel.branchcov2.test.ts @@ -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 => + ({ + 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()); + }); + }); +}); diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.branchcov2.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.branchcov2.test.ts new file mode 100644 index 000000000..9155ac826 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/workloadInventorySourceIssues.branchcov2.test.ts @@ -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 => ({ + 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 => + ({ + 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, + }, + ]); + }); + }); +}); diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.branchcov2.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.branchcov2.test.ts new file mode 100644 index 000000000..745519343 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.branchcov2.test.ts @@ -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 => ({ + 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, + }); + }); + }); +}); diff --git a/frontend-modern/src/components/shared/__tests__/containerUpdateBadgeModel.branchcov2.test.ts b/frontend-modern/src/components/shared/__tests__/containerUpdateBadgeModel.branchcov2.test.ts new file mode 100644 index 000000000..62d595890 --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/containerUpdateBadgeModel.branchcov2.test.ts @@ -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 { + 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...', + ); + }); + }); +}); diff --git a/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts b/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts new file mode 100644 index 000000000..f65677277 --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts @@ -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(['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(['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); + }); +}); diff --git a/frontend-modern/src/features/alerts/__tests__/alertHistoryModel.branchcov2.test.ts b/frontend-modern/src/features/alerts/__tests__/alertHistoryModel.branchcov2.test.ts new file mode 100644 index 000000000..a578e63d4 --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/alertHistoryModel.branchcov2.test.ts @@ -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 { + 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 { + 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 { + 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); + }); +}); diff --git a/frontend-modern/src/features/alerts/__tests__/helpers.branchcov2.test.ts b/frontend-modern/src/features/alerts/__tests__/helpers.branchcov2.test.ts new file mode 100644 index 000000000..b0ae348ba --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/helpers.branchcov2.test.ts @@ -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[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[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 => + ({ 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'); + }); + }); +}); diff --git a/frontend-modern/src/features/docker/__tests__/dockerContainerLifecycleActions.branchcov2.test.ts b/frontend-modern/src/features/docker/__tests__/dockerContainerLifecycleActions.branchcov2.test.ts new file mode 100644 index 000000000..facfe8d3c --- /dev/null +++ b/frontend-modern/src/features/docker/__tests__/dockerContainerLifecycleActions.branchcov2.test.ts @@ -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 => ({ + 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(); + }); + }); + }); +}); diff --git a/frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.branchcov2.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.branchcov2.test.ts new file mode 100644 index 000000000..4b0106110 --- /dev/null +++ b/frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.branchcov2.test.ts @@ -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([]); + }); + }); +}); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableModel.branchcov2.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableModel.branchcov2.test.ts new file mode 100644 index 000000000..b45bff74d --- /dev/null +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableModel.branchcov2.test.ts @@ -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 => 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 " 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 avbv 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 ab 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[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); + }); +}); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.branchcov2.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.branchcov2.test.ts new file mode 100644 index 000000000..2d8f9548c --- /dev/null +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.branchcov2.test.ts @@ -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 & Pick): 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 => ({ + 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__']); + }); +}); diff --git a/frontend-modern/src/features/storageBackups/__tests__/resourceStorageMapping.branchcov2.test.ts b/frontend-modern/src/features/storageBackups/__tests__/resourceStorageMapping.branchcov2.test.ts new file mode 100644 index 000000000..1c6a2c6bd --- /dev/null +++ b/frontend-modern/src/features/storageBackups/__tests__/resourceStorageMapping.branchcov2.test.ts @@ -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 => + ({ + 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', + ]); + }); +}); diff --git a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov2.test.ts b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov2.test.ts new file mode 100644 index 000000000..0c2c8f0b5 --- /dev/null +++ b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov2.test.ts @@ -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('—'); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/actionAuditPresentation.branchcov2.test.ts b/frontend-modern/src/utils/__tests__/actionAuditPresentation.branchcov2.test.ts new file mode 100644 index 000000000..3c3f3a017 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/actionAuditPresentation.branchcov2.test.ts @@ -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); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/availabilityProbePresentation.branchcov2.test.ts b/frontend-modern/src/utils/__tests__/availabilityProbePresentation.branchcov2.test.ts new file mode 100644 index 000000000..d37b202aa --- /dev/null +++ b/frontend-modern/src/utils/__tests__/availabilityProbePresentation.branchcov2.test.ts @@ -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 => ({ + protocol: 'tcp', + port: 443, + available: true, + latencyMillis: 5, + lastChecked: '2026-05-06T13:00:00Z', + ...overrides, +}); + +const makeResource = (overrides?: Partial): 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 " 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 " 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 ": " 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'); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/recoveryOutcomePresentation.branchcov2.test.ts b/frontend-modern/src/utils/__tests__/recoveryOutcomePresentation.branchcov2.test.ts new file mode 100644 index 000000000..780423257 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/recoveryOutcomePresentation.branchcov2.test.ts @@ -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'); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/recoveryPlatformModel.branchcov2.test.ts b/frontend-modern/src/utils/__tests__/recoveryPlatformModel.branchcov2.test.ts new file mode 100644 index 000000000..747959046 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/recoveryPlatformModel.branchcov2.test.ts @@ -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[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[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[0]), + ).toStrictEqual({ data: [], meta: defaults }); + expect( + normalizeRecoveryRollupsResponse({ + data: [], + meta: null, + } as unknown as Parameters[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[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[0]), + ).toStrictEqual({ + data: [], + meta: { page: 1, limit: 0, total: 0, totalPages: 1 }, + }); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/resourceBadgePresentation.branchcov2.test.ts b/frontend-modern/src/utils/__tests__/resourceBadgePresentation.branchcov2.test.ts new file mode 100644 index 000000000..b1684a2b0 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/resourceBadgePresentation.branchcov2.test.ts @@ -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 => + ({ + 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(''); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/resourcePolicyPresentation.branchcov2.test.ts b/frontend-modern/src/utils/__tests__/resourcePolicyPresentation.branchcov2.test.ts new file mode 100644 index 000000000..395aacb2a --- /dev/null +++ b/frontend-modern/src/utils/__tests__/resourcePolicyPresentation.branchcov2.test.ts @@ -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 => ({ + 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 => 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); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/status.branchcov2.test.ts b/frontend-modern/src/utils/__tests__/status.branchcov2.test.ts new file mode 100644 index 000000000..31d534f17 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/status.branchcov2.test.ts @@ -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`/`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', + }); + }); +});