From 22c9e83b158cba7864a2628e403566420636b11e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 17 Jul 2026 08:41:04 +0100 Subject: [PATCH] Add branch-coverage tests for finding, cache and badge presentation modules aiFindingPresentation had grown a set of badge, tone, label and Patrol classification helpers its existing suites never called, so this covers 25 previously-uncovered functions plus their fallback and count-edge arms. workloadsSummaryCache goes from 28 to 76 covered branches across cache hit/miss/expiry paths, infrastructureSummaryCache picks up its remaining getter and branch arms, and resourceBadgePresentation covers a further block of edge branches its two branchcov suites left open. Four new test-only files with distinct branchcov0717 suffixes alongside the existing suites. No source changed. Verified with vitest and type-check, each measured to raise branch or function coverage over the pre-existing suite. --- ...iFindingPresentation.branchcov0717.test.ts | 1053 +++++++++++++++++ ...tructureSummaryCache.branchcov0717.test.ts | 724 ++++++++++++ ...rceBadgePresentation.branchcov0717.test.ts | 958 +++++++++++++++ ...orkloadsSummaryCache.branchcov0717.test.ts | 756 ++++++++++++ 4 files changed, 3491 insertions(+) create mode 100644 frontend-modern/src/utils/__tests__/aiFindingPresentation.branchcov0717.test.ts create mode 100644 frontend-modern/src/utils/__tests__/infrastructureSummaryCache.branchcov0717.test.ts create mode 100644 frontend-modern/src/utils/__tests__/resourceBadgePresentation.branchcov0717.test.ts create mode 100644 frontend-modern/src/utils/__tests__/workloadsSummaryCache.branchcov0717.test.ts diff --git a/frontend-modern/src/utils/__tests__/aiFindingPresentation.branchcov0717.test.ts b/frontend-modern/src/utils/__tests__/aiFindingPresentation.branchcov0717.test.ts new file mode 100644 index 0000000000..167c42bd58 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/aiFindingPresentation.branchcov0717.test.ts @@ -0,0 +1,1053 @@ +import { describe, expect, it } from 'vitest'; + +import type { InvestigationOutcome, InvestigationStatus } from '@/api/patrol'; +import type { UnifiedFinding } from '@/stores/aiIntelligence'; +import { + buildFindingFilterOptions, + buildPatrolFindingDisplayGroups, + doesFindingNeedAttention, + formatFindingLifecycleType, + formatFindingLoopState, + formatOperatorStateDismissCauseLabel, + getFindingActiveRuntimeSortOrder, + getFindingEmptyStateCopy, + getFindingEvidencePresentation, + getFindingLoopStateBadgeClasses, + getFindingLoopStateBadgeTone, + getFindingManualControlsPresentation, + getFindingPatrolWorkflowPresentation, + getFindingPrimaryActionPresentation, + getFindingSeverityCompactLabel, + getFindingSourceBadgeTone, + getFindingStatusBadgeClasses, + getFindingStatusBadgeTone, + getFindingStatusLabel, + getFindingSubjectPresentation, + getInvestigationOutcomeBadgeClasses, + getInvestigationOutcomeBadgeTone, + getInvestigationOutcomeLabel, + getInvestigationStatusBadgeClasses, + getInvestigationStatusBadgeTone, + getInvestigationStatusLabel, + getPatrolFindingClassification, + getPatrolFindingIssueCountLabel, + hasFindingInvestigationDetails, + isPatrolInvestigationFixApproval, + sortFindingsForAttentionQueue, +} from '@/utils/aiFindingPresentation'; + +// `formatBoundedFindingTime` and `isFailedFixOutcome` are module-private helpers, +// exercised here indirectly through the exported entry points that call them. + +// Shared concrete Tailwind class strings, copied verbatim from the module's +// lookup tables so each assertion pins the exact rendered value. +const DEFAULT_BADGE_CLASSES = 'border-border bg-surface-alt text-muted'; +const DEFAULT_LOOP_STATE_CLASSES = 'border-border bg-surface-alt text-muted'; +const SKY_RUNTIME_CLASSES = + 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-800 dark:bg-sky-900 dark:text-sky-300'; +const BLUE_CLASSES = + 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-300'; +const GREEN_CLASSES = + 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900 dark:text-green-300'; +const INDIGO_CLASSES = + 'border-indigo-200 bg-indigo-50 text-indigo-700 dark:border-indigo-800 dark:bg-indigo-900 dark:text-indigo-300'; +const AMBER_CLASSES = + 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-900 dark:text-amber-300'; +const RED_CLASSES = + 'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900 dark:text-red-300'; +const EMERALD_CLASSES = + 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-900 dark:text-emerald-300'; + +// A finding whose resourceId marks it as a Patrol runtime finding. +const RUNTIME_SUBJECT = { resourceId: 'ai-service', resourceName: 'other', title: 'other' }; +// A finding that is plainly infrastructure (no runtime markers). +const INFRA_SUBJECT = { + resourceId: 'host-1', + resourceName: 'db-1', + title: 'disk full', +}; + +// Minimal builder for the Pick shape buildPatrolFindingDisplayGroups requires. +type DisplayFindingInput = Pick< + UnifiedFinding, + 'correlatedFindingIds' | 'id' | 'node' | 'resourceId' | 'resourceName' | 'resourceType' | 'title' +>; +const displayFinding = ( + overrides: Partial & Pick, +): DisplayFindingInput => ({ + id: overrides.id, + resourceId: overrides.resourceId ?? '', + resourceName: overrides.resourceName ?? '', + resourceType: overrides.resourceType ?? '', + title: overrides.title ?? '', + node: overrides.node, + correlatedFindingIds: overrides.correlatedFindingIds, +}); + +// Minimal builder for the Pick shape getFindingSubjectPresentation requires. +type SubjectFinding = Pick< + UnifiedFinding, + 'resourceId' | 'resourceName' | 'resourceType' | 'title' +>; +const subjectFinding = (overrides: Partial): SubjectFinding => ({ + resourceId: '', + resourceName: '', + resourceType: '', + title: '', + ...overrides, +}); + +describe('aiFindingPresentation branch coverage (supplemental)', () => { + describe('getFindingSourceBadgeTone', () => { + it('maps every canonical source to its tone', () => { + expect(getFindingSourceBadgeTone('threshold')).toBe('orange'); + expect(getFindingSourceBadgeTone('ai-patrol')).toBe('info'); + expect(getFindingSourceBadgeTone('anomaly')).toBe('info'); + expect(getFindingSourceBadgeTone('ai-chat')).toBe('teal'); + expect(getFindingSourceBadgeTone('correlation')).toBe('sky'); + expect(getFindingSourceBadgeTone('forecast')).toBe('success'); + }); + + it('falls back to the ai-patrol tone for unknown and empty sources (|| arm)', () => { + expect(getFindingSourceBadgeTone('mystery')).toBe('info'); + expect(getFindingSourceBadgeTone('')).toBe('info'); + }); + }); + + describe('getFindingStatusBadgeClasses', () => { + it('returns the colored class for each known status and the default for others', () => { + expect(getFindingStatusBadgeClasses('resolved')).toBe(GREEN_CLASSES); + expect(getFindingStatusBadgeClasses('snoozed')).toBe(BLUE_CLASSES); + expect(getFindingStatusBadgeClasses('dismissed')).toBe(DEFAULT_BADGE_CLASSES); + expect(getFindingStatusBadgeClasses('active')).toBe(DEFAULT_BADGE_CLASSES); + expect(getFindingStatusBadgeClasses('')).toBe(DEFAULT_BADGE_CLASSES); + }); + }); + + describe('getFindingStatusBadgeTone', () => { + it('maps known statuses to tones and falls back to muted', () => { + expect(getFindingStatusBadgeTone('resolved')).toBe('success'); + expect(getFindingStatusBadgeTone('snoozed')).toBe('info'); + expect(getFindingStatusBadgeTone('dismissed')).toBe('muted'); + expect(getFindingStatusBadgeTone('active')).toBe('muted'); + }); + }); + + describe('getFindingStatusLabel', () => { + it('maps known statuses to labels and falls back to Dismissed', () => { + expect(getFindingStatusLabel('resolved')).toBe('Resolved'); + expect(getFindingStatusLabel('snoozed')).toBe('Snoozed'); + expect(getFindingStatusLabel('dismissed')).toBe('Dismissed'); + // default (DEFAULT_FINDING_STATUS_LABEL) is also 'Dismissed' + expect(getFindingStatusLabel('active')).toBe('Dismissed'); + }); + }); + + describe('getFindingSeverityCompactLabel', () => { + it('returns the compact token for each known severity', () => { + expect(getFindingSeverityCompactLabel('critical')).toBe('CRIT'); + expect(getFindingSeverityCompactLabel('warning')).toBe('WARN'); + expect(getFindingSeverityCompactLabel('watch')).toBe('WATCH'); + expect(getFindingSeverityCompactLabel('info')).toBe('INFO'); + }); + + it('uppercases an unknown severity via String(severity).toUpperCase() (|| arm)', () => { + expect(getFindingSeverityCompactLabel('degraded')).toBe('DEGRADED'); + expect(getFindingSeverityCompactLabel('')).toBe(''); + }); + }); + + describe('getPatrolFindingClassification', () => { + it('classifies a Patrol runtime finding with the sky runtime badge', () => { + expect(getPatrolFindingClassification(RUNTIME_SUBJECT)).toStrictEqual({ + kind: 'runtime', + label: 'Patrol runtime', + badgeClasses: SKY_RUNTIME_CLASSES, + }); + }); + + it('classifies an infrastructure finding with the default badge (else arm)', () => { + expect(getPatrolFindingClassification(INFRA_SUBJECT)).toStrictEqual({ + kind: 'infrastructure', + label: 'Infrastructure', + badgeClasses: DEFAULT_BADGE_CLASSES, + }); + }); + }); + + describe('getPatrolFindingIssueCountLabel', () => { + it('singularises exactly one issue', () => { + expect(getPatrolFindingIssueCountLabel(1)).toBe('1 issue'); + }); + + it('pluralises zero and many', () => { + expect(getPatrolFindingIssueCountLabel(0)).toBe('0 issues'); + expect(getPatrolFindingIssueCountLabel(2)).toBe('2 issues'); + expect(getPatrolFindingIssueCountLabel(7)).toBe('7 issues'); + }); + + it('truncates fractional counts toward zero', () => { + expect(getPatrolFindingIssueCountLabel(3.9)).toBe('3 issues'); + }); + + it('clamps negative counts to zero', () => { + expect(getPatrolFindingIssueCountLabel(-5)).toBe('0 issues'); + }); + + it('treats non-finite counts (NaN/Infinity) as zero', () => { + expect(getPatrolFindingIssueCountLabel(Number.NaN)).toBe('0 issues'); + expect(getPatrolFindingIssueCountLabel(Number.POSITIVE_INFINITY)).toBe('0 issues'); + }); + }); + + describe('getFindingPrimaryActionPresentation', () => { + it('routes Patrol runtime findings to the provider-settings action', () => { + expect(getFindingPrimaryActionPresentation(RUNTIME_SUBJECT)).toStrictEqual({ + label: 'Check Patrol model', + href: '/settings/pulse-intelligence/patrol', + }); + }); + + it('returns undefined for infrastructure findings (else arm)', () => { + expect(getFindingPrimaryActionPresentation(INFRA_SUBJECT)).toBeUndefined(); + }); + }); + + describe('getFindingManualControlsPresentation', () => { + it('disables all manual controls for Patrol runtime findings', () => { + expect(getFindingManualControlsPresentation(RUNTIME_SUBJECT)).toStrictEqual({ + acknowledge: false, + snooze: false, + dismiss: false, + }); + }); + + it('enables all manual controls for infrastructure findings (else arm)', () => { + expect(getFindingManualControlsPresentation(INFRA_SUBJECT)).toStrictEqual({ + acknowledge: true, + snooze: true, + dismiss: true, + }); + }); + }); + + describe('getInvestigationStatusBadgeClasses', () => { + it('returns the mapped class for each canonical status', () => { + expect(getInvestigationStatusBadgeClasses('pending')).toBe(DEFAULT_BADGE_CLASSES); + expect(getInvestigationStatusBadgeClasses('running')).toBe(BLUE_CLASSES); + expect(getInvestigationStatusBadgeClasses('completed')).toBe(GREEN_CLASSES); + expect(getInvestigationStatusBadgeClasses('failed')).toBe(RED_CLASSES); + expect(getInvestigationStatusBadgeClasses('needs_attention')).toBe(AMBER_CLASSES); + }); + + it('falls back to the default badge for an unrecognized status', () => { + expect( + getInvestigationStatusBadgeClasses('frobnicated' as unknown as InvestigationStatus), + ).toBe(DEFAULT_BADGE_CLASSES); + }); + }); + + describe('getInvestigationStatusBadgeTone', () => { + it('maps every canonical status to its tone and unknown to muted', () => { + expect(getInvestigationStatusBadgeTone('pending')).toBe('muted'); + expect(getInvestigationStatusBadgeTone('running')).toBe('info'); + expect(getInvestigationStatusBadgeTone('completed')).toBe('success'); + expect(getInvestigationStatusBadgeTone('failed')).toBe('danger'); + expect(getInvestigationStatusBadgeTone('needs_attention')).toBe('warning'); + expect(getInvestigationStatusBadgeTone('unknown')).toBe('muted'); + }); + }); + + describe('getInvestigationStatusLabel', () => { + it('maps every canonical status to its label and unknown to the raw value', () => { + expect(getInvestigationStatusLabel('pending')).toBe('Pending'); + expect(getInvestigationStatusLabel('running')).toBe('Running'); + expect(getInvestigationStatusLabel('completed')).toBe('Completed'); + expect(getInvestigationStatusLabel('failed')).toBe('Failed'); + expect(getInvestigationStatusLabel('needs_attention')).toBe('Needs Attention'); + expect(getInvestigationStatusLabel('unknown')).toBe('unknown'); + }); + }); + + describe('getInvestigationOutcomeBadgeClasses', () => { + const expected: Record = { + resolved: GREEN_CLASSES, + fix_queued: BLUE_CLASSES, + fix_executed: EMERALD_CLASSES, + fix_failed: RED_CLASSES, + fix_rejected: AMBER_CLASSES, + needs_attention: AMBER_CLASSES, + cannot_fix: DEFAULT_BADGE_CLASSES, + timed_out: 'border-border bg-surface-alt text-base-content', + fix_verified: GREEN_CLASSES, + fix_verification_failed: RED_CLASSES, + fix_verification_unknown: AMBER_CLASSES, + }; + it.each(Object.entries(expected))('maps %s to its badge class', (_outcome, classes) => { + // Cast back through the union type; runtime resolves via Record lookup. + expect( + getInvestigationOutcomeBadgeClasses(_outcome as InvestigationOutcome), + ).toBe(classes); + }); + + it('falls back to the default badge for an unrecognized outcome', () => { + expect(getInvestigationOutcomeBadgeClasses('mystery')).toBe(DEFAULT_BADGE_CLASSES); + }); + }); + + describe('getInvestigationOutcomeBadgeTone', () => { + const expected: Record = { + resolved: 'success', + fix_queued: 'info', + fix_executed: 'success', + fix_failed: 'danger', + fix_rejected: 'warning', + needs_attention: 'warning', + cannot_fix: 'muted', + timed_out: 'neutral', + fix_verified: 'success', + fix_verification_failed: 'danger', + fix_verification_unknown: 'warning', + }; + it.each(Object.entries(expected))('maps %s to its tone', (outcome, tone) => { + expect(getInvestigationOutcomeBadgeTone(outcome as InvestigationOutcome)).toBe(tone); + }); + + it('falls back to the muted tone for an unrecognized outcome', () => { + expect(getInvestigationOutcomeBadgeTone('mystery')).toBe('muted'); + }); + }); + + describe('getInvestigationOutcomeLabel', () => { + const expected: Record = { + resolved: 'Resolved', + fix_queued: 'Fix queued', + fix_executed: 'Fix applied', + fix_failed: 'Fix failed', + fix_rejected: 'Fix rejected', + needs_attention: 'Needs attention', + cannot_fix: 'Cannot remediate', + timed_out: 'Timed out', + fix_verified: 'Fix verified', + fix_verification_failed: 'Verification failed', + fix_verification_unknown: 'Verification unknown', + }; + it.each(Object.entries(expected))('maps %s to its label', (outcome, label) => { + expect(getInvestigationOutcomeLabel(outcome as InvestigationOutcome)).toBe(label); + }); + + it('falls back to the raw string for an unrecognized outcome', () => { + expect(getInvestigationOutcomeLabel('mystery')).toBe('mystery'); + }); + }); + + describe('hasFindingInvestigationDetails', () => { + it('returns false when no investigation field is set', () => { + expect(hasFindingInvestigationDetails({})).toBe(false); + expect( + hasFindingInvestigationDetails({ + investigationSessionId: ' ', + investigationAttempts: 0, + }), + ).toBe(false); + }); + + it('returns true when a non-empty investigation session id is present', () => { + expect(hasFindingInvestigationDetails({ investigationSessionId: 'sess-1' })).toBe(true); + }); + + it('returns true when an investigation status is present', () => { + expect(hasFindingInvestigationDetails({ investigationStatus: 'running' })).toBe(true); + }); + + it('returns true when an investigation outcome is present', () => { + expect(hasFindingInvestigationDetails({ investigationOutcome: 'fix_queued' })).toBe(true); + }); + + it('returns true when investigation attempts is greater than zero', () => { + expect(hasFindingInvestigationDetails({ investigationAttempts: 2 })).toBe(true); + }); + + it('treats undefined attempts as zero via the ?? coalescer', () => { + expect(hasFindingInvestigationDetails({ investigationAttempts: undefined })).toBe(false); + }); + }); + + describe('isPatrolInvestigationFixApproval', () => { + it('returns true only for the investigation_fix tool id', () => { + expect(isPatrolInvestigationFixApproval({ toolId: 'investigation_fix' })).toBe(true); + expect(isPatrolInvestigationFixApproval({ toolId: 'something_else' })).toBe(false); + }); + }); + + describe('getFindingLoopStateBadgeClasses', () => { + it('returns the mapped class for each canonical loop state', () => { + expect(getFindingLoopStateBadgeClasses('detected')).toBe(BLUE_CLASSES); + expect(getFindingLoopStateBadgeClasses('investigating')).toBe(INDIGO_CLASSES); + expect(getFindingLoopStateBadgeClasses('remediation_planned')).toBe(AMBER_CLASSES); + expect(getFindingLoopStateBadgeClasses('remediating')).toBe(AMBER_CLASSES); + expect(getFindingLoopStateBadgeClasses('remediation_failed')).toBe(RED_CLASSES); + expect(getFindingLoopStateBadgeClasses('needs_attention')).toBe(AMBER_CLASSES); + expect(getFindingLoopStateBadgeClasses('timed_out')).toBe( + 'border-border bg-surface-alt text-base-content', + ); + expect(getFindingLoopStateBadgeClasses('resolved')).toBe(GREEN_CLASSES); + expect(getFindingLoopStateBadgeClasses('dismissed')).toBe(DEFAULT_LOOP_STATE_CLASSES); + expect(getFindingLoopStateBadgeClasses('snoozed')).toBe(BLUE_CLASSES); + expect(getFindingLoopStateBadgeClasses('suppressed')).toBe(DEFAULT_LOOP_STATE_CLASSES); + }); + + it('falls back to the default loop-state class for an unrecognized state', () => { + expect(getFindingLoopStateBadgeClasses('mystery')).toBe(DEFAULT_LOOP_STATE_CLASSES); + }); + }); + + describe('getFindingLoopStateBadgeTone', () => { + const expected: Record = { + detected: 'info', + investigating: 'indigo', + remediation_planned: 'warning', + remediating: 'warning', + remediation_failed: 'danger', + needs_attention: 'warning', + timed_out: 'neutral', + resolved: 'success', + dismissed: 'muted', + snoozed: 'info', + suppressed: 'muted', + }; + it.each(Object.entries(expected))('maps %s to its tone', (state, tone) => { + expect(getFindingLoopStateBadgeTone(state)).toBe(tone); + }); + + it('falls back to the muted tone for an unrecognized state', () => { + expect(getFindingLoopStateBadgeTone('mystery')).toBe('muted'); + }); + }); + + describe('formatFindingLoopState', () => { + it('replaces underscores with spaces via formatIdentifierLabel', () => { + expect(formatFindingLoopState('remediation_planned')).toBe('remediation planned'); + expect(formatFindingLoopState('detected')).toBe('detected'); + expect(formatFindingLoopState('')).toBe(''); + }); + }); + + describe('formatFindingLifecycleType', () => { + it('maps known lifecycle types to their human labels', () => { + expect(formatFindingLifecycleType('detected')).toBe('Detected'); + expect(formatFindingLifecycleType('auto_resolved')).toBe('Auto-resolved'); + expect(formatFindingLifecycleType('verification_passed')).toBe('Fix verified'); + expect(formatFindingLifecycleType('loop_transition_violation')).toBe( + 'Invalid transition blocked', + ); + }); + + it('falls back to formatIdentifierLabel for an unknown type (|| arm)', () => { + expect(formatFindingLifecycleType('custom_thing')).toBe('custom thing'); + expect(formatFindingLifecycleType('')).toBe(''); + }); + }); + + describe('buildFindingFilterOptions', () => { + it('returns only the three base options when neither count is positive', () => { + const options = buildFindingFilterOptions({ + needsAttentionCount: 0, + pendingApprovalCount: 0, + }); + expect(options.map((o) => o.value)).toStrictEqual(['active', 'all', 'resolved']); + }); + + it('appends the attention option when needsAttentionCount is positive', () => { + const options = buildFindingFilterOptions({ + needsAttentionCount: 3, + pendingApprovalCount: 0, + }); + expect(options).toHaveLength(4); + expect(options[3]).toStrictEqual({ + value: 'attention', + label: 'Needs Attention', + tone: 'warning', + count: 3, + }); + }); + + it('appends the approvals option when pendingApprovalCount is positive', () => { + const options = buildFindingFilterOptions({ + needsAttentionCount: 0, + pendingApprovalCount: 2, + }); + expect(options).toHaveLength(4); + expect(options[3]).toStrictEqual({ + value: 'approvals', + label: 'Approvals', + tone: 'warning', + count: 2, + }); + }); + + it('appends both conditional options when both counts are positive', () => { + const options = buildFindingFilterOptions({ + needsAttentionCount: 1, + pendingApprovalCount: 4, + }); + expect(options.map((o) => o.value)).toStrictEqual([ + 'active', + 'all', + 'resolved', + 'attention', + 'approvals', + ]); + }); + }); + + describe('getFindingEmptyStateCopy', () => { + it('returns the active copy with a body', () => { + expect(getFindingEmptyStateCopy('active')).toStrictEqual({ + title: 'No active findings', + body: 'Your infrastructure looks healthy!', + }); + }); + + it('returns title-only copy for the attention filter', () => { + expect(getFindingEmptyStateCopy('attention')).toStrictEqual({ + title: 'No findings need attention right now.', + }); + }); + + it('returns title-only copy for the approvals filter', () => { + expect(getFindingEmptyStateCopy('approvals')).toStrictEqual({ + title: 'No pending approvals.', + }); + }); + + it('falls through to the default copy for all/resolved (default arm)', () => { + expect(getFindingEmptyStateCopy('all')).toStrictEqual({ + title: 'No Patrol findings to display', + }); + expect(getFindingEmptyStateCopy('resolved')).toStrictEqual({ + title: 'No Patrol findings to display', + }); + }); + }); + + describe('formatOperatorStateDismissCauseLabel', () => { + it('maps the canonical causes to short labels', () => { + expect(formatOperatorStateDismissCauseLabel('maintenance_window')).toBe('maintenance'); + expect(formatOperatorStateDismissCauseLabel('intentionally_offline')).toBe( + 'intentionally offline', + ); + }); + + it('returns empty string for unknown and empty causes (default arm)', () => { + expect(formatOperatorStateDismissCauseLabel('mystery')).toBe(''); + expect(formatOperatorStateDismissCauseLabel('')).toBe(''); + }); + }); + + describe('getFindingActiveRuntimeSortOrder (branch arm: active + runtime)', () => { + it('returns 0 only for an active Patrol runtime finding', () => { + expect( + getFindingActiveRuntimeSortOrder({ + status: 'active', + resourceId: 'ai-service', + resourceName: 'other', + title: 'other', + }), + ).toBe(0); + }); + + it('returns 1 for an active non-runtime finding', () => { + expect( + getFindingActiveRuntimeSortOrder({ + status: 'active', + resourceId: 'host-1', + resourceName: 'db-1', + title: 'disk full', + }), + ).toBe(1); + }); + + it('returns 1 for a non-active finding even when it is runtime', () => { + expect( + getFindingActiveRuntimeSortOrder({ + status: 'resolved', + resourceId: 'ai-service', + resourceName: 'other', + title: 'other', + }), + ).toBe(1); + }); + }); + + describe('getFindingSubjectPresentation (runtime + resourceName fallback arms)', () => { + it('short-circuits to "Patrol runtime" for a runtime finding', () => { + expect(getFindingSubjectPresentation(subjectFinding(RUNTIME_SUBJECT))).toStrictEqual({ + label: 'Patrol runtime', + }); + }); + + it('combines name and formatted type when both are present', () => { + expect( + getFindingSubjectPresentation( + subjectFinding({ + resourceId: 'host-1', + resourceName: 'db-1', + resourceType: 'qemu_guest', + title: 'disk full', + }), + ), + ).toStrictEqual({ label: 'db-1 (qemu guest)' }); + }); + + it('uses just the resource name when resourceType is absent (!resourceType arm)', () => { + expect( + getFindingSubjectPresentation( + subjectFinding({ resourceId: 'host-1', resourceName: 'db-1', title: 'x' }), + ), + ).toStrictEqual({ label: 'db-1' }); + }); + + it('falls back to resourceId when resourceName is blank (|| right operand)', () => { + expect( + getFindingSubjectPresentation( + subjectFinding({ resourceId: 'host-1', resourceName: ' ', title: 'x' }), + ), + ).toStrictEqual({ label: 'host-1' }); + }); + + it('takes the resourceId || "" right arm when resourceName is empty and resourceId is set', () => { + // resourceName is falsy (""), so `resourceName || ""` short-circuits to "". + expect( + getFindingSubjectPresentation( + subjectFinding({ resourceId: 'host-1', resourceName: '', title: 'x' }), + ), + ).toStrictEqual({ label: 'host-1' }); + }); + + it('returns an empty label when neither resourceName nor resourceId is set', () => { + // Both `|| ""` right arms and the outer `||` resolve to "". + expect(getFindingSubjectPresentation(subjectFinding({ title: 'x' }))).toStrictEqual({ + label: '', + }); + }); + }); + + describe('getFindingEvidencePresentation (formatBoundedFindingTime + match arms)', () => { + it('renders the plural host-update observation with reboot authorized boundary', () => { + const out = getFindingEvidencePresentation({ + key: 'apt-host-updates', + evidence: + 'pending_updates=2 inventory=host-1 checked_at=2026-07-17T10:00:00Z received_at=2026-07-17T10:00:05Z reboot_required=true', + }); + expect(out).toContain('2 operating system updates were pending'); + expect(out).toContain('reboot required: Yes. No reboot is authorized by this finding or action'); + expect(out).not.toContain('Pulse could not safely present'); + }); + + it('renders the singular host-update observation with reboot not required', () => { + const out = getFindingEvidencePresentation({ + key: 'apt-host-updates', + evidence: + 'pending_updates=1 inventory=host-1 checked_at=2026-07-17T10:00:00Z received_at=2026-07-17T10:00:05Z reboot_required=false', + }); + expect(out).toContain('1 operating system update was pending'); + expect(out).toContain('The host already reported reboot required: No.'); + }); + + it('emits the safety fallback when a matched date does not parse (NaN arm of formatBoundedFindingTime)', () => { + // Month 13 passes the shape regex but yields an Invalid Date -> NaN -> undefined. + const out = getFindingEvidencePresentation({ + key: 'apt-host-updates', + evidence: + 'pending_updates=2 inventory=host-1 checked_at=2026-13-01T00:00:00Z received_at=2026-07-17T10:00:00Z reboot_required=false', + }); + expect(out).toBe( + 'Pulse could not safely present the bounded host-update observation. Refresh before acting.', + ); + }); + + it('renders the package-cleanup observation with formatted bytes and usage (valid arm)', () => { + const out = getFindingEvidencePresentation({ + key: 'apt-package-cache-pressure', + evidence: + 'reclaimable_bytes=1048576 filesystem_usage=75.5 fingerprint=abc checked_at=2026-07-17T10:00:00Z received_at=2026-07-17T10:00:05Z', + }); + expect(out).toContain('1.00 MB of downloaded package data was reclaimable'); + expect(out).toContain('75.5% full'); + expect(out).not.toContain('Pulse could not safely present'); + }); + + it('passes trimmed evidence through verbatim when the key is not a bounded format', () => { + expect( + getFindingEvidencePresentation({ key: 'something-else', evidence: ' raw evidence body ' }), + ).toBe('raw evidence body'); + }); + + it('returns an empty string when evidence is absent and the key is not bounded', () => { + expect(getFindingEvidencePresentation({ key: 'something-else' })).toBe(''); + }); + }); + + describe('getFindingPatrolWorkflowPresentation (typed action state arms)', () => { + type WorkflowFinding = Parameters[0]; + const base = { + id: 'wf-1', + source: 'ai-patrol' as const, + status: 'active' as const, + resourceId: 'host-1', + resourceName: 'host-1', + title: 'disk full', + investigationStatus: undefined, + investigationOutcome: undefined, + loopState: undefined, + }; + + it('returns the pending_approval approval stage', () => { + expect( + getFindingPatrolWorkflowPresentation( + { + ...base, + investigationRecord: { action: { state: 'pending_approval' } }, + } as unknown as WorkflowFinding, + [], + ), + ).toStrictEqual({ + stage: 'approval', + label: 'Approve or reject', + detail: 'A typed governed action is waiting for an operator decision.', + tone: 'warning', + }); + }); + + it('returns the planned approval stage', () => { + expect( + getFindingPatrolWorkflowPresentation( + { + ...base, + investigationRecord: { action: { state: 'planned' } }, + } as unknown as WorkflowFinding, + [], + ), + ).toStrictEqual({ + stage: 'approval', + label: 'Run action', + detail: 'The typed action plan is ready to run under its declared policy.', + tone: 'info', + }); + }); + + it('returns the approved approval stage', () => { + expect( + getFindingPatrolWorkflowPresentation( + { + ...base, + investigationRecord: { action: { state: 'approved' } }, + } as unknown as WorkflowFinding, + [], + ), + ).toStrictEqual({ + stage: 'approval', + label: 'Run approved action', + detail: 'The typed action is approved; execution remains a separate operator step.', + tone: 'success', + }); + }); + + it('returns the executing verification stage', () => { + expect( + getFindingPatrolWorkflowPresentation( + { + ...base, + investigationRecord: { action: { state: 'executing' } }, + } as unknown as WorkflowFinding, + [], + ), + ).toStrictEqual({ + stage: 'verification', + label: 'Action running', + detail: 'The governed action is running and will publish its verification result.', + tone: 'info', + }); + }); + }); + + describe('doesFindingNeedAttention (fix_queued + investigationRecord?.action arm)', () => { + type AttentionFinding = Parameters[0]; + const queuedBase = { + id: 'f-1', + status: 'active' as const, + investigationOutcome: 'fix_queued' as const, + }; + + it('needs attention when fix is queued with no live approval and no action record', () => { + expect( + doesFindingNeedAttention( + { ...queuedBase } as unknown as AttentionFinding, + [], + ), + ).toBe(true); + }); + + it('needs attention when the record exists but carries no action (?. non-short-circuit arm)', () => { + expect( + doesFindingNeedAttention( + { ...queuedBase, investigationRecord: {} } as unknown as AttentionFinding, + [], + ), + ).toBe(true); + }); + + it('does not need attention once a typed action exists (!action false arm)', () => { + expect( + doesFindingNeedAttention( + { + ...queuedBase, + investigationRecord: { action: { state: 'executing' } }, + } as unknown as AttentionFinding, + [], + ), + ).toBe(false); + }); + }); + + describe('sortFindingsForAttentionQueue (runtime tie-break arm)', () => { + it('ranks an active runtime finding above an active infrastructure finding when other keys tie', () => { + const runtime = { + id: 'runtime', + source: 'ai-patrol', + resourceId: 'ai-service', + resourceName: 'other', + resourceType: 'service', + category: 'runtime', + severity: 'critical', + title: 'other', + description: '', + detectedAt: '2026-07-17T00:00:00Z', + status: 'active', + resourceCriticality: 'high', + }; + const infra = { + id: 'infra', + source: 'ai-patrol', + resourceId: 'host-1', + resourceName: 'db-1', + resourceType: 'vm', + category: 'infra', + severity: 'critical', + title: 'disk full', + description: '', + detectedAt: '2026-07-17T00:00:00Z', + status: 'active', + resourceCriticality: 'high', + }; + const sorted = sortFindingsForAttentionQueue([ + infra as unknown as UnifiedFinding, + runtime as unknown as UnifiedFinding, + ]); + expect(sorted.map((f) => f.id)).toStrictEqual(['runtime', 'infra']); + }); + }); + + describe('buildPatrolFindingDisplayGroups', () => { + it('returns an empty array for no findings', () => { + expect(buildPatrolFindingDisplayGroups([])).toStrictEqual([]); + }); + + it('produces a single-finding group of kind "finding" with the subject label', () => { + const f1 = displayFinding({ + id: 'f1', + resourceId: 'r1', + resourceName: 'Host One', + resourceType: 'qemu_guest', + title: 'CPU high', + }); + const groups = buildPatrolFindingDisplayGroups([f1]); + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ + id: 'group:f1', + kind: 'finding', + label: 'Host One (qemu guest)', + affectedResourceCount: 1, + }); + expect(groups[0].relatedFindings).toStrictEqual([]); + expect(groups[0].findings).toStrictEqual([f1]); + expect(groups[0].primaryFinding).toBe(f1); + }); + + it('groups findings sharing a resource into a "resource" group', () => { + const f1 = displayFinding({ + id: 'f1', + resourceId: 'r1', + resourceName: 'Host One', + resourceType: 'vm', + title: 'CPU high', + }); + const f2 = displayFinding({ + id: 'f2', + resourceId: 'r1', + resourceName: 'Host One', + resourceType: 'vm', + title: 'Mem high', + }); + const groups = buildPatrolFindingDisplayGroups([f1, f2]); + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ + id: 'group:f1:f2', + kind: 'resource', + label: 'Host One (vm)', + affectedResourceCount: 1, + }); + expect(groups[0].relatedFindings).toHaveLength(1); + expect(groups[0].relatedFindings[0]).toBe(f2); + expect(groups[0].findings).toHaveLength(2); + }); + + it('groups findings sharing a node into a "node" group with a title-cased label', () => { + const groups = buildPatrolFindingDisplayGroups([ + displayFinding({ + id: 'f1', + resourceId: 'r1', + resourceName: 'A', + resourceType: 'vm', + title: 't1', + node: 'node-1', + }), + displayFinding({ + id: 'f2', + resourceId: 'r2', + resourceName: 'B', + resourceType: 'vm', + title: 't2', + node: 'node-1', + }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ + kind: 'node', + label: 'Node 1', + affectedResourceCount: 2, + }); + }); + + it('groups explicitly correlated findings into a "correlated" group with the resource-count label', () => { + const groups = buildPatrolFindingDisplayGroups([ + displayFinding({ + id: 'f1', + resourceId: 'r1', + resourceName: 'A', + resourceType: 'vm', + title: 't1', + correlatedFindingIds: ['f2'], + }), + displayFinding({ + id: 'f2', + resourceId: 'r2', + resourceName: 'B', + resourceType: 'vm', + title: 't2', + }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ + kind: 'correlated', + label: '2 related resources', + affectedResourceCount: 2, + }); + }); + + it('uses the singular "1 related resource" fallback when explicit correlation shares one resource', () => { + // Explicit correlation wins over the size===1 "resource" kind, so a + // single-resource correlated pair reaches the resource-count label and + // hits the singular `resourceKeys.size === 1 ? "resource"` arm. + const groups = buildPatrolFindingDisplayGroups([ + displayFinding({ + id: 'f1', + resourceId: 'r1', + resourceName: 'A', + resourceType: 'vm', + title: 't1', + correlatedFindingIds: ['f2'], + }), + displayFinding({ + id: 'f2', + resourceId: 'r1', + resourceName: 'A', + resourceType: 'vm', + title: 't2', + correlatedFindingIds: ['f1'], + }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ + kind: 'correlated', + label: '1 related resource', + affectedResourceCount: 1, + }); + }); + + it('falls through to the final "correlated" kind via a transitive resource+node union', () => { + // f1<->f2 share resource r1; f2<->f3 share node node-b. All three end up in + // one component with two resources and two nodes and no explicit correlation, + // hitting the final 'correlated' kind and the resource-count fallback label. + const groups = buildPatrolFindingDisplayGroups([ + displayFinding({ + id: 'f1', + resourceId: 'r1', + resourceName: 'A', + resourceType: 'vm', + title: 't1', + node: 'node-a', + }), + displayFinding({ + id: 'f2', + resourceId: 'r1', + resourceName: 'A', + resourceType: 'vm', + title: 't2', + node: 'node-b', + }), + displayFinding({ + id: 'f3', + resourceId: 'r2', + resourceName: 'B', + resourceType: 'vm', + title: 't3', + node: 'node-b', + }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ + kind: 'correlated', + label: '2 related resources', + affectedResourceCount: 2, + }); + expect(groups[0].findings).toHaveLength(3); + expect(groups[0].relatedFindings).toHaveLength(2); + }); + + it('ignores correlated ids that reference findings not in the set (findingById.has false arm)', () => { + const groups = buildPatrolFindingDisplayGroups([ + displayFinding({ + id: 'f1', + resourceId: 'r1', + resourceName: 'A', + resourceType: 'vm', + title: 't1', + correlatedFindingIds: ['ghost'], + }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0].kind).toBe('finding'); + }); + + it('keeps disjoint resources as separate single-finding groups', () => { + const groups = buildPatrolFindingDisplayGroups([ + displayFinding({ id: 'f1', resourceId: 'r1', resourceName: 'A', resourceType: 'vm', title: 't1' }), + displayFinding({ id: 'f2', resourceId: 'r2', resourceName: 'B', resourceType: 'vm', title: 't2' }), + ]); + expect(groups).toHaveLength(2); + expect(groups.map((g) => g.kind)).toStrictEqual(['finding', 'finding']); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/infrastructureSummaryCache.branchcov0717.test.ts b/frontend-modern/src/utils/__tests__/infrastructureSummaryCache.branchcov0717.test.ts new file mode 100644 index 0000000000..8995d6fbd0 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/infrastructureSummaryCache.branchcov0717.test.ts @@ -0,0 +1,724 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + ChartData, + ChartsResponse, + InfrastructureChartsResponse, + InfrastructureSummaryMetric, + TimeRange, +} from '@/api/charts'; +import { setOrgID } from '@/utils/apiClient'; +import { eventBus } from '@/stores/events'; +import { + __resetInfrastructureSummaryFetchesForTests, + extractInfrastructureSummaryChartMap, + extractInfrastructureSummaryChartMapFromInfrastructureResponse, + fetchInfrastructureSummaryAndCache, + persistInfrastructureSummaryCache, + readInfrastructureSummaryCache, +} from '@/utils/infrastructureSummaryCache'; + +// ChartsAPI is mocked (keeping `apiClient`, `orgScope` and `events` real) so the +// fetch entry point is deterministic and offline — the same isolation strategy +// the sibling `infrastructureSummaryCache.branchcov.test.ts` uses. Helpers below +// mirror its fixture shape so the assertions stay concrete. +const mockGetCharts = vi.fn(); + +vi.mock('@/api/charts', async () => { + const actual = await vi.importActual('@/api/charts'); + return { + ...actual, + ChartsAPI: { + ...actual.ChartsAPI, + getInfrastructureSummaryCharts: (...args: unknown[]) => mockGetCharts(...args), + }, + }; +}); + +const DEFAULT_METRICS_KEY = 'cpu,memory,disk,diskread,diskwrite,netin,netout'; + +const cacheKeyForRange = ( + range: TimeRange, + orgScope = 'default', + metrics = DEFAULT_METRICS_KEY, +) => `pulse.infrastructureSummaryCharts.${encodeURIComponent(orgScope)}::${range}::${metrics}`; + +interface MetricPoint { + timestamp: number; + value: number; +} + +const metricPoint = (timestamp: number, value: number): MetricPoint => ({ timestamp, value }); + +// Series of N points starting at `start`, stepping by `step` ms. +const makeSeries = (count: number, start = 0, step = 30_000): MetricPoint[] => + Array.from({ length: count }, (_, i) => metricPoint(start + i * step, i)); + +const now = () => Date.now(); + +const makeInfraResponse = ( + overrides: Partial = {}, +): InfrastructureChartsResponse => ({ + nodeData: {}, + timestamp: now(), + stats: { oldestDataTimestamp: now() - 60_000 }, + ...overrides, +}); + +// Shape of the JSON payload persistInfrastructureSummaryCache writes; used to +// hand-craft localStorage entries that exercise readInfrastructureSummaryCache's +// coercion branches directly. +interface StoredPayload { + version: number; + range: TimeRange; + cachedAt: number; + metrics: InfrastructureSummaryMetric[]; + oldestDataTimestamp: number | null; + charts: Record> | undefined; +} + +const buildPayload = (overrides: Partial = {}): StoredPayload => ({ + version: 5, + range: '1h', + cachedAt: now(), + metrics: ['cpu', 'memory', 'disk', 'diskread', 'diskwrite', 'netin', 'netout'], + oldestDataTimestamp: null, + charts: {}, + ...overrides, +}); + +const storePayload = ( + range: TimeRange, + payload: StoredPayload, + orgScope = 'default', + metrics = DEFAULT_METRICS_KEY, +) => { + window.localStorage.setItem(cacheKeyForRange(range, orgScope, metrics), JSON.stringify(payload)); +}; + +// Replace a global property with `undefined` for the duration of a callback, then +// restore the original descriptor. Used to exercise the `typeof window === +// 'undefined'` and `typeof performance === 'undefined'` early-return / fallback +// arms that the jsdom test environment otherwise keeps firmly on the happy path. +const withGlobalUndefined = (name: 'window' | 'performance', fn: () => T): T => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, name); + Object.defineProperty(globalThis, name, { + value: undefined, + configurable: true, + writable: true, + }); + try { + return fn(); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + } else { + // No pre-existing descriptor: leave the property undefined (matches the + // pre-test state we just established) but ensure it stays configurable. + Object.defineProperty(globalThis, name, { + value: undefined, + configurable: true, + writable: true, + }); + } + } +}; + +describe('extractInfrastructureSummaryChartMap — direct call coverage (uncovered getter)', () => { + // extractInfrastructureSummaryChartMap is only reached in production via + // extractInfrastructureSummaryChartMapFromInfrastructureResponse, which always + // supplies `nodeData: response.nodeData ?? {}`. That keeps the inner + // `if (response.nodeData)` arm firmly truthy. Calling the getter directly + // with an absent nodeData covers the falsy arm of all three optional fields. + + it('returns an empty map when called directly with every data field absent', () => { + const empty = { + data: {}, + storageData: {}, + timestamp: now(), + stats: { oldestDataTimestamp: now() }, + } as unknown as ChartsResponse; + const map = extractInfrastructureSummaryChartMap(empty); + expect(map.size).toBe(0); + }); + + it('returns an empty map when only nodeData is absent (covers the falsy nodeData arm directly)', () => { + // agentData/dockerHostData falsy arms are already reached via the wrapper, + // but the wrapper never forwards an absent nodeData, so this is the only + // way to take the `if (response.nodeData)` false branch. + const response = { + data: {}, + nodeData: undefined, + storageData: {}, + agentData: { 'agent-1': { cpu: makeSeries(1) } }, + timestamp: now(), + stats: { oldestDataTimestamp: now() }, + } as unknown as ChartsResponse; + const map = extractInfrastructureSummaryChartMap(response); + expect([...map.keys()]).toEqual(['agent-1']); + expect(map.get('agent-1')?.cpu?.length).toBe(1); + }); + + it('exposes ChartsResponse.data verbatim because the extractor ignores that field', () => { + // Documents a real-behaviour fact: data on the ChartsResponse is *not* + // indexed, only nodeData/agentData/dockerHostData are. A direct call with + // populated `data` and nothing else yields an empty map. + const response = { + data: { 'vm-1': { cpu: makeSeries(3) } }, + nodeData: {}, + storageData: {}, + timestamp: now(), + stats: { oldestDataTimestamp: now() }, + } as unknown as ChartsResponse; + expect(extractInfrastructureSummaryChartMap(response).size).toBe(0); + }); +}); + +describe('extractInfrastructureSummaryChartMapFromInfrastructureResponse — uncovered optional-forwarding arms', () => { + it('forwards an absent nodeData through the ?? and still extracts agent/docker data', () => { + // The `response.nodeData ?? {}` expression's right operand is only reached + // when nodeData is absent; the wrapper test only exercised populated nodeData. + const map = extractInfrastructureSummaryChartMapFromInfrastructureResponse( + makeInfraResponse({ + nodeData: undefined, + agentData: { 'agent-1': { memory: makeSeries(2) } }, + dockerHostData: { 'docker-1': { disk: makeSeries(2) } }, + }), + ); + expect([...map.keys()].sort()).toEqual(['agent-1', 'docker-1']); + expect(map.get('agent-1')?.memory?.length).toBe(2); + expect(map.get('docker-1')?.disk?.length).toBe(2); + }); +}); + +describe('readInfrastructureSummaryCache — series coercion coverage', () => { + beforeEach(() => { + setOrgID('default'); + localStorage.clear(); + }); + + it('coerces a non-array cpu field to an empty array (Array.isArray false arm for cpu)', () => { + // The sibling test sets `cpu: makeSeries(1)` so the cpu cond-expr's false + // arm stays open; here we store a non-array cpu and assert the coercion. + storePayload( + '1h', + buildPayload({ + charts: { + 'node-1': { + cpu: 'not-an-array', + memory: [], + disk: [], + diskread: [], + diskwrite: [], + netin: [], + netout: [], + }, + }, + }), + ); + const series = readInfrastructureSummaryCache('1h')?.map.get('node-1'); + expect(series?.cpu).toEqual([]); + }); + + it('coerces a non-array netout field to an empty array (Array.isArray false arm for netout)', () => { + // The sibling test sets `netout: []` which is still an array, leaving the + // netout cond-expr false arm uncovered. + storePayload( + '1h', + buildPayload({ + charts: { + 'node-1': { + cpu: [], + memory: [], + disk: [], + diskread: [], + diskwrite: [], + netin: [], + netout: { length: 5 }, + }, + }, + }), + ); + const series = readInfrastructureSummaryCache('1h')?.map.get('node-1'); + expect(series?.netout).toEqual([]); + }); + + it('returns null oldestDataTimestamp for a non-finite numeric value (Infinity)', () => { + // typeof Infinity === 'number' is true, so the only way to reach the null + // fallback past the typeof guard is a number that fails Number.isFinite. + storePayload( + '1h', + buildPayload({ + oldestDataTimestamp: Infinity as unknown as number, + charts: undefined, + }), + ); + expect(readInfrastructureSummaryCache('1h')?.oldestDataTimestamp).toBeNull(); + }); + + it('returns null oldestDataTimestamp for NaN', () => { + storePayload( + '1h', + buildPayload({ + oldestDataTimestamp: NaN, + charts: undefined, + }), + ); + expect(readInfrastructureSummaryCache('1h')?.oldestDataTimestamp).toBeNull(); + }); + + it('falls back to an empty entries list when charts is a non-object truthy value', () => { + // `parsed.charts && typeof parsed.charts === 'object'`: a truthy non-object + // (string) takes the && left-operand true / right-operand false path, + // producing [] rather than Object.entries throwing. + storePayload( + '1h', + buildPayload({ charts: 'not-an-object' as unknown as Record }), + ); + const cached = readInfrastructureSummaryCache('1h'); + expect(cached).not.toBeNull(); + expect(cached?.map.size).toBe(0); + }); + + it('treats a null charts field as the emptyentries branch', () => { + // null && ... -> false -> [] arm. Distinct from undefined (also falsy) and + // from the non-object truthy case above. + storePayload( + '1h', + buildPayload({ charts: null as unknown as undefined }), + ); + const cached = readInfrastructureSummaryCache('1h'); + expect(cached).not.toBeNull(); + expect(cached?.map.size).toBe(0); + }); +}); + +describe('readInfrastructureSummaryCache — hit / miss / expiry / empty boundaries', () => { + beforeEach(() => { + setOrgID('default'); + localStorage.clear(); + }); + + it('returns a cache hit with the exact cachedAt timestamp on the happy path', () => { + const cachedAt = now() - 1_000; + storePayload( + '1h', + buildPayload({ + cachedAt, + oldestDataTimestamp: now() - 5_000, + charts: { + 'node-1': { cpu: makeSeries(2), memory: [], disk: [], diskread: [], diskwrite: [], netin: [], netout: [] }, + }, + }), + ); + const hit = readInfrastructureSummaryCache('1h'); + expect(hit).not.toBeNull(); + expect(hit?.cachedAt).toBe(cachedAt); + expect(hit?.oldestDataTimestamp).toBe(now() - 5_000); + expect(hit?.map.get('node-1')?.cpu?.length).toBe(2); + }); + + it('returns a cache miss for a never-written key', () => { + expect(readInfrastructureSummaryCache('1h')).toBeNull(); + }); + + it('returns a cache hit when age equals maxAgeMs exactly (strict > boundary)', () => { + // `Date.now() - parsed.cachedAt > maxAgeMs` uses strict >, so an entry that + // is exactly maxAgeMs old must still be a hit (boundary is inclusive). + const maxAge = 60_000; + const cachedAt = now() - maxAge; + storePayload( + '1h', + buildPayload({ cachedAt, charts: undefined }), + ); + expect(readInfrastructureSummaryCache('1h', maxAge)).not.toBeNull(); + }); + + it('returns an empty map (cache hit, but empty) when charts is absent', () => { + storePayload('1h', buildPayload({ charts: undefined })); + const hit = readInfrastructureSummaryCache('1h'); + expect(hit).not.toBeNull(); + expect(hit?.map.size).toBe(0); + expect(hit?.oldestDataTimestamp).toBeNull(); + }); + + it('returns early with null when window is undefined', () => { + // Covers the `if (typeof window === 'undefined') return null;` true arm in + // readInfrastructureSummaryCache. + storePayload('1h', buildPayload({ charts: undefined })); + const hit = withGlobalUndefined('window', () => readInfrastructureSummaryCache('1h')); + expect(hit).toBeNull(); + }); +}); + +describe('persistInfrastructureSummaryCache — write-boundary coverage', () => { + beforeEach(() => { + setOrgID('default'); + localStorage.clear(); + }); + + it('returns early without writing when window is undefined', () => { + // Covers the `if (typeof window === 'undefined') return;` true arm. + localStorage.setItem('sentinel', 'keep-me'); + withGlobalUndefined('window', () => { + persistInfrastructureSummaryCache( + '1h', + new Map([['node-1', { cpu: makeSeries(2) }]]), + now() - 60_000, + ); + }); + // No infra cache key was created. + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k) keys.push(k); + } + expect(keys.some((k) => k.startsWith('pulse.infrastructureSummaryCharts.'))).toBe(false); + expect(localStorage.getItem('sentinel')).toBe('keep-me'); + }); + + it('uses an explicit orgScope argument rather than falling back to getOrgID()', () => { + // Covers the left operand of `orgScope ?? getOrgID()` in the persist path: + // when orgScope is supplied, getOrgID() must NOT be consulted, so the entry + // lands under the explicit scope even though the ambient org ID differs. + setOrgID('ambient-org'); + persistInfrastructureSummaryCache( + '1h', + new Map([['node-x', { cpu: makeSeries(1) }]]), + null, + 'explicit-scope', + ); + expect(localStorage.getItem(cacheKeyForRange('1h', 'explicit-scope'))).not.toBeNull(); + // Nothing landed under the ambient org scope. + expect(localStorage.getItem(cacheKeyForRange('1h', 'ambient-org'))).toBeNull(); + }); + + it('triggers the data.cpu ?? [] fallback in toCachedChartData (cpu field absent)', () => { + // The sibling test always supplies cpu, so the `?? []` right operand for + // cpu stays uncovered. Persist a ChartData without cpu and verify the + // cached entry round-trips with cpu === []. + persistInfrastructureSummaryCache( + '1h', + new Map([['node-1', { memory: makeSeries(2) }]]), + null, + ); + const series = readInfrastructureSummaryCache('1h')?.map.get('node-1'); + expect(series?.cpu).toEqual([]); + expect(series?.memory?.length).toBe(2); + }); +}); + +describe('countChartMapPoints — uncovered ?? 0 fallbacks via fetch', () => { + beforeEach(() => { + mockGetCharts.mockReset(); + __resetInfrastructureSummaryFetchesForTests(); + setOrgID('default'); + localStorage.clear(); + }); + + it('counts zero points for an entry whose cpu field is absent (data.cpu?.length ?? 0)', () => { + // The perf-log data object is built eagerly on every successful fetch, so + // countChartMapPoints runs even with perf disabled. Supplying a node with + // no cpu takes the `?? 0` right operand for the cpu line. + mockGetCharts.mockResolvedValueOnce( + makeInfraResponse({ + nodeData: { 'node-1': { memory: makeSeries(3) } }, + }), + ); + // Assertion is on the observable round-trip: the fetch resolved and the + // entry round-tripped through persist/read without dropping memory. + return fetchInfrastructureSummaryAndCache('1h').then((result) => { + expect(result.map.get('node-1')?.memory?.length).toBe(3); + expect(result.map.get('node-1')?.cpu).toBeUndefined(); + }); + }); +}); + +describe('fetchInfrastructureSummaryAndCache — error and caller edge arms', () => { + beforeEach(() => { + mockGetCharts.mockReset(); + __resetInfrastructureSummaryFetchesForTests(); + setOrgID('default'); + localStorage.clear(); + }); + + it('rethrows a non-Error rejection as-is after the String(error) fallback arm', () => { + // `error instanceof Error ? error.message : String(error)`: a string throw + // takes the false arm and String('boom') === 'boom' is fed to the perf log. + mockGetCharts.mockRejectedValueOnce('boom'); + return expect(fetchInfrastructureSummaryAndCache('1h')).rejects.toBe('boom'); + }); + + it('rethrows a numeric rejection (String(42) fallback shape)', () => { + mockGetCharts.mockRejectedValueOnce(42); + return expect(fetchInfrastructureSummaryAndCache('1h')).rejects.toBe(42); + }); + + it("falls back to 'unknown' when options.caller is an empty string (|| right arm)", () => { + // `options?.caller || 'unknown'`: empty string is falsy, so the right arm + // is taken. The fetch still resolves and persists a cache entry, which is + // the observable proof the call completed normally. + mockGetCharts.mockResolvedValueOnce(makeInfraResponse()); + return fetchInfrastructureSummaryAndCache('1h', { caller: '' }).then(() => { + expect(readInfrastructureSummaryCache('1h')).not.toBeNull(); + // Caller normalisation must not bleed into the persisted metrics list. + expect(mockGetCharts).toHaveBeenNthCalledWith(1, '1h', undefined, { + metrics: ['cpu', 'memory', 'disk', 'diskread', 'diskwrite', 'netin', 'netout'], + }); + }); + }); + + it('passes a null metrics option through normalize -> default metrics list', () => { + // `normalizeInfrastructureSummaryMetrics(null)`: Array.isArray(null) is + // false, so the !Array.isArray arm returns the default list rather than + // constructing from the (null) input. + mockGetCharts.mockResolvedValueOnce(makeInfraResponse()); + return fetchInfrastructureSummaryAndCache('1h', { metrics: null }).then(() => { + expect(mockGetCharts).toHaveBeenNthCalledWith(1, '1h', undefined, { + metrics: ['cpu', 'memory', 'disk', 'diskread', 'diskwrite', 'netin', 'netout'], + }); + }); + }); + + it('deduplicates a custom metric list (separate inFlightKey) from the default list', () => { + // Two different metric lists produce two different inFlightKeys, so they + // issue two distinct requests. This pins the inFlightKeyFor metrics + // participation: changing the metrics list must NOT be deduped against the + // default list. + mockGetCharts.mockResolvedValueOnce(makeInfraResponse()); + mockGetCharts.mockResolvedValueOnce(makeInfraResponse()); + return Promise.all([ + fetchInfrastructureSummaryAndCache('1h', { metrics: ['cpu'] }), + fetchInfrastructureSummaryAndCache('1h'), + ]).then(() => { + expect(mockGetCharts).toHaveBeenCalledTimes(2); + }); + }); +}); + +describe('infraSummaryPerfNow — Date.now() fallback arm', () => { + beforeEach(() => { + mockGetCharts.mockReset(); + __resetInfrastructureSummaryFetchesForTests(); + setOrgID('default'); + localStorage.clear(); + }); + + it('still records a finite ms duration when performance is unavailable', async () => { + // `typeof performance !== 'undefined' && typeof performance.now === 'function'`: + // hiding performance takes the && false arm, so infraSummaryPerfNow falls + // back to Date.now(). The fetch path calls perfNow at start and end, so a + // successful round-trip with performance masked proves the fallback works. + mockGetCharts.mockResolvedValueOnce( + makeInfraResponse({ nodeData: { 'n': { cpu: makeSeries(1) } } }), + ); + const result = await withGlobalUndefined('performance', () => + fetchInfrastructureSummaryAndCache('1h'), + ); + expect(result.map.get('n')?.cpu?.length).toBe(1); + }); +}); + +describe('org_switched cache invalidation handler — uncovered getter + branches', () => { + // The org_switched handler registered at module load (and its inner forEach + // callback) are never invoked by the existing tests. Emitting the event here + // exercises the whole invalidation closure: the `typeof window` guard, the + // key/prefix filter, the keysToRemove forEach, and the catch-all. + beforeEach(() => { + setOrgID('default'); + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('removes every prefixed cache entry on org_switched and preserves unrelated keys', () => { + const prefixedA = `${'pulse.infrastructureSummaryCharts.'}default::1h::${DEFAULT_METRICS_KEY}`; + const prefixedB = `${'pulse.infrastructureSummaryCharts.'}default::24h::cpu`; + const unrelated = 'pulse.somethingElse.key'; + localStorage.setItem(prefixedA, 'a'); + localStorage.setItem(prefixedB, 'b'); + localStorage.setItem(unrelated, 'c'); + + eventBus.emit('org_switched', 'new-org'); + + expect(localStorage.getItem(prefixedA)).toBeNull(); + expect(localStorage.getItem(prefixedB)).toBeNull(); + expect(localStorage.getItem(unrelated)).toBe('c'); + }); + + it('skips keys for which localStorage.key returns null (key && ... false arm)', () => { + // The && left operand is reachable only if a stored key is null; that never + // happens with the in-memory storage used in tests, so mock key() to force + // a null return and confirm the handler completes without pushing anything. + const prefixed = `${'pulse.infrastructureSummaryCharts.'}default::1h::${DEFAULT_METRICS_KEY}`; + localStorage.setItem(prefixed, 'a'); + // Stub Storage.prototype.key to return null for the first slot, which makes + // the `key && key.startsWith(...)` short-circuit on the && left operand. + const keySpy = vi.spyOn(Storage.prototype, 'key').mockReturnValue(null); + try { + expect(() => eventBus.emit('org_switched', 'newer-org')).not.toThrow(); + } finally { + keySpy.mockRestore(); + } + // Because key() returned null, the real prefixed entry was never matched + // and is still present. + expect(localStorage.getItem(prefixed)).toBe('a'); + }); + + it('no-ops when there are no prefixed entries to remove (empty keysToRemove)', () => { + // Covers the forEach callback's "zero iterations" path: keysToRemove is + // empty because no key starts with the prefix. + const unrelated = 'completely.unrelated'; + localStorage.setItem(unrelated, 'keep'); + expect(() => eventBus.emit('org_switched', 'empty-org')).not.toThrow(); + expect(localStorage.getItem(unrelated)).toBe('keep'); + }); + + it('does not throw when the localStorage iteration itself throws (catch arm)', () => { + // Covers the try/catch around the invalidation loop: if localStorage.length + // throws, the handler must swallow it and return silently. + const lengthSpy = vi + .spyOn(Storage.prototype, 'length', 'get') + .mockImplementation(() => { + throw new Error('storage poisoned'); + }); + try { + expect(() => eventBus.emit('org_switched', 'broken-org')).not.toThrow(); + } finally { + lengthSpy.mockRestore(); + } + }); + + it('returns early without iterating when window is undefined (typeof window guard true arm)', () => { + // Covers the `if (typeof window === 'undefined') return;` true arm of the + // org_switched handler. A prefixed entry must survive because the handler + // bails out before touching localStorage. + const prefixed = `${'pulse.infrastructureSummaryCharts.'}default::1h::${DEFAULT_METRICS_KEY}`; + localStorage.setItem(prefixed, 'survives'); + withGlobalUndefined('window', () => { + expect(() => eventBus.emit('org_switched', 'no-window-org')).not.toThrow(); + }); + expect(localStorage.getItem(prefixed)).toBe('survives'); + }); +}); + +describe('infraSummaryPerfLog — perf-enabled branches via module reload', () => { + // `infraSummaryPerfEnabled` is captured once at module load from + // `import.meta.env.DEV && import.meta.env.VITE_INFRA_SUMMARY_PERF === '1'`. + // Reaching the perf-log body therefore requires stubbing the env var AND + // re-evaluating the module (vi.resetModules + dynamic import) so the constant + // is re-read with the stub in place. The static top-level imports that every + // other describe block uses keep pointing at the original (perf-disabled) + // module instance, so this isolation does not leak. + + beforeEach(() => { + setOrgID('default'); + localStorage.clear(); + }); + + afterEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it('emits [InfraSummaryPerf] console.debug lines when VITE_INFRA_SUMMARY_PERF=1', async () => { + vi.stubEnv('VITE_INFRA_SUMMARY_PERF', '1'); + vi.resetModules(); + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + try { + const mod = await import('@/utils/infrastructureSummaryCache'); + mod.__resetInfrastructureSummaryFetchesForTests(); + mockGetCharts.mockReset(); + mockGetCharts.mockResolvedValueOnce( + makeInfraResponse({ nodeData: { 'n': { cpu: makeSeries(1) } } }), + ); + await mod.fetchInfrastructureSummaryAndCache('1h', { caller: 'perf-enabled' }); + + const prefixLines = debugSpy.mock.calls + .map((call) => (typeof call[0] === 'string' ? call[0] : '')) + .filter((msg) => msg.startsWith('[InfraSummaryPerf]')); + // fetch start, fetch done (data present -> truthy arm of `if (data)`). + expect(prefixLines.some((m) => m.endsWith('fetch start'))).toBe(true); + expect(prefixLines.some((m) => m.endsWith('fetch done'))).toBe(true); + // The data object was forwarded as the second console.debug argument. + const doneCall = debugSpy.mock.calls.find( + ([msg]) => typeof msg === 'string' && msg.endsWith('fetch done'), + ); + expect(doneCall?.[1]).toMatchObject({ caller: 'perf-enabled', range: '1h' }); + } finally { + debugSpy.mockRestore(); + } + }); + + it('logs the fetch-error path with a stringified error when perf is enabled', async () => { + // Covers the perf-log call inside .catch (still gated on perfEnabled) with + // a non-Error rejection so `error instanceof Error ? ... : String(error)` + // takes its String(error) arm under perf-enabled conditions. + vi.stubEnv('VITE_INFRA_SUMMARY_PERF', '1'); + vi.resetModules(); + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + try { + const mod = await import('@/utils/infrastructureSummaryCache'); + mod.__resetInfrastructureSummaryFetchesForTests(); + mockGetCharts.mockReset(); + mockGetCharts.mockRejectedValueOnce('perf-boom'); + await expect(mod.fetchInfrastructureSummaryAndCache('1h')).rejects.toBe('perf-boom'); + + const errorLine = debugSpy.mock.calls + .map((call) => (typeof call[0] === 'string' ? call[0] : '')) + .find((msg) => msg.endsWith('fetch error')); + expect(errorLine).toBeDefined(); + const dataArg = debugSpy.mock.calls.find( + ([msg]) => typeof msg === 'string' && msg.endsWith('fetch error'), + )?.[1]; + expect(dataArg).toMatchObject({ error: 'perf-boom' }); + } finally { + debugSpy.mockRestore(); + } + }); +}); + +describe('readInfrastructureSummaryCache — read failure path', () => { + beforeEach(() => { + setOrgID('default'); + localStorage.clear(); + }); + + it('swallows a localStorage.getItem failure and returns null (catch arm)', () => { + // Covers the outer try/catch in readInfrastructureSummaryCache: a throw out + // of getItem must be swallowed and surface as a null result. + const getItemSpy = vi + .spyOn(Storage.prototype, 'getItem') + .mockImplementation(() => { + throw new Error('getItem exploded'); + }); + try { + expect(readInfrastructureSummaryCache('1h')).toBeNull(); + } finally { + getItemSpy.mockRestore(); + } + }); +}); + +describe('persistInfrastructureSummaryCache — JSON.stringify failure path', () => { + beforeEach(() => { + setOrgID('default'); + localStorage.clear(); + }); + + it('swallows a JSON.stringify cycle error without throwing', () => { + // Build a value that JSON.stringify cannot serialize (a self-referencing + // object smuggled in as a ChartData field). The try/catch around the + // persist body must swallow the TypeError. + const cyclic = { cpu: [] as MetricPoint[] } as unknown as ChartData; + (cyclic as { self: unknown }).self = cyclic; + expect(() => + persistInfrastructureSummaryCache( + '1h', + new Map([['node-1', cyclic]]), + null, + ), + ).not.toThrow(); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/resourceBadgePresentation.branchcov0717.test.ts b/frontend-modern/src/utils/__tests__/resourceBadgePresentation.branchcov0717.test.ts new file mode 100644 index 0000000000..fc15e00e4c --- /dev/null +++ b/frontend-modern/src/utils/__tests__/resourceBadgePresentation.branchcov0717.test.ts @@ -0,0 +1,958 @@ +import { describe, expect, it } from 'vitest'; + +import type { Resource } from '@/types/resource'; +import { + getContainerRuntimeBadgeForRuntime, + getInfrastructurePlatformBadges, + getInfrastructureSystemIdentityBadges, + getInfrastructureSystemIdentitySortLabel, + getInfrastructureSystemTitleBadges, + getPlatformBadge, + getSourceBadge, + getTypeBadge, + getUnifiedSourceBadges, + dedupeResourceBadges, +} from '@/utils/resourceBadgePresentation'; + +// Module-private helpers (`titleFromParts`, `withBadgeVersion`, `normalizeVersion`, +// `buildUnifiedSourceBadges`, `deriveSourceKeysFromFacets`, `getKnownHostIdentitySource`, +// `getHostIdentityAgentProfile`, `getHostIdentityPlatform`, `getAgentSystemIdentityBadge`, +// `getDockerHostOsIdentityBadge`, `getAvailabilitySystemIdentityBadge`, +// `getStorageSystemIdentityBadge`, `getStoragePlatformSource`, `getSystemSourceVersion`, +// `getVersionedSourceBadge`, `proxmoxLxcDockerBadge`, `proxmoxLxcDockerVmid`, +// `badgeIdentityLabels`) are exercised transitively through the exported entry points +// below, asserting on their observable outputs. + +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; + +const agentOnly = (agent: Record): Partial => ({ + type: 'agent', + platformType: 'agent', + sourceType: 'agent', + sources: ['agent'], + platformData: { sources: ['agent'], agent }, +}); + +describe('getPlatformBadge — branch coverage', () => { + it('returns null when platformType is absent', () => { + expect(getPlatformBadge()).toBeNull(); + expect(getPlatformBadge(undefined)).toBeNull(); + expect(getPlatformBadge('' as never)).toBeNull(); + }); + + it('renders the dedicated availability badge for the availability platform type', () => { + const badge = getPlatformBadge('availability'); + expect(badge).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('renders shared presentation badges for recognized platform types', () => { + expect(getPlatformBadge('kubernetes')?.label).toBe('K8s'); + expect(getPlatformBadge('truenas')?.label).toBe('TrueNAS'); + expect(getPlatformBadge('vmware-vsphere')?.label).toBe('vSphere'); + }); +}); + +describe('getSourceBadge — branch coverage', () => { + it('returns null when sourceType is absent', () => { + expect(getSourceBadge(undefined)).toBeNull(); + expect(getSourceBadge('' as never)).toBeNull(); + }); + + it('renders the canonical badge for a known source type', () => { + const badge = getSourceBadge('api'); + expect(badge?.label).toBe('API'); + expect(badge?.classes).toContain('bg-blue-100'); + expect(badge?.title).toBe('api'); + }); + + it('falls back to the raw label and neutral tone for an unrecognized source type', () => { + const badge = getSourceBadge('custom-collector' as never); + expect(badge?.label).toBe('custom-collector'); + expect(badge?.classes).toContain('bg-surface-alt'); + expect(badge?.title).toBe('custom-collector'); + }); +}); + +describe('getTypeBadge — branch coverage', () => { + it('returns null when resourceType is absent', () => { + expect(getTypeBadge(undefined)).toBeNull(); + expect(getTypeBadge('')).toBeNull(); + expect(getTypeBadge(null as unknown as string)).toBeNull(); + }); + + it('renders a canonical label for a known resource type', () => { + expect(getTypeBadge('pbs')?.label).toBe('PBS'); + }); + + it('maps a legacy/external alias to its presentation', () => { + expect(getTypeBadge('proxmox-lxc')?.label).toBe('LXC'); + }); + + it('falls back to a default presentation for an unrecognized type string', () => { + const badge = getTypeBadge('totally-fake-type'); + expect(badge?.label).toBe('totally-fake-type'); + expect(badge?.title).toBe('totally-fake-type'); + expect(badge?.classes).toContain('bg-surface-alt'); + }); +}); + +describe('getUnifiedSourceBadges / normalizeUnifiedSourceKeys — branch coverage', () => { + it('returns an empty array for nullish or empty input', () => { + expect(getUnifiedSourceBadges(null)).toEqual([]); + expect(getUnifiedSourceBadges(undefined)).toEqual([]); + expect(getUnifiedSourceBadges([])).toEqual([]); + }); + + it('drops values that do not normalize to a known platform', () => { + expect(getUnifiedSourceBadges(['totally-unknown-source'])).toEqual([]); + }); + + it('maps canonical aliases and dedupes to a single badge per platform', () => { + expect(getUnifiedSourceBadges(['pve', 'proxmox', 'pbs', 'pmg']).map((b) => b.label)).toEqual([ + 'PVE', + 'PBS', + 'PMG', + ]); + }); + + it('normalizes vmware, k8s, and hyper-v aliases', () => { + expect(getUnifiedSourceBadges(['vmware', 'k8s', 'hyper-v']).map((b) => b.label)).toEqual([ + 'vSphere', + 'K8s', + 'Hyper-V', + ]); + }); + + 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 source normalizes to a known platform', () => { + expect(getInfrastructurePlatformBadges([])).toEqual([]); + expect(getInfrastructurePlatformBadges(undefined)).toEqual([]); + expect(getInfrastructurePlatformBadges(['totally-unknown-source'])).toEqual([]); + }); + + it('passes a single source straight through', () => { + expect(getInfrastructurePlatformBadges(['docker']).map((b) => b.label)).toEqual([ + 'Docker / Podman', + ]); + }); + + it('keeps every non-agent platform source when multiple platforms remain', () => { + expect(getInfrastructurePlatformBadges(['docker', 'kubernetes']).map((b) => b.label)).toEqual([ + 'Docker / Podman', + 'K8s', + ]); + }); +}); + +describe('getContainerRuntimeBadgeForRuntime / getContainerRuntimeTone — branch coverage', () => { + it('returns null for an empty or whitespace-only runtime', () => { + expect(getContainerRuntimeBadgeForRuntime('')).toBeNull(); + expect(getContainerRuntimeBadgeForRuntime(' ')).toBeNull(); + expect(getContainerRuntimeBadgeForRuntime(null)).toBeNull(); + expect(getContainerRuntimeBadgeForRuntime(undefined)).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('selects the podman tone for a Podman runtime', () => { + const badge = getContainerRuntimeBadgeForRuntime('podman'); + expect(badge?.label).toBe('Podman'); + expect(badge?.title).toBe('Runtime: Podman'); + expect(badge?.classes).toContain('bg-violet-100'); + }); + + it('falls back to the neutral tone for an unrecognized runtime label', () => { + const badge = getContainerRuntimeBadgeForRuntime('containerd'); + expect(badge?.label).toBe('containerd'); + expect(badge?.classes).toContain('bg-surface-alt'); + expect(badge?.classes).toContain('text-base-content'); + }); +}); + +describe('dedupeResourceBadges — branch coverage', () => { + it('drops nullish entries, empty labels, and duplicate identities', () => { + expect( + dedupeResourceBadges([ + null, + { label: 'PVE', classes: 'c' }, + { label: 'pve', classes: 'c' }, + { label: ' ', classes: 'c' }, + undefined, + ]), + ).toEqual([{ label: 'PVE', classes: 'c' }]); + }); +}); + +describe('badgeIdentityLabels via getInfrastructureSystemTitleBadges — branch coverage', () => { + it('treats a system badge with no title as its label-only identity (value ? ... : "" arm)', () => { + // badgeIdentityLabels maps [badge.label, badge.title]; when title is undefined + // the `(value ? normalizeBadgeIdentityLabel(value) : '')` ternary takes the "" + // arm and the entry is dropped by filter(Boolean). The label identity survives, + // so a source badge sharing it is deduped away. + const result = getInfrastructureSystemTitleBadges( + [{ label: 'PVE', classes: 'type-cls' }], + [{ label: 'pve', classes: 'type-cls', title: 'pve' }], + ); + expect(result).toEqual([{ label: 'PVE', classes: 'type-cls' }]); + }); + + it('retains a source badge whose identity is absent from the system identity set', () => { + const result = getInfrastructureSystemTitleBadges( + [{ label: 'PVE', classes: 'type-cls' }], + [{ label: 'K8s', classes: 'type-cls', title: 'K8s' }], + ); + expect(result.map((b) => b.label)).toEqual(['PVE', 'K8s']); + }); +}); + +describe('withBadgeVersion via getInfrastructureSystemIdentityBadges — branch coverage', () => { + it('strips the Proxmox pve-manager wrapper format down to the dotted version', () => { + const badges = getInfrastructureSystemIdentityBadges( + makeResource( + agentOnly({ + platform: 'debian', + osName: 'Proxmox VE', + osVersion: 'pve-manager/9.1.9/ee7bad0a3d1546c9', + }), + ), + ); + expect(badges.map((b) => b.label)).toEqual(['PVE 9.1.9']); + }); + + it.each(['unknown', 'n/a', 'na', 'none', '-'] as const)( + 'treats sentinel version %s as empty so no suffix is appended', + (sentinelVersion) => { + const badges = getInfrastructureSystemIdentityBadges( + makeResource( + agentOnly({ + platform: 'linux', + osName: 'TrueNAS SCALE', + osVersion: sentinelVersion, + }), + ), + ); + expect(badges.map((b) => b.label)).toEqual(['TrueNAS']); + }, + ); + + it('keeps the title unchanged when the version already appears in it (? title arm)', () => { + // The agent OS-label branch builds a title containing the version; withBadgeVersion + // then detects the version substring in the title and reuses it verbatim rather + // than appending a duplicate. + const badges = getInfrastructureSystemIdentityBadges( + makeResource( + agentOnly({ + platform: 'linux', + osName: 'Ubuntu 24.04', + osVersion: '24.04', + }), + ), + ); + expect(badges[0]?.label).toBe('Ubuntu 24.04'); + expect(badges[0]?.title).toBe('Ubuntu 24.04 24.04'); + }); +}); + +describe('getAgentSystemIdentityBadge via getInfrastructureSystemIdentityBadges — branch coverage', () => { + it('resolves an exact agent host profile id (exactProfile return arm)', () => { + const badges = getInfrastructureSystemIdentityBadges( + makeResource( + agentOnly({ + hostProfile: 'unraid', + osName: 'Unraid', + osVersion: '7.3.0', + }), + ), + ); + expect(badges.map((b) => b.label)).toEqual(['Unraid 7.3.0']); + }); + + it('falls back to the badge title in the tooltip when osName is empty (hostProfile branch || arm)', () => { + // `titleFromParts(osName || badge?.title || profileFamily || label, osVersion)`: + // empty osName makes the `osName ||` operand falsy so evaluation proceeds to + // `badge?.title`, but the badge still resolves. + const badges = getInfrastructureSystemIdentityBadges( + makeResource( + agentOnly({ + hostProfile: 'unraid', + osName: '', + osVersion: '', + }), + ), + ); + expect(badges.map((b) => b.label)).toEqual(['Unraid']); + expect(badges[0]?.title).toBe('Unraid'); + }); + + it('derives a known source from a raw platform key via normalizeSourcePlatformKey', () => { + const badges = getInfrastructureSystemIdentityBadges( + makeResource( + agentOnly({ + platform: 'linux', + osName: 'truenas', + }), + ), + ); + expect(badges.map((b) => b.label)).toEqual(['TrueNAS']); + }); + + it('falls back to the badge title in the tooltip when osName is empty (knownSource branch || arm)', () => { + // `titleFromParts(osName || badge.title, osVersion)`: no hostProfile, empty osName, + // but the platform key alone resolves a known source. + const badges = getInfrastructureSystemIdentityBadges( + makeResource( + agentOnly({ + platform: 'truenas', + osName: '', + }), + ), + ); + expect(badges.map((b) => b.label)).toEqual(['TrueNAS']); + expect(badges[0]?.title).toBe('TrueNAS'); + }); + + it('uses the second identity value when the first does not match', () => { + const badges = getInfrastructureSystemIdentityBadges( + makeResource( + agentOnly({ + platform: 'unknown-os', + osName: 'truenas', + }), + ), + ); + expect(badges.map((b) => b.label)).toEqual(['TrueNAS']); + }); + + it('matches an agent host profile identity token in the osName', () => { + const badges = getInfrastructureSystemIdentityBadges( + makeResource( + agentOnly({ + platform: 'unknown-os', + osName: 'my unraid server', + }), + ), + ); + expect(badges.map((b) => b.label)).toEqual(['Unraid']); + }); + + it('matches a source platform display token in the osName', () => { + const badges = getInfrastructureSystemIdentityBadges( + makeResource( + agentOnly({ + platform: 'linux', + osName: 'Synology DSM', + }), + ), + ); + expect(badges.map((b) => b.label)).toEqual(['Synology']); + }); + + it('derives the OS label from platform when osName is empty (osLabel branch)', () => { + const badges = getInfrastructureSystemIdentityBadges( + makeResource( + agentOnly({ + platform: 'freebsd', + osName: '', + }), + ), + ); + expect(badges.map((b) => b.label)).toEqual(['FreeBSD']); + }); + + it.each([ + ['Ubuntu 24.04.2 LTS', 'Ubuntu'], + ['Debian GNU/Linux 12', 'Debian'], + ['Rocky Linux 9.3', 'Rocky'], + ['Alpine Linux 3.19', 'Alpine'], + ['AlmaLinux 9.3', 'AlmaLinux'], + ['CentOS Stream 9', 'CentOS'], + ['RHEL 9.4', 'RHEL'], + ['Arch Linux', 'Arch'], + ['openSUSE Tumbleweed', 'openSUSE'], + ['SUSE Linux Enterprise', 'SUSE'], + ['macOS Sonoma 14.4', 'macOS'], + ['Windows Server 2022', 'Windows'], + ['FreeBSD 14.1', 'FreeBSD'], + ['Linux Mint 22', 'Linux'], + ] as const)('derives the OS label %s from osName via hostOsLabelPatterns', (osName, expected) => { + const badges = getInfrastructureSystemIdentityBadges( + makeResource( + agentOnly({ + platform: 'linux', + osName, + }), + ), + ); + expect(badges.map((b) => b.label)).toEqual([expected]); + }); + + it('excludes docker/generic from host identity and falls through to the platform badge', () => { + const badges = getInfrastructureSystemIdentityBadges( + makeResource( + agentOnly({ + platform: 'generic', + osName: 'generic host', + }), + ), + ); + expect(badges.map((b) => b.label)).toEqual(['Agent']); + }); +}); + +describe('getAvailabilitySystemIdentityBadge via getInfrastructureSystemIdentityBadges — branch coverage', () => { + const endpoint = ( + availability: Record, + overrides: Partial = {}, + ): Resource => + makeResource({ + type: 'network-endpoint', + platformType: 'generic', + sourceType: 'api', + sources: ['availability'], + platformData: { sources: ['availability'], availability }, + ...overrides, + }); + + it('maps the icmp protocol to an uppercased label', () => { + const badges = getInfrastructureSystemIdentityBadges( + endpoint({ protocol: 'icmp', address: 'host-a' }), + ); + expect(badges.map((b) => b.label)).toEqual(['ICMP']); + expect(badges[0]?.title).toBe('ICMP availability probe host-a'); + }); + + it('maps tcp with a port to a TCP label and appends the port to the title', () => { + const badges = getInfrastructureSystemIdentityBadges( + endpoint({ protocol: 'tcp', address: 'host-a', port: 22 }), + ); + expect(badges.map((b) => b.label)).toEqual(['TCP']); + expect(badges[0]?.title).toBe('TCP availability probe host-a:22'); + }); + + it('drops the port suffix when the port is zero', () => { + const badges = getInfrastructureSystemIdentityBadges( + endpoint({ protocol: 'tcp', address: 'host-a', port: 0 }), + ); + expect(badges[0]?.title).toBe('TCP availability probe host-a'); + }); + + it('maps http and https to uppercased labels without a port when none is set', () => { + const http = getInfrastructureSystemIdentityBadges(endpoint({ protocol: 'http' })); + expect(http.map((b) => b.label)).toEqual(['HTTP']); + expect(http[0]?.title).toBe('HTTP availability probe'); + const https = getInfrastructureSystemIdentityBadges(endpoint({ protocol: 'https' })); + expect(https.map((b) => b.label)).toEqual(['HTTPS']); + }); + + it('uppercases an unrecognized non-empty protocol', () => { + const badges = getInfrastructureSystemIdentityBadges(endpoint({ protocol: 'snmp' })); + expect(badges.map((b) => b.label)).toEqual(['SNMP']); + }); + + it('falls back to the Probe label when the protocol is empty', () => { + const badges = getInfrastructureSystemIdentityBadges(endpoint({ address: 'host-a' })); + expect(badges.map((b) => b.label)).toEqual(['Probe']); + expect(badges[0]?.title).toBe('Probe availability probe host-a'); + }); + + it('treats a raw availability source as an endpoint even without an availability facet', () => { + const badges = getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'agent', + platformType: 'agent', + sourceType: 'api', + sources: ['availability'], + platformData: { sources: ['availability'] }, + }), + ); + expect(badges.map((b) => b.label)).toEqual(['Probe']); + expect(badges[0]?.title).toBe('Probe availability probe'); + }); +}); + +describe('getStorageSystemIdentityBadge / getStoragePlatformSource via getInfrastructureSystemIdentityBadges — branch coverage', () => { + it('versions a TrueNAS storage identity and surfaces the topology in the title', () => { + 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('reads the storage platform from platformData.platform for a storage-typed resource', () => { + const badges = getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'storage', + platformType: 'proxmox-pbs', + sourceType: 'api', + sources: ['pbs'], + platformData: { + sources: ['pbs'], + storage: { type: 'pbs-datastore', topology: 'datastore' }, + platform: 'pbs', + pbs: { version: 2 }, + }, + }), + ); + expect(badges.map((b) => b.label)).toEqual(['PBS 2']); + }); + + it('resolves the docker version arm when the storage platform is docker', () => { + const badges = getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'storage', + platformType: 'docker', + sourceType: 'agent', + sources: ['docker'], + platformData: { + sources: ['docker'], + storage: { platform: 'docker', type: 'docker-volume' }, + docker: { runtimeVersion: '24.0.7' }, + }, + }), + ); + expect(badges.map((b) => b.label)).toEqual(['Docker / Podman 24.0.7']); + }); + + 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('returns null for an unrecognized storage platform and falls through', () => { + const badges = getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'storage', + platformType: 'agent', + sourceType: 'agent', + sources: ['agent'], + platformData: { + sources: ['agent'], + storage: { platform: 'mystery-nas', type: 'mystery-array' }, + }, + }), + ); + expect(badges.map((b) => b.label)).toEqual(['Agent']); + }); + + it('skips platformData.platform for a non-storage resource type (ternary "" arm)', () => { + // An agent-shaped resource carrying a storage facet with no platform: the ternary + // takes the "" arm (type is 'agent') so storagePlatform resolves to null and the + // storage identity badge is skipped, letting the caller fall through. + const badges = getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'agent', + platformType: 'agent', + sourceType: 'agent', + sources: ['agent'], + platformData: { + sources: ['agent'], + storage: { type: 'mystery-array' }, + platform: 'should-be-ignored', + }, + }), + ); + expect(badges.map((b) => b.label)).toEqual(['Agent']); + }); +}); + +describe('getSystemSourceVersion via getInfrastructureSystemIdentityBadges — branch coverage', () => { + it('versions PBS, PMG, vSphere, and K8s identities from their 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 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('falls back to platformData.version for proxmox-pmg when the pmg facet omits version', () => { + // PMG case: `getRecordVersion(pmg, 'version') || getRecordVersion(platformData, 'version')` + // — the pmg facet has no version so the `||` evaluates the right operand. + const badges = getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'pmg', + platformType: 'proxmox-pmg', + sourceType: 'api', + sources: ['pmg'], + platformData: { + sources: ['pmg'], + pmg: { hostname: 'mailgw-1' }, + version: '9.1.2', + }, + }), + ); + expect(badges.map((b) => b.label)).toEqual(['PMG 9.1.2']); + }); + + it('falls back to the platform facet version in the default case when the source facet omits it', () => { + // Default case: `getRecordVersion(sourceRecord, ...) || getRecordVersion(platformRecord, ...)`. + // A presentation-only platform (synology-dsm) routes here; its own facet lacks a + // version, so the `||` evaluates the platform-record operand. + const badges = getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'agent', + platformType: 'agent', + sourceType: 'api', + sources: ['synology-dsm'], + platformData: { + sources: ['synology-dsm'], + 'synology-dsm': { note: 'no version here' }, + platform: { version: '7.2.1' }, + }, + }), + ); + expect(badges.map((b) => b.label)).toEqual(['Synology 7.2.1']); + }); + + it('drops the version suffix when neither the source facet nor the platform facet carries one', () => { + const badges = getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'agent', + platformType: 'agent', + sourceType: 'api', + sources: ['microsoft-hyperv'], + platformData: { + sources: ['microsoft-hyperv'], + 'microsoft-hyperv': { note: 'no version here' }, + platform: { note: 'also no version' }, + }, + }), + ); + expect(badges.map((b) => b.label)).toEqual(['Hyper-V']); + }); + + it('falls back to platformData.pveVersion for proxmox-pve when the proxmox facet omits it', () => { + // PVE case: `getRecordVersion(proxmox,'pveVersion','version') || getRecordVersion(platformData,'pveVersion')` + // — the proxmox facet has no version so the `||` evaluates the platformData operand. + const badges = getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'agent', + platformType: 'proxmox-pve', + sourceType: 'api', + sources: ['proxmox'], + platformData: { + sources: ['proxmox'], + proxmox: { hostname: 'pve-1' }, + pveVersion: '8.2.4', + }, + }), + ); + expect(badges.map((b) => b.label)).toEqual(['PVE 8.2.4']); + }); + + it('falls back to the agent osVersion for proxmox-pve when no platform version is present', () => { + // PVE case third operand: both version records are empty, so the `||` evaluates + // getAgentPlatformVersion, which proceeds because the agent identity matches the + // resolved source. + const badges = getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'agent', + platformType: 'proxmox-pve', + sourceType: 'api', + sources: ['proxmox'], + platformData: { + sources: ['proxmox'], + proxmox: { hostname: 'pve-1' }, + agent: { platform: 'proxmox-pve', osVersion: '8.2.4' }, + }, + }), + ); + expect(badges.map((b) => b.label)).toEqual(['PVE 8.2.4']); + }); + + it('falls back to platformData.version for proxmox-pbs when the pbs facet omits version', () => { + // PBS case: `getRecordVersion(pbs, 'version') || getRecordVersion(platformData, 'version')`. + const badges = getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'pbs', + platformType: 'proxmox-pbs', + sourceType: 'api', + sources: ['pbs'], + platformData: { + sources: ['pbs'], + pbs: { hostname: 'pbs-1' }, + version: '3.3.2', + }, + }), + ); + expect(badges.map((b) => b.label)).toEqual(['PBS 3.3.2']); + }); +}); + +describe('getHostIdentityAgentProfile / getAgentIdentitySource via getInfrastructureSystemIdentityBadges — branch coverage', () => { + it('resolves an exact agent host profile id passed as the osName (exactProfile return arm)', () => { + // getKnownHostIdentitySource feeds 'unraid' to getHostIdentityAgentProfile, which + // resolves an exact manifest entry and returns it before the token-search fallback. + const badges = getInfrastructureSystemIdentityBadges( + makeResource( + agentOnly({ + platform: '', + osName: 'unraid', + }), + ), + ); + expect(badges.map((b) => b.label)).toEqual(['Unraid']); + }); + + it('skips a non-agent-profile hostProfile and falls back to platformData.osVersion for unraid', () => { + // getAgentIdentitySource sees hostProfile 'proxmox-pve' (not an agent host profile), + // so the `if (profile && getSourcePlatformManifestEntry(profile.id))` guard is false + // and it falls through; getAgentPlatformVersion then returns '' for the 'unraid' + // source because the resolved identity differs, so the unraid case falls back to + // platformData.osVersion. + const badges = getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'storage', + platformType: 'agent', + sourceType: 'agent', + sources: ['agent'], + platformData: { + sources: ['agent'], + storage: { platform: 'unraid' }, + agent: { hostProfile: 'proxmox-pve', platform: 'linux' }, + osVersion: '6.9.0', + }, + }), + ); + expect(badges.map((b) => b.label)).toEqual(['Unraid 6.9.0']); + }); +}); + +describe('deriveSourceKeysFromFacets via getInfrastructureSystemIdentityBadges — branch coverage', () => { + it('derives the system source from a proxmox facet when no explicit sources are set', () => { + // resource.sources is absent, so rawSources is built from platformData.sources + // plus deriveSourceKeysFromFacets; the proxmox facet pushes 'proxmox' which then + // resolves the PVE version from pveVersion. + const badges = getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'agent', + platformType: 'proxmox-pve', + sourceType: 'api', + platformData: { + sources: [], + proxmox: { pveVersion: 'pve-manager/9.1.9/ee7bad0a3d1546c9' }, + }, + }), + ); + expect(badges.map((b) => b.label)).toEqual(['PVE 9.1.9']); + }); +}); + +describe('getDockerHostOsIdentityBadge via getInfrastructureSystemIdentityBadges — branch coverage', () => { + const dockerHost = (docker: Resource['docker']): Resource => + makeResource({ + type: 'docker-host', + platformType: 'docker', + sourceType: 'agent', + sources: ['docker'], + platformData: { sources: ['docker'] }, + docker, + }); + + it('maps a docker OS string that matches a governed platform to the shared badge', () => { + const badges = getInfrastructureSystemIdentityBadges( + dockerHost({ os: 'TrueNAS SCALE', runtime: 'docker' }), + ); + expect(badges.map((b) => b.label)).toEqual(['TrueNAS']); + }); + + it('derives a host OS label from docker metadata when no known source matches', () => { + const badges = getInfrastructureSystemIdentityBadges( + dockerHost({ os: 'Ubuntu 24.04.2 LTS', runtime: 'docker' }), + ); + expect(badges.map((b) => b.label)).toEqual(['Ubuntu']); + expect(badges[0]?.title).toBe('Ubuntu 24.04.2 LTS'); + }); + + it('falls back to the docker runtime badge when the docker OS string is unrecognized', () => { + const badges = getInfrastructureSystemIdentityBadges( + dockerHost({ os: 'acme-custom-os', runtime: 'docker' }), + ); + expect(badges.map((b) => b.label)).toEqual(['Docker / Podman']); + }); +}); + +describe('proxmoxLxcDockerBadge / 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('produces no LXC badge when docker.hostSourceId is a non-LXC string (startsWith false arm)', () => { + // A string hostSourceId that is not the LXC prefix takes the early-return arm, so + // no LXC badge is emitted and the docker runtime badge is used instead. + const badges = getInfrastructureSystemIdentityBadges( + makeResource({ + type: 'docker-host', + platformType: 'docker', + sourceType: 'agent', + sources: ['docker'], + docker: { hostSourceId: 'docker://box-01', runtime: 'docker' }, + }), + ); + expect(badges.map((b) => b.label)).toEqual(['Docker / Podman']); + }); +}); + +describe('getInfrastructureSystemIdentityBadges — end-to-end 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']); + }); + + it('returns an empty badge set when nothing resolves', () => { + const resource = makeResource({ + id: 'lonely', + name: 'lonely', + type: 'vm', + platformType: undefined, + sourceType: 'api', + sources: [], + }); + expect(getInfrastructureSystemIdentityBadges(resource)).toEqual([]); + }); +}); + +describe('getInfrastructureSystemIdentitySortLabel — branch coverage', () => { + it('returns the first identity badge label when one resolves', () => { + const resource = makeResource({ + type: 'k8s-node', + platformType: 'kubernetes', + sourceType: 'api', + sources: ['kubernetes'], + platformData: { sources: ['kubernetes'], kubernetes: { version: '1.28.4' } }, + }); + expect(getInfrastructureSystemIdentitySortLabel(resource)).toBe('K8s 1.28.4'); + }); + + it('returns the platform badge label when no system identity is found', () => { + const resource = makeResource({ + type: 'agent', + platformType: 'truenas', + sourceType: 'agent', + platformData: { sources: ['agent'] }, + }); + expect(getInfrastructureSystemIdentitySortLabel(resource)).toBe('TrueNAS'); + }); + + it('returns "" when no identity, no platform badge, and no platformType resolve', () => { + const resource = makeResource({ + type: 'storage', + platformType: '' as Resource['platformType'], + sourceType: 'agent', + sources: [], + }); + expect(getInfrastructureSystemIdentitySortLabel(resource)).toBe(''); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/workloadsSummaryCache.branchcov0717.test.ts b/frontend-modern/src/utils/__tests__/workloadsSummaryCache.branchcov0717.test.ts new file mode 100644 index 0000000000..205ef4fe86 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/workloadsSummaryCache.branchcov0717.test.ts @@ -0,0 +1,756 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MetricPoint, TimeRange, WorkloadChartsResponse } from '@/api/charts'; +import { eventBus } from '@/stores/events'; +import { + __resetWorkloadsSummaryCacheForTests, + fetchWorkloadsSummaryAndCache, + hasFreshWorkloadsSummaryCache, + persistWorkloadsSummaryCache, + readInMemoryWorkloadsSummaryCache, + readWorkloadsSummaryCache, + workloadsSummaryCacheScopeKey, +} from '@/utils/workloadsSummaryCache'; + +// Module-private helpers (`normalizeNodeScope`, `normalizeMaxPoints`, +// `resolveOrgScope`, `storageCacheKeyFor`, `trimPoints`, `toCachedChartData`, +// `writeInMemoryWorkloadsSummaryCache`) are not exported, so they are exercised +// transitively through the exported entry points below, asserting on their +// observable outputs (cache keys, API call args, trimmed persisted arrays). + +const mockGetWorkloadCharts = vi.fn(); + +vi.mock('@/api/charts', async () => { + const actual = await vi.importActual('@/api/charts'); + return { + ...actual, + ChartsAPI: { + ...actual.ChartsAPI, + getWorkloadCharts: (...args: unknown[]) => mockGetWorkloadCharts(...args), + }, + }; +}); + +// `getOrgID` is the fallback inside `resolveOrgScope` (`orgScope ?? getOrgID()`). +// Mocking it to a sentinel lets us pin the nullish-orgScope arm deterministically. +vi.mock('@/utils/apiClient', () => ({ + getOrgID: () => 'test-org', +})); + +// NOTE: `@/stores/events` is deliberately NOT mocked so the module's real +// `eventBus.on('org_switched', ...)` side-effect registers, letting us exercise +// the org-switch cache-invalidation handler by emitting the event. + +const ORG_FROM_GETORGID = 'test-org'; +const VERSION = 6; +const PREFIX = 'pulse.workloadsSummaryCharts.'; + +// Mirrors the module's private `resolveOrgScope` (with the mocked getOrgID) for +// setup-only key construction; assertions are on observable values, never on +// this helper's output. +const resolveOrgScopeForSetup = (orgScope?: string | null): string => { + const raw = orgScope ?? ORG_FROM_GETORGID; + const trimmed = (raw || '').trim(); + return trimmed || 'default'; +}; + +const storageKeyFor = ( + range: TimeRange, + normalizedNodeScope: string, + orgScope?: string | null, +): string => + `${PREFIX}${encodeURIComponent(resolveOrgScopeForSetup(orgScope))}::${range}::${encodeURIComponent(normalizedNodeScope || '__all__')}`; + +const metric = (value: number): MetricPoint => ({ timestamp: value, value }); + +const points = (n: number): MetricPoint[] => + Array.from({ length: n }, (_, i) => metric(i)); + +const makeResponse = (cpu?: MetricPoint[]): WorkloadChartsResponse => ({ + data: { + w1: { + cpu: cpu ?? [metric(1), metric(2)], + memory: [metric(3), metric(4)], + disk: [metric(5), metric(6)], + diskread: [metric(7), metric(8)], + diskwrite: [metric(9), metric(10)], + netin: [metric(11), metric(12)], + netout: [metric(13), metric(14)], + }, + }, + dockerData: {}, + guestTypes: {}, + timestamp: 1, + stats: { oldestDataTimestamp: 1 }, +}); + +const emptyResponse = (): WorkloadChartsResponse => ({ + data: {}, + dockerData: {}, + guestTypes: {}, + timestamp: 1, + stats: { oldestDataTimestamp: 0 }, +}); + +describe('workloadsSummaryCache branch coverage (supplemental)', () => { + beforeEach(() => { + mockGetWorkloadCharts.mockReset(); + mockGetWorkloadCharts.mockResolvedValue(emptyResponse()); + __resetWorkloadsSummaryCacheForTests(); + localStorage.clear(); + }); + + describe('normalizeNodeScope (via workloadsSummaryCacheScopeKey)', () => { + it('trims surrounding whitespace from a defined node scope', () => { + expect(workloadsSummaryCacheScopeKey('1h', ' node-1 ', 'acme')).toBe( + `${VERSION}::acme::1h::node-1`, + ); + }); + + it('coerces undefined node scope to empty string via the ?. ?? chain', () => { + expect(workloadsSummaryCacheScopeKey('1h', undefined, 'acme')).toBe( + `${VERSION}::acme::1h::`, + ); + }); + + it('coerces null node scope to empty string (optional chaining treats null as nullish)', () => { + expect(workloadsSummaryCacheScopeKey('1h', null, 'acme')).toBe( + `${VERSION}::acme::1h::`, + ); + }); + + it('collapses a whitespace-only node scope to empty string', () => { + expect(workloadsSummaryCacheScopeKey('1h', ' ', 'acme')).toBe( + `${VERSION}::acme::1h::`, + ); + }); + + it('uses the defaulted params when only range is supplied', () => { + // nodeScope defaults '' and orgScope defaults undefined -> getOrgID(). + expect(workloadsSummaryCacheScopeKey('1h')).toBe( + `${VERSION}::${ORG_FROM_GETORGID}::1h::`, + ); + }); + }); + + describe('resolveOrgScope (via workloadsSummaryCacheScopeKey)', () => { + it('honours an explicitly provided org scope without calling getOrgID', () => { + expect(workloadsSummaryCacheScopeKey('1h', '', 'custom')).toBe( + `${VERSION}::custom::1h::`, + ); + }); + + it('falls back to getOrgID() when orgScope is undefined (?? right operand)', () => { + expect(workloadsSummaryCacheScopeKey('1h', '', undefined)).toBe( + `${VERSION}::${ORG_FROM_GETORGID}::1h::`, + ); + }); + + it('falls back to getOrgID() when orgScope is null (?? right operand)', () => { + expect(workloadsSummaryCacheScopeKey('1h', '', null)).toBe( + `${VERSION}::${ORG_FROM_GETORGID}::1h::`, + ); + }); + + it('normalises an empty-string orgScope to the default org (empty string is not nullish)', () => { + // '' ?? getOrgID() -> '' (not nullish), then normalizeOrgScope('') -> 'default'. + expect(workloadsSummaryCacheScopeKey('1h', '', '')).toBe( + `${VERSION}::default::1h::`, + ); + }); + + it('trims and keeps a whitespace-padded org scope', () => { + expect(workloadsSummaryCacheScopeKey('1h', '', ' acme ')).toBe( + `${VERSION}::acme::1h::`, + ); + }); + }); + + describe('storageCacheKeyFor (observable via persisted localStorage key)', () => { + it('substitutes "__all__" when nodeScope is empty (|| arm false)', () => { + persistWorkloadsSummaryCache('1h', '', 'acme', makeResponse()); + expect(localStorage.getItem(`${PREFIX}acme::1h::__all__`)).not.toBeNull(); + }); + + it('keeps the concrete node scope when it is non-empty (|| arm true)', () => { + persistWorkloadsSummaryCache('1h', 'node-1', 'acme', makeResponse()); + expect(localStorage.getItem(`${PREFIX}acme::1h::node-1`)).not.toBeNull(); + }); + + it('URL-encodes special characters in both org scope and node scope', () => { + persistWorkloadsSummaryCache('1h', 'a/b', 'a b', makeResponse()); + expect(localStorage.getItem(`${PREFIX}a%20b::1h::a%2Fb`)).not.toBeNull(); + }); + }); + + describe('normalizeMaxPoints (via fetchWorkloadsSummaryAndCache call args)', () => { + const expectCall = async ( + maxPoints: unknown, + expected: number | undefined, + ): Promise => { + await fetchWorkloadsSummaryAndCache('1h', { + maxPoints: maxPoints as number | null, + }); + expect(mockGetWorkloadCharts).toHaveBeenCalledWith('1h', undefined, { + nodeId: undefined, + maxPoints: expected, + }); + }; + + it('rounds a positive fractional maxPoints to the nearest integer', async () => { + await expectCall(180.7, 181); + }); + + it('passes a positive integer maxPoints through unchanged', async () => { + await expectCall(360, 360); + }); + + it('returns undefined for a non-number type (string)', async () => { + await expectCall('big', undefined); + }); + + it('returns undefined for null', async () => { + await expectCall(null, undefined); + }); + + it('returns undefined for NaN (non-finite)', async () => { + await expectCall(NaN, undefined); + }); + + it('returns undefined for Infinity (non-finite)', async () => { + await expectCall(Infinity, undefined); + }); + + it('returns undefined for zero (maxPoints <= 0 guard)', async () => { + await expectCall(0, undefined); + }); + + it('returns undefined for a negative number (maxPoints <= 0 guard)', async () => { + await expectCall(-5, undefined); + }); + }); + + describe('normalizeNodeScope via fetch (nodeId arg normalization)', () => { + it('passes the trimmed nodeId to the API', async () => { + await fetchWorkloadsSummaryAndCache('1h', { nodeId: ' node-1 ' }); + expect(mockGetWorkloadCharts).toHaveBeenCalledWith('1h', undefined, { + nodeId: 'node-1', + maxPoints: undefined, + }); + }); + + it('converts an empty/whitespace nodeId to undefined (nodeScope || undefined)', async () => { + await fetchWorkloadsSummaryAndCache('1h', { nodeId: ' ' }); + expect(mockGetWorkloadCharts).toHaveBeenCalledWith('1h', undefined, { + nodeId: undefined, + maxPoints: undefined, + }); + }); + + it('converts a null nodeId to undefined', async () => { + await fetchWorkloadsSummaryAndCache('1h', { nodeId: null }); + expect(mockGetWorkloadCharts).toHaveBeenCalledWith('1h', undefined, { + nodeId: undefined, + maxPoints: undefined, + }); + }); + }); + + describe('resolveOrgScope via fetch (org routing)', () => { + it('persists under the explicitly provided org scope', async () => { + const response = makeResponse(); + mockGetWorkloadCharts.mockResolvedValueOnce(response); + await fetchWorkloadsSummaryAndCache('1h', { orgScope: 'acme' }); + expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBe(response); + }); + + it('persists under getOrgID() when orgScope is omitted', async () => { + const response = makeResponse(); + mockGetWorkloadCharts.mockResolvedValueOnce(response); + await fetchWorkloadsSummaryAndCache('1h', {}); + expect(readInMemoryWorkloadsSummaryCache('1h', '', ORG_FROM_GETORGID)).toBe( + response, + ); + }); + }); + + describe('trimPoints (via persisted+read storage arrays)', () => { + const persistAndReadCpu = async (cpu: MetricPoint[] | undefined) => { + const response: WorkloadChartsResponse = { + data: { w1: { cpu } }, + dockerData: {}, + guestTypes: {}, + timestamp: 1, + stats: { oldestDataTimestamp: 0 }, + }; + persistWorkloadsSummaryCache('1h', '', 'acme', response); + const read = readWorkloadsSummaryCache('1h', '', 'acme'); + return read?.data.w1?.cpu ?? null; + }; + + it('returns [] when the points array is empty', async () => { + expect(await persistAndReadCpu([])).toEqual([]); + }); + + it('returns [] when the points array is undefined (!points arm)', async () => { + expect(await persistAndReadCpu(undefined)).toEqual([]); + }); + + it('returns a short array unchanged (length <= max arm)', async () => { + const cpu = await persistAndReadCpu([metric(10), metric(20)]); + expect(cpu).toEqual([metric(10), metric(20)]); + }); + + it('downsamples 361 points without appending/slicing (step loop, no last-append, no final trim)', async () => { + // step=ceil(361/360)=2 -> 181 samples; last sampled index == sliced last -> no append; + // 181 <= 360 -> returned as-is. + const cpu = await persistAndReadCpu(points(361)); + expect(cpu?.length).toBe(181); + expect(cpu?.[0]).toEqual(metric(0)); + expect(cpu?.[180]).toEqual(metric(360)); + }); + + it('appends the final point when the step loop misses it (last-append arm, no final trim)', async () => { + // 362 points -> step 2 -> 181 sampled; last sampled (360) != sliced last (361) -> push; + // 182 <= 360 -> returned as-is. + const cpu = await persistAndReadCpu(points(362)); + expect(cpu?.length).toBe(182); + expect(cpu?.[0]).toEqual(metric(0)); + expect(cpu?.[181]).toEqual(metric(361)); + }); + + it('trims back to exactly max when downsample+append overflows (final slice arm)', async () => { + // 720 points -> step 2 -> 360 sampled + 1 appended = 361 -> slice(1) -> 360. + const cpu = await persistAndReadCpu(points(720)); + expect(cpu?.length).toBe(360); + expect(cpu?.[0]).toEqual(metric(2)); + expect(cpu?.[359]).toEqual(metric(719)); + }); + + it('trims a large array (1000 pts) down to max keeping the most recent tail', async () => { + const cpu = await persistAndReadCpu(points(1000)); + expect(cpu?.length).toBe(360); + expect(cpu?.[cpu.length - 1]).toEqual(metric(999)); + }); + }); + + describe('readInMemoryWorkloadsSummaryCache', () => { + it('returns null on a miss (?? null arm)', () => { + expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); + }); + + it('returns the stored response on a hit', () => { + const response = makeResponse(); + persistWorkloadsSummaryCache('1h', 'n1', 'acme', response); + expect(readInMemoryWorkloadsSummaryCache('1h', 'n1', 'acme')).toBe(response); + }); + + it('returns null when the org scope differs', () => { + const response = makeResponse(); + persistWorkloadsSummaryCache('1h', 'n1', 'acme', response); + expect(readInMemoryWorkloadsSummaryCache('1h', 'n1', 'other')).toBeNull(); + }); + + it('resolves the default-omitted orgScope through getOrgID()', () => { + const response = makeResponse(); + persistWorkloadsSummaryCache('1h', 'n1', undefined, response); + expect(readInMemoryWorkloadsSummaryCache('1h', 'n1')).toBe(response); + }); + }); + + describe('writeInMemoryWorkloadsSummaryCache eviction', () => { + it('evicts the oldest entry once the 20-entry cap is exceeded', () => { + const first = makeResponse([metric(1)]); + persistWorkloadsSummaryCache('1h', 'node-0', 'acme', first); + // Fill distinct scopes up to the cap. + for (let i = 1; i < 20; i += 1) { + persistWorkloadsSummaryCache('1h', `node-${i}`, 'acme', makeResponse()); + } + // All 20 still resident. + expect(readInMemoryWorkloadsSummaryCache('1h', 'node-0', 'acme')).toBe(first); + expect(readInMemoryWorkloadsSummaryCache('1h', 'node-19', 'acme')).not.toBeNull(); + + // 21st distinct scope triggers eviction of the oldest (node-0). + const twentyFirst = makeResponse(); + persistWorkloadsSummaryCache('1h', 'node-20', 'acme', twentyFirst); + expect(readInMemoryWorkloadsSummaryCache('1h', 'node-0', 'acme')).toBeNull(); + expect(readInMemoryWorkloadsSummaryCache('1h', 'node-20', 'acme')).toBe(twentyFirst); + }); + + it('overwrites in place without evicting when the scope key already exists', () => { + const a = makeResponse([metric(1)]); + const b = makeResponse([metric(2)]); + for (let i = 0; i < 20; i += 1) { + persistWorkloadsSummaryCache('1h', `node-${i}`, 'acme', a); + } + // Re-persist an existing key -> has() is true so no eviction; value updates. + persistWorkloadsSummaryCache('1h', 'node-0', 'acme', b); + expect(readInMemoryWorkloadsSummaryCache('1h', 'node-0', 'acme')).toBe(b); + expect(readInMemoryWorkloadsSummaryCache('1h', 'node-19', 'acme')).toBe(a); + }); + }); + + describe('persistWorkloadsSummaryCache error / edge arms', () => { + it('skips localStorage entirely when window is undefined (SSR guard) but still warms memory', () => { + vi.stubGlobal('window', undefined); + try { + persistWorkloadsSummaryCache('1h', '', 'acme', makeResponse()); + expect(localStorage.length).toBe(0); + } finally { + vi.unstubAllGlobals(); + } + // Memory was written before the SSR guard. + expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).not.toBeNull(); + }); + + it('stores an empty data record when response.data is undefined (|| {} arm)', () => { + persistWorkloadsSummaryCache( + '1h', + '', + 'acme', + { dockerData: {}, timestamp: 1, stats: { oldestDataTimestamp: 0 } } as unknown as WorkloadChartsResponse, + ); + expect(readWorkloadsSummaryCache('1h', '', 'acme')?.data).toEqual({}); + }); + + it('stores an empty dockerData record when response.dockerData is undefined (|| {} arm)', () => { + persistWorkloadsSummaryCache( + '1h', + '', + 'acme', + { data: {}, timestamp: 1, stats: { oldestDataTimestamp: 0 } } as unknown as WorkloadChartsResponse, + ); + expect(readWorkloadsSummaryCache('1h', '', 'acme')?.dockerData).toEqual({}); + }); + + it('persists dockerData entries through toCachedChartData', () => { + persistWorkloadsSummaryCache('1h', '', 'acme', { + data: {}, + dockerData: { c1: { cpu: [metric(1), metric(2)] } }, + timestamp: 1, + stats: { oldestDataTimestamp: 0 }, + }); + expect(readWorkloadsSummaryCache('1h', '', 'acme')?.dockerData?.c1?.cpu).toEqual([ + metric(1), + metric(2), + ]); + }); + + it('drops the localStorage entry when the serialized payload exceeds the char cap', () => { + // A single workload id whose key name alone pushes serialization past + // WORKLOADS_SUMMARY_CACHE_MAX_CHARS (900_000) -> removeItem + early return. + const hugeId = 'x'.repeat(1_000_000); + persistWorkloadsSummaryCache('1h', '', 'acme', { + data: { [hugeId]: { cpu: [metric(1)] } }, + dockerData: {}, + timestamp: 1, + stats: { oldestDataTimestamp: 0 }, + }); + expect(localStorage.length).toBe(0); + // Memory is populated before the size check. + expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).not.toBeNull(); + }); + + it('swallows a localStorage.setItem failure (catch arm) without throwing', () => { + const spy = vi + .spyOn(Storage.prototype, 'setItem') + .mockImplementationOnce(() => { + throw new Error('QuotaExceededError'); + }); + const response = makeResponse(); + expect(() => + persistWorkloadsSummaryCache('1h', '', 'acme', response), + ).not.toThrow(); + // Not persisted to storage (setItem threw)... + expect(localStorage.length).toBe(0); + // ...but memory was warmed before the try block. + expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBe(response); + spy.mockRestore(); + }); + }); + + describe('readWorkloadsSummaryCache', () => { + afterEach(() => vi.restoreAllMocks()); + + it('returns null on the SSR path (window undefined) without touching storage', () => { + persistWorkloadsSummaryCache('1h', '', 'acme', makeResponse()); + vi.stubGlobal('window', undefined); + try { + expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); + } finally { + vi.unstubAllGlobals(); + } + // Storage entry left intact (read returned before touching localStorage). + expect(localStorage.length).toBe(1); + }); + + it('returns null when no entry exists (!raw arm)', () => { + expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); + }); + + it('returns null when JSON.parse throws (catch arm); leaves the malformed entry in place', () => { + // The catch arm only swallows and returns null; it does NOT removeItem. + const key = storageKeyFor('1h', '', 'acme'); + localStorage.setItem(key, '{ not valid json'); + expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); + expect(localStorage.getItem(key)).toBe('{ not valid json'); + }); + + const writePayload = ( + range: TimeRange, + nodeScope: string, + orgScope: string | undefined, + payload: Record, + ): void => { + const normalizedNode = (nodeScope?.trim() ?? ''); + localStorage.setItem( + storageKeyFor(range, normalizedNode, orgScope), + JSON.stringify(payload), + ); + }; + + it('rejects and removes an entry whose version mismatches', () => { + const key = storageKeyFor('1h', '', 'acme'); + writePayload('1h', '', 'acme', { + version: 999, + range: '1h', + nodeScope: '', + cachedAt: Date.now(), + data: {}, + dockerData: {}, + }); + expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); + expect(localStorage.getItem(key)).toBeNull(); + }); + + it('rejects and removes an entry whose range mismatches', () => { + const key = storageKeyFor('1h', '', 'acme'); + writePayload('1h', '', 'acme', { + version: VERSION, + range: '4h', + nodeScope: '', + cachedAt: Date.now(), + data: {}, + dockerData: {}, + }); + expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); + expect(localStorage.getItem(key)).toBeNull(); + }); + + it('rejects and removes an entry whose nodeScope mismatches', () => { + const key = storageKeyFor('1h', '', 'acme'); + writePayload('1h', '', 'acme', { + version: VERSION, + range: '1h', + nodeScope: 'different', + cachedAt: Date.now(), + data: {}, + dockerData: {}, + }); + expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); + expect(localStorage.getItem(key)).toBeNull(); + }); + + it('rejects and removes an entry whose cachedAt is not a number', () => { + const key = storageKeyFor('1h', '', 'acme'); + writePayload('1h', '', 'acme', { + version: VERSION, + range: '1h', + nodeScope: '', + cachedAt: 'not-a-number', + data: {}, + dockerData: {}, + }); + expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); + expect(localStorage.getItem(key)).toBeNull(); + }); + + it('rejects and removes a stale entry past the 5-minute TTL', () => { + const key = storageKeyFor('1h', '', 'acme'); + const stale = Date.now() - (5 * 60_000 + 1); + writePayload('1h', '', 'acme', { + version: VERSION, + range: '1h', + nodeScope: '', + cachedAt: stale, + data: {}, + dockerData: {}, + }); + expect(readWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); + expect(localStorage.getItem(key)).toBeNull(); + }); + + it('accepts a fresh entry exactly at the TTL boundary (age == max age)', () => { + const now = 10_000_000; + vi.spyOn(Date, 'now').mockReturnValue(now); + writePayload('1h', '', 'acme', { + version: VERSION, + range: '1h', + nodeScope: '', + cachedAt: now - 5 * 60_000, + data: { w1: { cpu: [metric(7)] } }, + dockerData: {}, + }); + const read = readWorkloadsSummaryCache('1h', '', 'acme'); + expect(read?.data.w1?.cpu).toEqual([metric(7)]); + }); + + it('reconstructs the response with reset guestTypes/stats and the cached timestamp', () => { + const now = 50_000; + vi.spyOn(Date, 'now').mockReturnValue(now); + writePayload('1h', '', 'acme', { + version: VERSION, + range: '1h', + nodeScope: '', + cachedAt: 12345, + data: {}, + dockerData: {}, + }); + const read = readWorkloadsSummaryCache('1h', '', 'acme'); + expect(read).toEqual({ + data: {}, + dockerData: {}, + guestTypes: {}, + timestamp: 12345, + stats: { oldestDataTimestamp: 0 }, + }); + }); + + it('warms the in-memory cache as a side effect of a successful read', () => { + const now = 99_000; + vi.spyOn(Date, 'now').mockReturnValue(now); + writePayload('1h', '', 'acme', { + version: VERSION, + range: '1h', + nodeScope: '', + cachedAt: now, + data: {}, + dockerData: {}, + }); + expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); + const read = readWorkloadsSummaryCache('1h', '', 'acme'); + expect(read).not.toBeNull(); + expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBe(read); + }); + }); + + describe('hasFreshWorkloadsSummaryCache', () => { + afterEach(() => vi.restoreAllMocks()); + + it('returns false when neither memory nor storage has an entry (both || arms false)', () => { + expect(hasFreshWorkloadsSummaryCache('1h', '', 'acme')).toBe(false); + }); + + it('short-circuits true on a memory hit (|| left arm)', () => { + const response = makeResponse(); + persistWorkloadsSummaryCache('1h', '', 'acme', response); + // Drop storage so only memory holds it. + localStorage.clear(); + expect(hasFreshWorkloadsSummaryCache('1h', '', 'acme')).toBe(true); + }); + + it('falls through to storage and returns true on a storage hit (|| right arm)', () => { + const now = 1_000; + vi.spyOn(Date, 'now').mockReturnValue(now); + persistWorkloadsSummaryCache('1h', '', 'acme', makeResponse()); + // Reset memory (keeps localStorage) so only storage holds it. + __resetWorkloadsSummaryCacheForTests(); + expect(hasFreshWorkloadsSummaryCache('1h', '', 'acme')).toBe(true); + }); + }); + + describe('fetchWorkloadsSummaryAndCache dedupe / signal arms', () => { + it('deduplicates concurrent same-key fetches without a signal (inFlight return)', async () => { + const response = makeResponse(); + mockGetWorkloadCharts.mockReset(); + mockGetWorkloadCharts.mockResolvedValue(response); + + const first = fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 }); + const second = fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 }); + + await expect(Promise.all([first, second])).resolves.toEqual([response, response]); + expect(mockGetWorkloadCharts).toHaveBeenCalledTimes(1); + }); + + it('clears inFlight on settlement so a later call fetches again (finally arm)', async () => { + mockGetWorkloadCharts.mockReset(); + mockGetWorkloadCharts.mockResolvedValue(makeResponse()); + + await fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 }); + await fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 }); + + expect(mockGetWorkloadCharts).toHaveBeenCalledTimes(2); + }); + + it('bypasses dedupe when an AbortSignal is supplied (both !signal guards false)', async () => { + mockGetWorkloadCharts.mockReset(); + mockGetWorkloadCharts.mockResolvedValue(makeResponse()); + + const controller = new AbortController(); + const p1 = fetchWorkloadsSummaryAndCache('1h', { signal: controller.signal }); + const p2 = fetchWorkloadsSummaryAndCache('1h', { signal: controller.signal }); + + await Promise.all([p1, p2]); + expect(mockGetWorkloadCharts).toHaveBeenCalledTimes(2); + }); + + it('persists the fetched response before resolving', async () => { + const response = makeResponse(); + mockGetWorkloadCharts.mockReset(); + mockGetWorkloadCharts.mockResolvedValue(response); + + const result = await fetchWorkloadsSummaryAndCache('1h', { + nodeId: 'n1', + orgScope: 'acme', + }); + expect(result).toBe(response); + expect(readInMemoryWorkloadsSummaryCache('1h', 'n1', 'acme')).toBe(response); + }); + }); + + describe('org_switched event handler', () => { + it('clears the in-memory cache when org_switched is emitted', () => { + const response = makeResponse(); + persistWorkloadsSummaryCache('1h', 'n1', 'acme', response); + expect(readInMemoryWorkloadsSummaryCache('1h', 'n1', 'acme')).toBe(response); + + eventBus.emit('org_switched', 'new-org'); + + expect(readInMemoryWorkloadsSummaryCache('1h', 'n1', 'acme')).toBeNull(); + }); + + it('clears in-flight fetch tracking so the next request is not deduped', async () => { + mockGetWorkloadCharts.mockReset(); + mockGetWorkloadCharts.mockResolvedValue(makeResponse()); + + // Kick off a never-resolving first fetch so inFlight is populated. + let resolveFirst!: (v: WorkloadChartsResponse) => void; + mockGetWorkloadCharts.mockReturnValueOnce( + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + void fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 }); + + // Emit org_switched -> inFlight map is cleared. + eventBus.emit('org_switched', 'new-org'); + + // A new concurrent call (same key) must NOT be deduped -> 2nd API call. + const p2 = fetchWorkloadsSummaryAndCache('1h', { maxPoints: 180 }); + expect(mockGetWorkloadCharts).toHaveBeenCalledTimes(2); + + // Allow both promises to settle. + mockGetWorkloadCharts.mockResolvedValueOnce(makeResponse()); + resolveFirst(makeResponse()); + await p2; + }); + }); + + describe('__resetWorkloadsSummaryCacheForTests', () => { + it('clears the in-memory cache', () => { + persistWorkloadsSummaryCache('1h', '', 'acme', makeResponse()); + expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).not.toBeNull(); + __resetWorkloadsSummaryCacheForTests(); + expect(readInMemoryWorkloadsSummaryCache('1h', '', 'acme')).toBeNull(); + }); + }); +});