From 7da1f0ae990cfb6858b2bb4f82cd0ddba3e3e370 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 10 Jul 2026 09:45:11 +0100 Subject: [PATCH] Add branch-coverage tests for eight already-tested frontend models Covers previously-uncovered branches in proxmox backup recovery, AI finding presentation, chat tool/transcript formatting, docker page model, alert history/overrides models, and storage page state - 275 assertions through public entry points, no source changes. --- .../Chat/__tests__/toolPresentation.test.ts | 129 +++++ .../transcriptExport.coverage.test.ts | 287 ++++++++++ .../storagePageState.coverage.test.ts | 397 +++++++++++++ .../alertHistoryModel.coverage.test.ts | 476 ++++++++++++++++ .../alertOverridesModel.coverage.test.ts | 404 ++++++++++++++ .../dockerPageModel.coverage.test.ts | 520 ++++++++++++++++++ ...roxmoxBackupRecoveryModel.coverage.test.ts | 290 ++++++++++ .../aiFindingPresentation.coverage.test.ts | 239 ++++++++ 8 files changed, 2742 insertions(+) create mode 100644 frontend-modern/src/components/AI/Chat/__tests__/toolPresentation.test.ts create mode 100644 frontend-modern/src/components/AI/Chat/__tests__/transcriptExport.coverage.test.ts create mode 100644 frontend-modern/src/components/Storage/__tests__/storagePageState.coverage.test.ts create mode 100644 frontend-modern/src/features/alerts/__tests__/alertHistoryModel.coverage.test.ts create mode 100644 frontend-modern/src/features/alerts/__tests__/alertOverridesModel.coverage.test.ts create mode 100644 frontend-modern/src/features/docker/__tests__/dockerPageModel.coverage.test.ts create mode 100644 frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.coverage.test.ts create mode 100644 frontend-modern/src/utils/__tests__/aiFindingPresentation.coverage.test.ts diff --git a/frontend-modern/src/components/AI/Chat/__tests__/toolPresentation.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/toolPresentation.test.ts new file mode 100644 index 0000000000..1edf724ff2 --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/__tests__/toolPresentation.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { parseToolInputSummary } from '../toolPresentation'; + +const readSummary = (record: Record) => + parseToolInputSummary(JSON.stringify(record), 'pulse_read'); +const querySummary = (record: Record) => + parseToolInputSummary(JSON.stringify(record), 'pulse_query'); +const runCommandSummary = (record: Record) => + parseToolInputSummary(JSON.stringify(record), 'pulse_run_command'); +const controlSummary = (record: Record) => + parseToolInputSummary(JSON.stringify(record), 'pulse_control'); + +describe('formatPulseReadInputSummary (read tool)', () => { + it('summarizes exec with a command via its read intent and falls back when command is absent', () => { + expect(readSummary({ action: 'exec', command: 'df -h' })).toBe('Inspect filesystems'); + expect(readSummary({ action: 'exec' })).toBe('run read-only command'); + }); + + it('summarizes file with and without a path', () => { + expect(readSummary({ action: 'file', path: '/etc/hosts' })).toBe('read /etc/hosts'); + expect(readSummary({ action: 'file' })).toBe('read file'); + }); + + it('appends a target suffix when target_host is present', () => { + expect(readSummary({ action: 'file', path: '/etc/hosts', target_host: 'web-01' })).toBe( + 'read /etc/hosts on web-01', + ); + }); + + it('summarizes tail with and without a path', () => { + expect(readSummary({ action: 'tail', path: '/var/log/syslog' })).toBe( + 'tail /var/log/syslog', + ); + expect(readSummary({ action: 'tail' })).toBe('tail file'); + }); + + it('summarizes find with pattern+path, pattern only, and neither', () => { + expect(readSummary({ action: 'find', pattern: 'ERROR', path: '/var/log' })).toBe( + 'find "ERROR" in /var/log', + ); + expect(readSummary({ action: 'find', pattern: 'OOM' })).toBe('find "OOM"'); + expect(readSummary({ action: 'find' })).toBe('find files'); + }); + + it('summarizes logs by container, then source, then a generic fallback', () => { + expect(readSummary({ action: 'logs', container: 'nginx' })).toBe('logs nginx'); + expect(readSummary({ action: 'logs', source: 'journald' })).toBe('journald logs'); + expect(readSummary({ action: 'logs' })).toBe('read logs'); + }); + + it('uses the type field as an action alias and labels unrecognized actions', () => { + expect(readSummary({ type: 'file', path: '/etc/hosts' })).toBe('read /etc/hosts'); + expect(readSummary({ action: 'stat' })).toBe('stat'); + }); +}); + +describe('formatQueryInputSummary (query tool)', () => { + it('summarizes search with and without a query term', () => { + expect(querySummary({ action: 'search', query: 'web-101' })).toBe('search "web-101"'); + expect(querySummary({ action: 'search' })).toBe('search resources'); + }); + + it('summarizes list with a type, a resource_type alias, and no type', () => { + expect(querySummary({ action: 'list', type: 'vm' })).toBe('list vm'); + expect(querySummary({ action: 'list', resource_type: 'storage_pool' })).toBe( + 'list storage pool', + ); + expect(querySummary({ action: 'list' })).toBe('list resources'); + }); + + it('summarizes get with and without a resource id', () => { + expect(querySummary({ action: 'get', resource_id: 'vm:101' })).toBe('get vm:101'); + expect(querySummary({ action: 'get' })).toBe('get resource'); + }); + + it('summarizes config with id+node, id only, and neither', () => { + expect(querySummary({ action: 'config', resource_id: 'vm:101', node: 'pve01' })).toBe( + 'config vm:101 on pve01', + ); + expect(querySummary({ action: 'config', resource_id: 'vm:101' })).toBe('config vm:101'); + expect(querySummary({ action: 'config' })).toBe('resource config'); + }); + + it('summarizes topology honoring summary_only and health as a fixed label', () => { + expect(querySummary({ action: 'topology' })).toBe('topology'); + expect(querySummary({ action: 'topology', summary_only: true })).toBe('topology summary'); + expect(querySummary({ action: 'topology', summary_only: false })).toBe('topology'); + expect(querySummary({ action: 'health' })).toBe('health summary'); + }); + + it('labels unknown actions and falls back to resource-type then a generic query label', () => { + expect(querySummary({ action: 'custom' })).toBe('custom'); + expect(querySummary({ resource_type: 'vm' })).toBe('query vm'); + expect(querySummary({ foo: 'bar' })).toBe('query resources'); + }); +}); + +describe('formatStructuredInputSummary mode split (read vs run_command vs control)', () => { + it('routes run_command and control to write mode, producing "Run command"', () => { + expect(runCommandSummary({ command: 'systemctl restart nginx' })).toBe('Run command'); + expect(controlSummary({ command: 'reboot', target_host: 'node-1' })).toBe( + 'Run command on node-1', + ); + }); + + it('falls back to "run command" for write tools when no command is present', () => { + expect(runCommandSummary({ foo: 'bar' })).toBe('run command'); + expect(controlSummary({ foo: 'bar' })).toBe('run command'); + }); + + it('falls back to "read resource" when the read tool yields no specific summary', () => { + expect(readSummary({ foo: 'bar' })).toBe('read resource'); + }); + + it('splits the same command between read intent and write execution by tool', () => { + expect(readSummary({ command: 'df -h' })).toBe('Inspect filesystems'); + expect(runCommandSummary({ command: 'df -h' })).toBe('Run command'); + }); +}); + +describe('partialResult rescue during function-style input parsing', () => { + it('recovers args parsed before a malformed token in a partial function call', () => { + expect(parseToolInputSummary('read(action="file", 123)', 'pulse_read')).toBe('read file'); + }); + + it('yields no structured summary when no args are recoverable', () => { + expect(parseToolInputSummary('read(=', 'pulse_read')).toBe('read(='); + }); +}); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/transcriptExport.coverage.test.ts b/frontend-modern/src/components/AI/Chat/__tests__/transcriptExport.coverage.test.ts new file mode 100644 index 0000000000..253edbf524 --- /dev/null +++ b/frontend-modern/src/components/AI/Chat/__tests__/transcriptExport.coverage.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from 'vitest'; +import type { AssistantTranscriptOptions } from '../transcriptExport'; +import { formatAssistantTranscript, hasAssistantTranscriptContent } from '../transcriptExport'; +import type { ChatMessage, PendingApproval, Question, StreamDisplayEvent } from '../types'; + +const timestamp = new Date('2026-06-06T12:34:56Z'); + +const assistantWithEvents = (events: StreamDisplayEvent[]): ChatMessage => ({ + id: 'assistant-1', + role: 'assistant', + content: '', + timestamp, + streamEvents: events, +}); + +const render = ( + events: StreamDisplayEvent[], + options: Partial = {}, +): string => + formatAssistantTranscript({ + messages: [assistantWithEvents(events)], + generatedAt: timestamp, + ...options, + }); + +const hasContent = (event: StreamDisplayEvent): boolean => + hasAssistantTranscriptContent([assistantWithEvents([event])]); + +describe('formatPendingTool', () => { + it('uses the parsed input summary, default pending status, and command preview', () => { + const transcript = render([ + { + type: 'pending_tool', + pendingTool: { + id: 't1', + name: 'pulse_read', + input: JSON.stringify({ + action: 'exec', + command: 'ls /dev | wc -l', + target_host: 'current_resource', + }), + }, + }, + ]); + + expect(transcript).toContain('[tool:read] Inspect devices on current resource - pending'); + expect(transcript).toContain('$ ls /dev | wc -l'); + }); + + it('falls back to the action label and surfaces status plus progress without a command preview', () => { + const transcript = render([ + { + type: 'pending_tool', + pendingTool: { + id: 't2', + name: 'pulse_get_disk_health', + input: '', + status: 'waiting', + progress: 'probing controllers', + }, + }, + ]); + + expect(transcript).toContain( + '[tool:disks] Checking disks... - waiting - progress: probing controllers', + ); + expect(transcript).not.toContain('$'); + }); +}); + +describe('formatApproval', () => { + it('marks a non-executing high-risk approval as approval required', () => { + const transcript = render([ + { + type: 'approval', + approval: { + toolId: 't1', + toolName: 'pulse_run_command', + description: 'Restart nginx service', + command: 'systemctl restart nginx', + risk: 'high', + runOnHost: true, + }, + }, + ]); + + expect(transcript).toContain( + '[approval:cmd] Restart nginx service - approval required - high risk', + ); + }); + + it('falls back to the command and executing status when description is absent', () => { + const transcript = render([ + { + type: 'approval', + approval: { + toolId: 't2', + toolName: '', + command: 'rm -rf /tmp/cache', + isExecuting: true, + runOnHost: true, + }, + }, + ]); + + expect(transcript).toContain('[approval:approval] rm -rf /tmp/cache - executing'); + expect(transcript).not.toContain('risk'); + }); + + it('uses the generic action subject when no description or command is present', () => { + const transcript = render([ + { + type: 'approval', + approval: { + toolId: 't3', + toolName: 'pulse_read', + runOnHost: false, + } as unknown as PendingApproval, + }, + ]); + + expect(transcript).toContain('[approval:read] action - approval required'); + }); +}); + +describe('formatQuestion', () => { + it('renders a single prompt', () => { + const transcript = render([ + { + type: 'question', + question: { + questionId: 'q1', + questions: [{ id: 'q1a', type: 'text', question: 'Which host should I inspect?' }], + }, + }, + ]); + + expect(transcript).toContain('[question] Which host should I inspect?'); + }); + + it('joins multiple prompts with a slash separator', () => { + const transcript = render([ + { + type: 'question', + question: { + questionId: 'q2', + questions: [ + { id: '1', type: 'select', question: 'Pick severity' }, + { id: '2', type: 'select', question: 'Pick scope' }, + ], + }, + }, + ]); + + expect(transcript).toContain('[question] Pick severity / Pick scope'); + }); + + it('falls back to the header when the question text is missing', () => { + const transcript = render([ + { + type: 'question', + question: { + questionId: 'q3', + questions: [{ id: '1', type: 'text', header: 'Resource name' } as unknown as Question], + }, + }, + ]); + + expect(transcript).toContain('[question] Resource name'); + }); + + it('omits the question block when every prompt normalizes to empty', () => { + const transcript = render([ + { + type: 'question', + question: { + questionId: 'q4', + questions: [{ id: '1', type: 'text', question: ' ', header: '' }], + }, + }, + ]); + + expect(transcript).toContain('## Pulse Assistant'); + expect(transcript).not.toContain('[question]'); + }); +}); + +describe('formatModelSwitch', () => { + const labelFor = (id: string): string => + ( + ({ + 'm-sel': 'Selected Route', + 'm-new': 'New Route', + 'm-old': 'Old Route', + 'm-x': 'X Route', + }) as Record + )[id] ?? id; + + it('renders a selected route with the using prefix', () => { + const transcript = render( + [{ type: 'model_switch', model: 'm-sel', modelEvent: 'selected' }], + { getModelRouteLabel: labelFor }, + ); + + expect(transcript).toContain('[model] using Selected Route'); + }); + + it('renders a switch after a failed route', () => { + const transcript = render( + [{ type: 'model_switch', model: 'm-new', failedModel: 'm-old', modelEvent: 'switch' }], + { getModelRouteLabel: labelFor }, + ); + + expect(transcript).toContain('[model] New Route after Old Route'); + }); + + it('renders a plain switch without a failed route', () => { + const transcript = render([{ type: 'model_switch', model: 'm-x', modelEvent: 'switch' }], { + getModelRouteLabel: labelFor, + }); + + expect(transcript).toContain('[model] X Route'); + expect(transcript).not.toContain('after'); + }); + + it('does not duplicate the route when the failed route matches the new one', () => { + const transcript = render( + [{ type: 'model_switch', model: 'm-x', failedModel: 'm-x', modelEvent: 'switch' }], + { getModelRouteLabel: labelFor }, + ); + + expect(transcript).toContain('[model] X Route'); + expect(transcript).not.toContain('after'); + }); +}); + +describe('hasRenderableEvent switch arms', () => { + it('treats thinking as renderable only when included and non-empty', () => { + expect(hasContent({ type: 'thinking', thinking: 'Private reasoning' })).toBe(false); + expect( + render([{ type: 'thinking', thinking: 'Private reasoning' }], { includeThinking: true }), + ).toContain('[thinking]'); + expect(render([{ type: 'thinking', thinking: ' ' }], { includeThinking: true })).toBe(''); + }); + + it('renders workflow status only when it formats to a message', () => { + expect( + hasContent({ + type: 'workflow_status', + workflowStatus: { message: 'Reading current inventory.' }, + }), + ).toBe(true); + expect(hasContent({ type: 'workflow_status' })).toBe(false); + }); + + it('renders tool cancellation only when a cancel payload exists', () => { + expect( + hasContent({ + type: 'tool_cancel', + toolCancel: { id: 'c1', name: 'pulse_read', input: '' }, + }), + ).toBe(true); + expect(hasContent({ type: 'tool_cancel' })).toBe(false); + }); + + it('renders model switch only when a model is present', () => { + expect(hasContent({ type: 'model_switch', model: 'm' })).toBe(true); + expect(hasContent({ type: 'model_switch' })).toBe(false); + }); + + it('renders approval only when an approval payload exists', () => { + expect( + hasContent({ + type: 'approval', + approval: { toolId: 'a', toolName: '', command: '', runOnHost: false }, + }), + ).toBe(true); + expect(hasContent({ type: 'approval' })).toBe(false); + }); + + it('renders question only when a question payload exists', () => { + expect( + hasContent({ type: 'question', question: { questionId: 'q', questions: [] } }), + ).toBe(true); + expect(hasContent({ type: 'question' })).toBe(false); + }); +}); diff --git a/frontend-modern/src/components/Storage/__tests__/storagePageState.coverage.test.ts b/frontend-modern/src/components/Storage/__tests__/storagePageState.coverage.test.ts new file mode 100644 index 0000000000..fe8a06b818 --- /dev/null +++ b/frontend-modern/src/components/Storage/__tests__/storagePageState.coverage.test.ts @@ -0,0 +1,397 @@ +import { describe, expect, it } from 'vitest'; +import type { StorageHealthFilter } from '@/features/storageBackups/models'; +import { + buildStorageRouteFields, + coerceSelectedStorageNodeId, + countActiveStorageFilters, + DEFAULT_STORAGE_DISK_GROUP_FILTER, + DEFAULT_STORAGE_DISK_ROLE_FILTER, + DEFAULT_STORAGE_GROUP_KEY, + DEFAULT_STORAGE_SELECTED_NODE_ID, + DEFAULT_STORAGE_SORT_DIRECTION, + DEFAULT_STORAGE_SORT_KEY, + DEFAULT_STORAGE_SOURCE_FILTER, + DEFAULT_STORAGE_STATUS_FILTER, + getDefaultStorageSortDirection, + hasActiveStorageFilters, + normalizeStorageHealthFilter, + normalizeStorageSortKey, + type StoragePageNodeOption, + type StorageFilterActivityState, +} from '@/components/Storage/storagePageState'; + +const baseInactiveState: StorageFilterActivityState = { + search: '', + sortKey: DEFAULT_STORAGE_SORT_KEY, + sortDirection: DEFAULT_STORAGE_SORT_DIRECTION, + groupBy: DEFAULT_STORAGE_GROUP_KEY, + statusFilter: DEFAULT_STORAGE_STATUS_FILTER, + sourceFilter: DEFAULT_STORAGE_SOURCE_FILTER, + diskRoleFilter: DEFAULT_STORAGE_DISK_ROLE_FILTER, + diskGroupFilter: DEFAULT_STORAGE_DISK_GROUP_FILTER, +}; + +describe('storagePageState coverage', () => { + describe('normalizeStorageHealthFilter', () => { + it('returns "all" for empty, whitespace, and null-ish inputs', () => { + expect(normalizeStorageHealthFilter('')).toBe('all'); + expect(normalizeStorageHealthFilter(' ')).toBe('all'); + }); + + it('returns "all" for the literal "all" token (case-insensitive, trimmed)', () => { + expect(normalizeStorageHealthFilter('all')).toBe('all'); + expect(normalizeStorageHealthFilter(' All ')).toBe('all'); + expect(normalizeStorageHealthFilter('ALL')).toBe('all'); + }); + + it.each<[string, StorageHealthFilter]>([ + ['attention', 'attention'], + ['needs-attention', 'attention'], + ['issue', 'attention'], + ['issues', 'attention'], + ['unhealthy', 'attention'], + ])('maps %s alias to "attention"', (input, expected) => { + expect(normalizeStorageHealthFilter(input)).toBe(expected); + }); + + it.each<[string, StorageHealthFilter]>([ + ['available', 'healthy'], + ['online', 'healthy'], + ['healthy', 'healthy'], + ])('maps %s alias to "healthy"', (input, expected) => { + expect(normalizeStorageHealthFilter(input)).toBe(expected); + }); + + it.each<[string, StorageHealthFilter]>([ + ['degraded', 'warning'], + ['warning', 'warning'], + ['critical', 'critical'], + ['offline', 'offline'], + ['unknown', 'unknown'], + ])('maps %s to its canonical bucket', (input, expected) => { + expect(normalizeStorageHealthFilter(input)).toBe(expected); + }); + + it('is case-insensitive and trims whitespace', () => { + expect(normalizeStorageHealthFilter(' CRITICAL ')).toBe('critical'); + expect(normalizeStorageHealthFilter('Offline')).toBe('offline'); + expect(normalizeStorageHealthFilter(' Unhealthy ')).toBe('attention'); + }); + + it('falls back to "all" for unrecognized tokens', () => { + expect(normalizeStorageHealthFilter('banana')).toBe('all'); + expect(normalizeStorageHealthFilter('error')).toBe('all'); + }); + }); + + describe('normalizeStorageSortKey', () => { + it.each([ + 'priority', + 'name', + 'state', + 'usage', + 'type', + 'host', + 'protection', + 'growth', + ])('accepts canonical sort key %s (trimmed, lowercased)', (key) => { + expect(normalizeStorageSortKey(key)).toBe(key); + expect(normalizeStorageSortKey(` ${key.toUpperCase()} `)).toBe(key); + }); + + it('falls back to "priority" for empty / whitespace input', () => { + expect(normalizeStorageSortKey('')).toBe('priority'); + expect(normalizeStorageSortKey(' ')).toBe('priority'); + }); + + it('falls back to "priority" for unknown keys', () => { + expect(normalizeStorageSortKey('banana')).toBe('priority'); + expect(normalizeStorageSortKey('random')).toBe('priority'); + }); + + it('folds the legacy "source" key back to the default "priority"', () => { + expect(normalizeStorageSortKey('source')).toBe('priority'); + }); + }); + + describe('getDefaultStorageSortDirection', () => { + it('returns "desc" for priority, usage, and growth', () => { + expect(getDefaultStorageSortDirection('priority')).toBe('desc'); + expect(getDefaultStorageSortDirection('usage')).toBe('desc'); + expect(getDefaultStorageSortDirection('growth')).toBe('desc'); + }); + + it('returns "asc" for name, state, type, host, and protection', () => { + expect(getDefaultStorageSortDirection('name')).toBe('asc'); + expect(getDefaultStorageSortDirection('state')).toBe('asc'); + expect(getDefaultStorageSortDirection('type')).toBe('asc'); + expect(getDefaultStorageSortDirection('host')).toBe('asc'); + expect(getDefaultStorageSortDirection('protection')).toBe('asc'); + }); + }); + + describe('countActiveStorageFilters', () => { + it('returns 0 when every field is at its default', () => { + expect(countActiveStorageFilters({ ...baseInactiveState })).toBe(0); + }); + + it('counts only non-whitespace search', () => { + expect(countActiveStorageFilters({ ...baseInactiveState, search: ' ' })).toBe(0); + expect(countActiveStorageFilters({ ...baseInactiveState, search: 'tank' })).toBe(1); + }); + + it('counts groupBy when not "none"', () => { + expect(countActiveStorageFilters({ ...baseInactiveState, groupBy: 'node' })).toBe(1); + expect(countActiveStorageFilters({ ...baseInactiveState, groupBy: 'type' })).toBe(1); + }); + + it('counts statusFilter when not "all"', () => { + expect(countActiveStorageFilters({ ...baseInactiveState, statusFilter: 'critical' })).toBe(1); + }); + + it('counts sourceFilter when not "all"', () => { + expect(countActiveStorageFilters({ ...baseInactiveState, sourceFilter: 'agent' })).toBe(1); + }); + + it('counts diskRoleFilter when not at default', () => { + expect( + countActiveStorageFilters({ ...baseInactiveState, diskRoleFilter: 'nvme-disk' }), + ).toBe(1); + }); + + it('counts diskGroupFilter when not at default', () => { + expect( + countActiveStorageFilters({ ...baseInactiveState, diskGroupFilter: 'data-pool' }), + ).toBe(1); + }); + + it('sums to 6 when all six filter dimensions are active', () => { + expect( + countActiveStorageFilters({ + search: 'tank', + sortKey: 'priority', + sortDirection: 'desc', + groupBy: 'node', + statusFilter: 'critical', + sourceFilter: 'agent', + diskRoleFilter: 'nvme-disk', + diskGroupFilter: 'data-pool', + }), + ).toBe(6); + }); + }); + + describe('hasActiveStorageFilters', () => { + it('returns false at full default state', () => { + expect(hasActiveStorageFilters({ ...baseInactiveState })).toBe(false); + }); + + it('returns true for non-whitespace search', () => { + expect(hasActiveStorageFilters({ ...baseInactiveState, search: 'tank' })).toBe(true); + }); + + it('returns false for whitespace-only search', () => { + expect(hasActiveStorageFilters({ ...baseInactiveState, search: ' ' })).toBe(false); + }); + + it('returns true when sortKey differs from default', () => { + expect(hasActiveStorageFilters({ ...baseInactiveState, sortKey: 'name' })).toBe(true); + }); + + it('returns true when sortDirection differs from default', () => { + expect(hasActiveStorageFilters({ ...baseInactiveState, sortDirection: 'asc' })).toBe(true); + }); + + it('returns true when groupBy differs from default', () => { + expect(hasActiveStorageFilters({ ...baseInactiveState, groupBy: 'type' })).toBe(true); + }); + + it('returns true when statusFilter differs from default', () => { + expect(hasActiveStorageFilters({ ...baseInactiveState, statusFilter: 'warning' })).toBe(true); + }); + + it('returns true when sourceFilter differs from default', () => { + expect(hasActiveStorageFilters({ ...baseInactiveState, sourceFilter: 'agent' })).toBe(true); + }); + + it('returns true when diskRoleFilter differs from default', () => { + expect( + hasActiveStorageFilters({ ...baseInactiveState, diskRoleFilter: 'nvme-disk' }), + ).toBe(true); + }); + + it('returns true when diskGroupFilter differs from default', () => { + expect( + hasActiveStorageFilters({ ...baseInactiveState, diskGroupFilter: 'data-pool' }), + ).toBe(true); + }); + }); + + describe('coerceSelectedStorageNodeId', () => { + const nodeOptions: StoragePageNodeOption[] = [ + { id: 'node-1', label: 'pve1' }, + { id: 'node-2', label: 'pve2' }, + ]; + + it('returns "all" unchanged', () => { + expect(coerceSelectedStorageNodeId('all', nodeOptions)).toBe('all'); + }); + + it('returns the id when it matches a known node option', () => { + expect(coerceSelectedStorageNodeId('node-1', nodeOptions)).toBe('node-1'); + expect(coerceSelectedStorageNodeId('node-2', nodeOptions)).toBe('node-2'); + }); + + it('falls back to "all" when the id is not in nodeOptions', () => { + expect(coerceSelectedStorageNodeId('missing', nodeOptions)).toBe('all'); + }); + + it('falls back to "all" when nodeOptions is empty', () => { + expect(coerceSelectedStorageNodeId('node-1', [])).toBe('all'); + }); + }); + + describe('route serde round-trip', () => { + const noop = () => {}; + const makeFields = () => + buildStorageRouteFields({ + view: () => 'pools', + setView: noop, + sourceFilter: () => 'all', + setSourceFilter: noop, + healthFilter: () => 'all', + setHealthFilter: noop, + diskRoleFilter: () => 'all', + setDiskRoleFilter: noop, + diskGroupFilter: () => 'all', + setDiskGroupFilter: noop, + selectedNodeId: () => 'all', + setSelectedNodeId: noop, + groupBy: () => 'none', + setGroupBy: noop, + sortKey: () => 'priority', + setSortKey: noop, + sortDirection: () => 'desc', + setSortDirection: noop, + search: () => '', + setSearch: noop, + }); + + // --- normalizeStorageRouteSelection (private, covered via node field) --- + + it('node.read returns "all" for "all" in any case, trimmed', () => { + const fields = makeFields(); + expect(fields.node!.read({ node: 'all' } as any)).toBe(DEFAULT_STORAGE_SELECTED_NODE_ID); + expect(fields.node!.read({ node: 'ALL' } as any)).toBe(DEFAULT_STORAGE_SELECTED_NODE_ID); + }); + + it('node.read returns the trimmed id (case preserved) for non-"all" values', () => { + const fields = makeFields(); + expect(fields.node!.read({ node: 'node-9' } as any)).toBe('node-9'); + expect(fields.node!.read({ node: ' Node-ABC ' } as any)).toBe('Node-ABC'); + }); + + it('node.read falls back to "all" when normalizeStorageRouteSelection yields empty', () => { + const fields = makeFields(); + expect(fields.node!.read({ node: '' } as any)).toBe(DEFAULT_STORAGE_SELECTED_NODE_ID); + expect(fields.node!.read({ node: ' ' } as any)).toBe(DEFAULT_STORAGE_SELECTED_NODE_ID); + expect(fields.node!.read({ node: undefined } as any)).toBe(DEFAULT_STORAGE_SELECTED_NODE_ID); + }); + + it('node.write returns null when the value resolves to "all"', () => { + const fields = makeFields(); + expect(fields.node!.write?.('all')).toBeNull(); + expect(fields.node!.write?.(' ')).toBeNull(); + }); + + it('node.write returns the resolved id when it is not "all"', () => { + const fields = makeFields(); + expect(fields.node!.write?.('node-9')).toBe('node-9'); + }); + + // --- unknown / invalid string fallbacks to defaults --- + + it('tab.read falls back to "pools" for unknown string', () => { + const fields = makeFields(); + expect(fields.tab!.read({ tab: 'unknown' } as any)).toBe('pools'); + expect(fields.tab!.read({ tab: 'weird' } as any)).toBe('pools'); + }); + + it('source.read falls back to "all" for empty / undefined / whitespace', () => { + const fields = makeFields(); + expect(fields.source!.read({ source: '' } as any)).toBe('all'); + expect(fields.source!.read({ source: undefined } as any)).toBe('all'); + expect(fields.source!.read({ source: ' ' } as any)).toBe('all'); + }); + + it('status.read falls back to "all" for unknown string and undefined', () => { + const fields = makeFields(); + expect(fields.status!.read({ status: 'banana' } as any)).toBe('all'); + expect(fields.status!.read({ status: undefined } as any)).toBe('all'); + expect(fields.status!.read({} as any)).toBe('all'); + }); + + it('diskRole.read falls back to default for undefined / empty', () => { + const fields = makeFields(); + expect(fields.diskRole!.read({ diskRole: undefined } as any)).toBe( + DEFAULT_STORAGE_DISK_ROLE_FILTER, + ); + expect(fields.diskRole!.read({ diskRole: ' ' } as any)).toBe( + DEFAULT_STORAGE_DISK_ROLE_FILTER, + ); + }); + + it('diskGroup.read falls back to default for undefined / empty', () => { + const fields = makeFields(); + expect(fields.diskGroup!.read({ diskGroup: undefined } as any)).toBe( + DEFAULT_STORAGE_DISK_GROUP_FILTER, + ); + expect(fields.diskGroup!.read({ diskGroup: ' ' } as any)).toBe( + DEFAULT_STORAGE_DISK_GROUP_FILTER, + ); + }); + + it('group.read falls back to "none" for unknown string', () => { + const fields = makeFields(); + expect(fields.group!.read({ group: 'banana' } as any)).toBe('none'); + }); + + it('sort.read falls back to "priority" for unknown string', () => { + const fields = makeFields(); + expect(fields.sort!.read({ sort: 'banana' } as any)).toBe('priority'); + }); + + it('order.read falls back to "desc" for unknown string', () => { + const fields = makeFields(); + expect(fields.order!.read({ order: 'banana' } as any)).toBe('desc'); + }); + + it('query.read falls back to empty string for undefined', () => { + const fields = makeFields(); + expect(fields.query!.read({ query: undefined } as any)).toBe(''); + expect(fields.query!.read({} as any)).toBe(''); + }); + + // --- write serde round-trip: default values serialize to null --- + + it('source.write returns null for the default "all"', () => { + const fields = makeFields(); + expect(fields.source!.write?.('all')).toBeNull(); + }); + + it('group.write returns null for "none"', () => { + const fields = makeFields(); + expect(fields.group!.write?.('none')).toBeNull(); + }); + + it('sort.write returns null for "priority"', () => { + const fields = makeFields(); + expect(fields.sort!.write?.('priority')).toBeNull(); + }); + + it('order.write returns null for "desc"', () => { + const fields = makeFields(); + expect(fields.order!.write?.('desc')).toBeNull(); + }); + }); +}); diff --git a/frontend-modern/src/features/alerts/__tests__/alertHistoryModel.coverage.test.ts b/frontend-modern/src/features/alerts/__tests__/alertHistoryModel.coverage.test.ts new file mode 100644 index 0000000000..bedd647537 --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/alertHistoryModel.coverage.test.ts @@ -0,0 +1,476 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyAlertHistoryWindow, + buildAlertAxisTicks, + buildAlertHistoryParams, + buildAlertTrends, + filterAlertHistoryItems, + getIncidentRowKey, + groupAlertHistoryItems, + MS_PER_HOUR, + type AlertHistoryRange, + type AlertTrendSeries, + type HistoryItem, +} from '../alertHistoryModel'; + +function makeItem(overrides: Partial = {}): HistoryItem { + return { + id: 'alert-1', + source: 'alert', + status: 'resolved', + startTime: '2026-03-22T09:00:00.000Z', + duration: '1h', + resourceName: 'vm-101', + resourceType: 'VM', + severity: 'critical', + title: 'CPU High', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// getIncidentRowKey (zero-hit) +// --------------------------------------------------------------------------- + +describe('getIncidentRowKey', () => { + it('joins id and startTime with ::', () => { + expect( + getIncidentRowKey(makeItem({ id: 'a1', startTime: '2026-03-22T09:00:00.000Z' })), + ).toBe('a1::2026-03-22T09:00:00.000Z'); + }); + + it('produces distinct keys for same id with different startTime', () => { + const a = makeItem({ id: 'x', startTime: '2026-03-22T09:00:00.000Z' }); + const b = makeItem({ id: 'x', startTime: '2026-03-22T10:00:00.000Z' }); + expect(getIncidentRowKey(a)).not.toBe(getIncidentRowKey(b)); + }); +}); + +// --------------------------------------------------------------------------- +// buildAlertHistoryParams – default switch branch + default now param +// --------------------------------------------------------------------------- + +describe('buildAlertHistoryParams', () => { + it('falls back to limit 1000 with no startTime for an unrecognised range', () => { + const result = buildAlertHistoryParams( + 'bogus' as AlertHistoryRange, + Date.UTC(2026, 2, 22), + ); + expect(result).toEqual({ limit: 1000 }); + expect(result.startTime).toBeUndefined(); + }); + + it('uses Date.now() when now is omitted', () => { + const before = Date.now(); + const result = buildAlertHistoryParams('24h'); + const after = Date.now(); + const startMs = new Date(result.startTime!).getTime(); + expect(startMs).toBeGreaterThanOrEqual(before - 24 * MS_PER_HOUR); + expect(startMs).toBeLessThanOrEqual(after - 24 * MS_PER_HOUR); + }); +}); + +// --------------------------------------------------------------------------- +// filterAlertHistoryItems +// --------------------------------------------------------------------------- + +describe('filterAlertHistoryItems', () => { + const items: HistoryItem[] = [ + makeItem({ + id: '1', + severity: 'critical', + resourceName: 'vm-101', + title: 'CPU High', + description: 'cpu 95%', + node: 'px1', + }), + makeItem({ + id: '2', + severity: 'warning', + resourceName: 'db-1', + title: 'Disk Full', + description: 'disk 90%', + node: 'px2', + }), + makeItem({ + id: '3', + severity: 'critical', + resourceName: 'web-1', + title: 'Memory', + description: 'mem high', + }), + ]; + + it('returns all items for severity "all" with empty search', () => { + expect(filterAlertHistoryItems(items, 'all', '')).toHaveLength(3); + }); + + it('treats whitespace-only search as no search', () => { + expect(filterAlertHistoryItems(items, 'all', ' \t ')).toHaveLength(3); + }); + + it('filters to warning severity only', () => { + const result = filterAlertHistoryItems(items, 'warning', ''); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('2'); + }); + + it('filters to critical severity only', () => { + const result = filterAlertHistoryItems(items, 'critical', ''); + expect(result.map((i) => i.id)).toEqual(['1', '3']); + }); + + it('matches case-insensitively on resourceName', () => { + const result = filterAlertHistoryItems(items, 'all', 'VM-101'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('1'); + }); + + it('matches on title', () => { + const result = filterAlertHistoryItems(items, 'all', 'disk'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('2'); + }); + + it('matches on description', () => { + const result = filterAlertHistoryItems(items, 'all', '95%'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('1'); + }); + + it('matches on node', () => { + const result = filterAlertHistoryItems(items, 'all', 'px2'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('2'); + }); + + it('combines severity and search filters', () => { + const result = filterAlertHistoryItems(items, 'critical', 'vm'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('1'); + }); + + it('returns empty array when nothing matches the search', () => { + expect(filterAlertHistoryItems(items, 'all', 'nonexistent')).toHaveLength(0); + }); + + it('handles items with undefined optional fields without throwing', () => { + const item = makeItem({ id: 'u', description: undefined, node: undefined }); + expect(filterAlertHistoryItems([item], 'all', '')).toHaveLength(1); + expect(filterAlertHistoryItems([item], 'all', 'zzz')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// buildAlertTrends – 7d, 30d, all-empty, all-with-data, window edges +// --------------------------------------------------------------------------- + +describe('buildAlertTrends', () => { + const now = Date.UTC(2026, 2, 22, 12, 0, 0); + + it('uses 6-hour buckets for 7d range', () => { + const trends = buildAlertTrends([], '7d', now); + expect(trends.bucketSize).toBe(6); + expect(trends.buckets).toHaveLength(28); + expect(trends.rangeHours).toBe(168); + }); + + it('uses 24-hour buckets for 30d range', () => { + const trends = buildAlertTrends([], '30d', now); + expect(trends.bucketSize).toBe(24); + expect(trends.buckets).toHaveLength(30); + expect(trends.rangeHours).toBe(720); + }); + + it('defaults to 24-hour single bucket for "all" range with no alerts', () => { + const trends = buildAlertTrends([], 'all', now); + expect(trends.bucketSize).toBe(24); + expect(trends.buckets).toHaveLength(1); + expect(trends.buckets[0]).toBe(0); + expect(trends.max).toBe(1); + expect(trends.rangeHours).toBe(24); + }); + + it('computes bucket size from earliest alert for "all" range with data', () => { + const alerts = [ + makeItem({ startTime: new Date(now - 50 * MS_PER_HOUR).toISOString() }), + makeItem({ startTime: new Date(now - 10 * MS_PER_HOUR).toISOString() }), + ]; + const trends = buildAlertTrends(alerts, 'all', now); + expect(trends.bucketSize).toBe(2); + expect(trends.buckets).toHaveLength(25); + expect(trends.rangeHours).toBe(50); + }); + + it('snaps bucket size up to the next nice value', () => { + const alerts = [makeItem({ startTime: new Date(now - 200 * MS_PER_HOUR).toISOString() })]; + const trends = buildAlertTrends(alerts, 'all', now); + // rawBucketSize = ceil(200/30) = 7, next nice >= 7 is 12 + expect(trends.bucketSize).toBe(12); + }); + + it('clamps alert at window end into the last bucket', () => { + const alerts = [makeItem({ startTime: new Date(now).toISOString() })]; + const trends = buildAlertTrends(alerts, '24h', now); + expect(trends.buckets[trends.buckets.length - 1]).toBe(1); + }); + + it('ignores alerts before the trend window', () => { + const alerts = [makeItem({ startTime: new Date(now - 25 * MS_PER_HOUR).toISOString() })]; + const trends = buildAlertTrends(alerts, '24h', now); + expect(trends.buckets.reduce((s, v) => s + v, 0)).toBe(0); + }); + + it('ignores alerts after now', () => { + const alerts = [makeItem({ startTime: new Date(now + 5 * MS_PER_HOUR).toISOString() })]; + const trends = buildAlertTrends(alerts, '24h', now); + expect(trends.buckets.reduce((s, v) => s + v, 0)).toBe(0); + }); + + it('reports max of at least 1 for empty buckets', () => { + const trends = buildAlertTrends([], '24h', now); + expect(trends.max).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// applyAlertHistoryWindow – selectedBar, all-range, cutoffs, sort +// --------------------------------------------------------------------------- + +describe('applyAlertHistoryWindow', () => { + const now = Date.UTC(2026, 2, 22, 12, 0, 0); + + it('filters to the selected bucket window', () => { + const trends = buildAlertTrends([], '24h', now); + const bucketIndex = 23; + const bucketStart = trends.bucketTimes[bucketIndex]; + const bucketEnd = bucketStart + trends.bucketSize * MS_PER_HOUR; + + const items: HistoryItem[] = [ + makeItem({ id: 'inside', startTime: new Date(bucketStart + 1000).toISOString() }), + makeItem({ id: 'before', startTime: new Date(bucketStart - MS_PER_HOUR).toISOString() }), + makeItem({ id: 'at-end', startTime: new Date(bucketEnd).toISOString() }), + ]; + + const result = applyAlertHistoryWindow({ + filteredItems: items, + timeFilter: '24h', + selectedBarIndex: bucketIndex, + trends, + now, + }); + + expect(result.map((i) => i.id)).toEqual(['inside']); + }); + + it('does not apply time cutoff for "all" range with no selected bar', () => { + const trends = buildAlertTrends([], 'all', now); + const items: HistoryItem[] = [ + makeItem({ id: 'recent', startTime: new Date(now - MS_PER_HOUR).toISOString() }), + makeItem({ id: 'ancient', startTime: new Date(now - 365 * 24 * MS_PER_HOUR).toISOString() }), + ]; + + const result = applyAlertHistoryWindow({ + filteredItems: items, + timeFilter: 'all', + selectedBarIndex: null, + trends, + now, + }); + + expect(result).toHaveLength(2); + }); + + it('applies 7d cutoff when no bar is selected', () => { + const trends = buildAlertTrends([], '7d', now); + const items: HistoryItem[] = [ + makeItem({ id: 'within', startTime: new Date(now - 3 * 24 * MS_PER_HOUR).toISOString() }), + makeItem({ id: 'beyond', startTime: new Date(now - 10 * 24 * MS_PER_HOUR).toISOString() }), + ]; + + const result = applyAlertHistoryWindow({ + filteredItems: items, + timeFilter: '7d', + selectedBarIndex: null, + trends, + now, + }); + + expect(result.map((i) => i.id)).toEqual(['within']); + }); + + it('sorts items by startTime descending', () => { + const trends = buildAlertTrends([], 'all', now); + const items: HistoryItem[] = [ + makeItem({ id: 'oldest', startTime: new Date(now - 100 * MS_PER_HOUR).toISOString() }), + makeItem({ id: 'newest', startTime: new Date(now - 1 * MS_PER_HOUR).toISOString() }), + makeItem({ id: 'middle', startTime: new Date(now - 50 * MS_PER_HOUR).toISOString() }), + ]; + + const result = applyAlertHistoryWindow({ + filteredItems: items, + timeFilter: 'all', + selectedBarIndex: null, + trends, + now, + }); + + expect(result.map((i) => i.id)).toEqual(['newest', 'middle', 'oldest']); + }); +}); + +// --------------------------------------------------------------------------- +// groupAlertHistoryItems +// --------------------------------------------------------------------------- + +describe('groupAlertHistoryItems', () => { + it('returns empty array for empty input', () => { + expect(groupAlertHistoryItems([])).toHaveLength(0); + }); + + it('groups same-day alerts into one group', () => { + const items = [ + makeItem({ id: 'a', startTime: '2026-01-15T12:00:00.000Z' }), + makeItem({ id: 'b', startTime: '2026-01-15T14:00:00.000Z' }), + ]; + const groups = groupAlertHistoryItems(items); + expect(groups).toHaveLength(1); + expect(groups[0].alerts).toHaveLength(2); + }); + + it('sorts groups descending by date', () => { + const items = [ + makeItem({ id: 'old', startTime: '2026-01-15T12:00:00.000Z' }), + makeItem({ id: 'new', startTime: '2026-06-15T12:00:00.000Z' }), + ]; + const groups = groupAlertHistoryItems(items); + expect(groups).toHaveLength(2); + expect(groups[0].date.getTime()).toBeGreaterThan(groups[1].date.getTime()); + }); + + it('labels today and yesterday relative to the current date', () => { + const realNow = new Date(); + const todayMidnight = new Date( + realNow.getFullYear(), + realNow.getMonth(), + realNow.getDate(), + ); + const yesterdayMidnight = new Date(todayMidnight); + yesterdayMidnight.setDate(yesterdayMidnight.getDate() - 1); + + const items = [ + makeItem({ + id: 'today', + startTime: new Date(todayMidnight.getTime() + 3600000).toISOString(), + }), + makeItem({ + id: 'yesterday', + startTime: new Date(yesterdayMidnight.getTime() + 3600000).toISOString(), + }), + ]; + const groups = groupAlertHistoryItems(items); + expect(groups).toHaveLength(2); + expect(groups[0].label).toMatch(/^Today /); + expect(groups[1].label).toMatch(/^Yesterday /); + }); + + it('uses absolute date label for non-relative dates', () => { + const items = [makeItem({ startTime: '2026-01-15T12:00:00.000Z' })]; + const groups = groupAlertHistoryItems(items); + expect(groups[0].label).not.toMatch(/^Today/); + expect(groups[0].label).not.toMatch(/^Yesterday/); + expect(groups[0].label).toMatch(/January/); + }); + + it('includes weekday and year in fullLabel', () => { + const items = [makeItem({ startTime: '2026-01-15T12:00:00.000Z' })]; + const groups = groupAlertHistoryItems(items); + expect(groups[0].fullLabel).toMatch(/2026/); + expect(groups[0].fullLabel).toMatch(/January/); + }); +}); + +// --------------------------------------------------------------------------- +// buildAlertAxisTicks +// --------------------------------------------------------------------------- + +describe('buildAlertAxisTicks', () => { + const now = Date.UTC(2026, 2, 22, 12, 0, 0); + + it('returns empty array when bucketTimes is empty', () => { + const trends: AlertTrendSeries = { + buckets: [], + max: 0, + bucketSize: 1, + bucketTimes: [], + rangeStart: now, + rangeHours: 0, + }; + expect(buildAlertAxisTicks(trends, 'en-US')).toEqual([]); + }); + + it('returns empty array when bucketSize is zero', () => { + const trends: AlertTrendSeries = { + buckets: [1], + max: 1, + bucketSize: 0, + bucketTimes: [now], + rangeStart: now, + rangeHours: 1, + }; + expect(buildAlertAxisTicks(trends, 'en-US')).toEqual([]); + }); + + it('places first tick at position 0 and last tick at position 1', () => { + const trends = buildAlertTrends([], '24h', now); + const ticks = buildAlertAxisTicks(trends, 'en-US'); + expect(ticks.length).toBeGreaterThanOrEqual(2); + expect(ticks[0].position).toBe(0); + expect(ticks[0].align).toBe('start'); + expect(ticks[ticks.length - 1].position).toBe(1); + expect(ticks[ticks.length - 1].align).toBe('end'); + }); + + it('assigns center align to all intermediate ticks', () => { + const trends = buildAlertTrends([], '30d', now); + const ticks = buildAlertAxisTicks(trends, 'en-US'); + for (let i = 1; i < ticks.length - 1; i++) { + expect(ticks[i].align).toBe('center'); + } + }); + + it('produces non-empty string labels for every tick', () => { + const trends = buildAlertTrends([], '24h', now); + const ticks = buildAlertAxisTicks(trends, 'en-US'); + for (const tick of ticks) { + expect(typeof tick.label).toBe('string'); + expect(tick.label.length).toBeGreaterThan(0); + } + }); + + it('replaces the last tick when loop already reaches position 1', () => { + // Craft trends so bucketTimes has 2 entries but buckets.length is 1. + // totalDurationMs = 1h, loop hits indices 0 and 1, + // index 1 maps to position 1h/1h = 1.0 → replace branch. + const trends: AlertTrendSeries = { + buckets: [0], + max: 1, + bucketSize: 1, + bucketTimes: [now, now + MS_PER_HOUR], + rangeStart: now, + rangeHours: 1, + }; + const ticks = buildAlertAxisTicks(trends, 'en-US'); + expect(ticks).toHaveLength(2); + expect(ticks[0].position).toBe(0); + expect(ticks[1].position).toBe(1); + }); + + it('limits tick count to a reasonable maximum', () => { + const trends = buildAlertTrends([], '30d', now); + const ticks = buildAlertAxisTicks(trends, 'en-US'); + expect(ticks.length).toBeLessThanOrEqual(7); + expect(ticks.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/frontend-modern/src/features/alerts/__tests__/alertOverridesModel.coverage.test.ts b/frontend-modern/src/features/alerts/__tests__/alertOverridesModel.coverage.test.ts new file mode 100644 index 0000000000..a6dcb7178b --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/alertOverridesModel.coverage.test.ts @@ -0,0 +1,404 @@ +import { describe, expect, it } from 'vitest'; + +import type { RawOverrideConfig } from '@/types/alerts'; +import type { Resource } from '@/types/resource'; + +import { + buildProjectedOverrides, + normalizeRawOverridesConfig, + storageOverrideActionId, + storageOverrideIdCandidates, +} from '../alertOverridesModel'; + +const makeResource = (overrides: Partial): Resource => + ({ + id: 'resource-1', + name: 'resource-1', + type: 'vm', + ...overrides, + }) as Resource; + +const cpuThreshold = (trigger: number, clear: number): RawOverrideConfig => + ({ cpu: { trigger, clear } }) as RawOverrideConfig; + +describe('alertOverridesModel coverage', () => { + describe('storageOverrideIdCandidates', () => { + it('prepends metricsTarget resource id when resourceType is storage', () => { + expect( + storageOverrideIdCandidates( + makeResource({ + id: 'storage-hash', + metricsTarget: { resourceType: 'storage', resourceId: 'canonical-id' }, + }), + ), + ).toEqual(['canonical-id', 'storage-hash']); + }); + + it('returns only resource id when metricsTarget resourceType is not storage', () => { + expect( + storageOverrideIdCandidates( + makeResource({ + id: 'storage-hash', + metricsTarget: { resourceType: 'agent', resourceId: 'other' }, + }), + ), + ).toEqual(['storage-hash']); + }); + + it('returns only resource id when metricsTarget is absent', () => { + expect(storageOverrideIdCandidates(makeResource({ id: 'storage-hash' }))).toEqual([ + 'storage-hash', + ]); + }); + }); + + describe('storageOverrideActionId', () => { + it('returns the first candidate (metricsTarget resource id)', () => { + expect( + storageOverrideActionId( + makeResource({ + id: 'storage-hash', + metricsTarget: { resourceType: 'storage', resourceId: 'canonical-id' }, + }), + ), + ).toBe('canonical-id'); + }); + + it('falls back to resource id when no valid candidates exist', () => { + expect(storageOverrideActionId(makeResource({ id: '' }))).toBe(''); + }); + }); + + describe('buildSharedStorageLegacyKeyMap (via normalizeRawOverridesConfig)', () => { + it('skips non-shared storage resources', () => { + const storage = makeResource({ + id: 's1', + name: 'local-lvm', + type: 'storage', + platformId: 'Main', + metricsTarget: { resourceType: 'storage', resourceId: 'canonical-1' }, + storage: { shared: false, nodes: ['node-a'] }, + }); + + expect( + normalizeRawOverridesConfig( + { 'Main-node-a-local-lvm': cpuThreshold(90, 80) }, + [storage], + ), + ).toEqual({ 'Main-node-a-local-lvm': cpuThreshold(90, 80) }); + }); + + it('skips resources with whitespace-only name', () => { + const storage = makeResource({ + id: 's1', + name: ' ', + type: 'storage', + platformId: 'Main', + metricsTarget: { resourceType: 'storage', resourceId: 'canonical-1' }, + storage: { shared: true, nodes: ['node-a'] }, + }); + + expect( + normalizeRawOverridesConfig( + { 'Main-node-a-': cpuThreshold(90, 80) }, + [storage], + ), + ).toEqual({ 'Main-node-a-': cpuThreshold(90, 80) }); + }); + + it('skips resources with empty canonical id', () => { + const storage = makeResource({ + id: '', + name: 'ceph-pool', + type: 'storage', + platformId: 'Main', + storage: { shared: true, nodes: ['node-a'] }, + }); + + expect( + normalizeRawOverridesConfig( + { 'Main-node-a-ceph-pool': cpuThreshold(90, 80) }, + [storage], + ), + ).toEqual({ 'Main-node-a-ceph-pool': cpuThreshold(90, 80) }); + }); + + it('uses platformId as instance when proxmox.instance is absent', () => { + const storage = makeResource({ + id: 's1', + name: 'nfs-share', + type: 'storage', + platformId: 'DC1', + metricsTarget: { resourceType: 'storage', resourceId: 'canonical-1' }, + storage: { shared: true, nodes: ['node-a'] }, + }); + + expect( + normalizeRawOverridesConfig( + { 'DC1-node-a-nfs-share': cpuThreshold(90, 80) }, + [storage], + ), + ).toEqual({ 'canonical-1': cpuThreshold(90, 80) }); + }); + + it('does not double-prefix when node already starts with instance', () => { + const storage = makeResource({ + id: 's1', + name: 'ceph-pool', + type: 'storage', + platformId: 'pve1', + metricsTarget: { resourceType: 'storage', resourceId: 'canonical-1' }, + storage: { shared: true, nodes: ['pve1-node-a'] }, + }); + + expect( + normalizeRawOverridesConfig( + { 'pve1-node-a-ceph-pool': cpuThreshold(90, 80) }, + [storage], + ), + ).toEqual({ 'canonical-1': cpuThreshold(90, 80) }); + }); + + it('skips empty and whitespace-only node entries', () => { + const storage = makeResource({ + id: 's1', + name: 'ceph-pool', + type: 'storage', + platformId: 'Main', + metricsTarget: { resourceType: 'storage', resourceId: 'canonical-1' }, + storage: { shared: true, nodes: ['valid-node', '', ' '] }, + }); + + expect( + normalizeRawOverridesConfig( + { 'Main-valid-node-ceph-pool': cpuThreshold(90, 80) }, + [storage], + ), + ).toEqual({ 'canonical-1': cpuThreshold(90, 80) }); + }); + + it('produces no legacy keys when storage has no nodes array', () => { + const storage = makeResource({ + id: 's1', + name: 'ceph-pool', + type: 'storage', + platformId: 'Main', + metricsTarget: { resourceType: 'storage', resourceId: 'canonical-1' }, + storage: { shared: true }, + }); + + expect( + normalizeRawOverridesConfig( + { 'Main-node-a-ceph-pool': cpuThreshold(90, 80) }, + [storage], + ), + ).toEqual({ 'Main-node-a-ceph-pool': cpuThreshold(90, 80) }); + }); + + it('uses bare node name when both proxmox.instance and platformId are empty', () => { + const storage = makeResource({ + id: 's1', + name: 'ceph-pool', + type: 'storage', + platformId: '', + metricsTarget: { resourceType: 'storage', resourceId: 'canonical-1' }, + storage: { shared: true, nodes: ['solo-node'] }, + }); + + expect( + normalizeRawOverridesConfig( + { 'solo-node-ceph-pool': cpuThreshold(90, 80) }, + [storage], + ), + ).toEqual({ 'canonical-1': cpuThreshold(90, 80) }); + }); + }); + + describe('normalizeRawOverridesConfig (malformed/empty shapes)', () => { + it('returns empty object for empty input', () => { + expect(normalizeRawOverridesConfig({})).toEqual({}); + }); + + it('passes through plain keys unchanged', () => { + expect( + normalizeRawOverridesConfig({ 'plain-key': cpuThreshold(90, 80) }), + ).toEqual({ 'plain-key': cpuThreshold(90, 80) }); + }); + + it('normalizes disk labels that collapse to empty into unknown', () => { + expect( + normalizeRawOverridesConfig({ + 'agent:agent-1/disk:!!!': cpuThreshold(90, 80), + }), + ).toEqual({ + 'agent:agent-1/disk:unknown': cpuThreshold(90, 80), + }); + }); + + it('resolves canonical guest key regardless of insertion order', () => { + expect( + normalizeRawOverridesConfig({ + 'cluster-a:node-1:100': cpuThreshold(95, 90), + 'guest:cluster-a:100': cpuThreshold(70, 65), + }), + ).toEqual({ + 'guest:cluster-a:100': cpuThreshold(70, 65), + }); + }); + }); + + describe('alertResourceIdCandidates (via buildProjectedOverrides)', () => { + it('resolves alert platform overrides through metricsTarget resource id candidate', () => { + const cluster = makeResource({ + id: 'k8s-internal-uid', + type: 'k8s-cluster', + name: 'prod-cluster', + displayName: 'prod-cluster', + platformId: 'k8s-platform-1', + metricsTarget: { resourceType: 'k8s-cluster', resourceId: 'k8s-metrics-alias' }, + kubernetes: { clusterName: 'prod-cluster' }, + }); + + const result = buildProjectedOverrides({ + rawConfig: { 'k8s-metrics-alias': cpuThreshold(90, 80) }, + nodeResources: [], + vmResources: [], + containerResources: [], + storageResources: [], + agentResourceList: [], + containerRuntimeResources: [], + getChildren: () => [], + pbsInstanceById: new Map(), + allResources: [cluster], + }); + + expect(result).toEqual([ + expect.objectContaining({ + id: 'k8s-metrics-alias', + type: 'kubernetesCluster', + resourceType: 'Kubernetes Cluster', + instance: 'prod-cluster', + }), + ]); + }); + + it('resolves alert platform overrides through platformId candidate', () => { + const cluster = makeResource({ + id: 'k8s-internal-uid', + type: 'k8s-cluster', + name: 'prod-cluster', + displayName: 'prod-cluster', + platformId: 'k8s-platform-1', + kubernetes: { clusterName: 'prod-cluster' }, + }); + + const result = buildProjectedOverrides({ + rawConfig: { 'k8s-platform-1': cpuThreshold(90, 80) }, + nodeResources: [], + vmResources: [], + containerResources: [], + storageResources: [], + agentResourceList: [], + containerRuntimeResources: [], + getChildren: () => [], + pbsInstanceById: new Map(), + allResources: [cluster], + }); + + expect(result).toEqual([ + expect.objectContaining({ + id: 'k8s-platform-1', + type: 'kubernetesCluster', + }), + ]); + }); + }); + + describe('upsertProjectedOverride (via buildProjectedOverrides)', () => { + it('inserts separate overrides for distinct projected ids', () => { + const guest1 = makeResource({ + id: 'cluster-a:node-1:100', + name: 'vm-100', + type: 'vm', + proxmox: { vmid: 100, node: 'node-1', instance: 'cluster-a' }, + platformData: { proxmox: { vmid: 100, node: 'node-1', instance: 'cluster-a' } }, + }); + const guest2 = makeResource({ + id: 'cluster-a:node-1:101', + name: 'vm-101', + type: 'vm', + proxmox: { vmid: 101, node: 'node-1', instance: 'cluster-a' }, + platformData: { proxmox: { vmid: 101, node: 'node-1', instance: 'cluster-a' } }, + }); + + const result = buildProjectedOverrides({ + rawConfig: { + 'guest:cluster-a:100': cpuThreshold(70, 65), + 'guest:cluster-a:101': cpuThreshold(80, 75), + }, + nodeResources: [], + vmResources: [guest1, guest2], + containerResources: [], + storageResources: [], + agentResourceList: [], + containerRuntimeResources: [], + getChildren: () => [], + pbsInstanceById: new Map(), + }); + + expect(result).toHaveLength(2); + expect(result.map((o) => o.id)).toEqual( + expect.arrayContaining(['guest:cluster-a:100', 'guest:cluster-a:101']), + ); + }); + + it('updates existing override when multiple storage keys resolve to the same canonical id', () => { + const storage = makeResource({ + id: 'storage-hash', + name: 'ceph-pool', + displayName: 'ceph-pool', + type: 'storage', + platformId: 'Main', + metricsTarget: { resourceType: 'storage', resourceId: 'canonical-1' }, + platformData: { node: 'cluster', instance: 'Main' }, + }); + + const result = buildProjectedOverrides({ + rawConfig: { + 'canonical-1': cpuThreshold(70, 65), + 'storage-hash': cpuThreshold(95, 90), + }, + nodeResources: [], + vmResources: [], + containerResources: [], + storageResources: [storage], + agentResourceList: [], + containerRuntimeResources: [], + getChildren: () => [], + pbsInstanceById: new Map(), + }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('canonical-1'); + expect(result[0].thresholds.cpu).toBe(95); + }); + }); + + describe('buildProjectedOverrides edge cases', () => { + it('skips raw config keys that match no known resource', () => { + expect( + buildProjectedOverrides({ + rawConfig: { 'unknown-key': cpuThreshold(90, 80) }, + nodeResources: [], + vmResources: [], + containerResources: [], + storageResources: [], + agentResourceList: [], + containerRuntimeResources: [], + getChildren: () => [], + pbsInstanceById: new Map(), + }), + ).toEqual([]); + }); + }); +}); diff --git a/frontend-modern/src/features/docker/__tests__/dockerPageModel.coverage.test.ts b/frontend-modern/src/features/docker/__tests__/dockerPageModel.coverage.test.ts new file mode 100644 index 0000000000..918e027780 --- /dev/null +++ b/frontend-modern/src/features/docker/__tests__/dockerPageModel.coverage.test.ts @@ -0,0 +1,520 @@ +import { describe, expect, it } from 'vitest'; + +import type { Resource, ResourceRelationship } from '@/types/resource'; +import { + buildDockerNetworkAttachmentRows, + dockerContainerPortLabel, + dockerContainerPortsSummary, + dockerResourceSearchHaystack, + filterDockerResources, + mapDockerContainerStatus, +} from '../dockerPageModel'; + +const makeResource = ( + resource: Partial & Pick, +): Resource => ({ + ...resource, + name: resource.name ?? resource.id, + displayName: resource.displayName ?? resource.id, + platformId: resource.platformId ?? 'lab', + platformType: resource.platformType ?? 'docker', + sourceType: resource.sourceType ?? 'agent', + status: resource.status ?? 'online', + lastSeen: resource.lastSeen ?? 1_700_000_000_000, +}); + +const rel = ( + sourceId: string, + targetId: string, + metadata?: Record, +): ResourceRelationship => ({ + sourceId, + targetId, + type: 'attached_to', + confidence: 1, + active: true, + discoverer: 'docker_adapter', + observedAt: '2026-01-01T00:00:00Z', + lastSeenAt: '2026-01-01T00:00:00Z', + ...(metadata ? { metadata } : {}), +}); + +// --------------------------------------------------------------------------- +// TARGET 1: container-state ranking fallbacks (unknown / empty state) +// --------------------------------------------------------------------------- + +describe('mapDockerContainerStatus — state fallbacks', () => { + it('returns Unknown when no container state or status is present', () => { + expect( + mapDockerContainerStatus( + makeResource({ id: 'c-empty', type: 'app-container', status: '' as unknown as Resource['status'], docker: {} }), + ), + ).toEqual({ variant: 'muted', label: 'Unknown' }); + }); + + it('returns Unknown when docker is entirely absent and status is empty', () => { + expect( + mapDockerContainerStatus( + makeResource({ id: 'c-no-docker', type: 'app-container', status: '' as unknown as Resource['status'] }), + ), + ).toEqual({ variant: 'muted', label: 'Unknown' }); + }); + + it('title-cases unrecognized container states as muted', () => { + expect( + mapDockerContainerStatus( + makeResource({ id: 'c-unknown', type: 'app-container', docker: { containerState: 'quarantined' } }), + ), + ).toEqual({ variant: 'muted', label: 'Quarantined' }); + }); + + it('falls back to resource.status when containerState is absent', () => { + expect( + mapDockerContainerStatus( + makeResource({ id: 'c-status', type: 'app-container', status: 'restarting' as unknown as Resource['status'], docker: {} }), + ), + ).toEqual({ variant: 'warning', label: 'Restarting' }); + }); + + it.each([ + ['created', 'Created'], + ['paused', 'Paused'], + ['stopped', 'Stopped'], + ])('treats %s as a deliberately stopped state', (state, label) => { + expect( + mapDockerContainerStatus( + makeResource({ id: `c-${state}`, type: 'app-container', docker: { containerState: state } }), + ), + ).toEqual({ variant: 'muted', label }); + }); + + it('treats removing as a fatal state with a title-cased label', () => { + expect( + mapDockerContainerStatus( + makeResource({ id: 'c-removing', type: 'app-container', docker: { containerState: 'removing' } }), + ), + ).toEqual({ variant: 'danger', label: 'Removing' }); + }); +}); + +// --------------------------------------------------------------------------- +// TARGET 2: image/tag normalization (no-tag, registry-prefixed, digest) +// --------------------------------------------------------------------------- + +describe('dockerResourceSearchHaystack — image tags and digests', () => { + it('indexes registry-prefixed repo tags', () => { + const image = makeResource({ + id: 'img-registry', + type: 'docker-image', + docker: { + image: 'ghcr.io/acme/api:latest', + repoTags: ['ghcr.io/acme/api:v2'], + }, + }); + expect(dockerResourceSearchHaystack(image)).toContain('ghcr.io/acme/api:v2'); + expect(filterDockerResources([image], 'ghcr.io/acme/api:v2', 'all')).toHaveLength(1); + }); + + it('indexes repo digests with sha256 references', () => { + const image = makeResource({ + id: 'img-digest', + type: 'docker-image', + docker: { + repoDigests: ['acme/api@sha256:deadbeefcafe'], + }, + }); + expect(dockerResourceSearchHaystack(image)).toContain('acme/api@sha256:deadbeefcafe'); + expect(filterDockerResources([image], 'sha256:deadbeefcafe', 'all')).toHaveLength(1); + }); + + it('handles images with no repoTags or repoDigests without crashing', () => { + const image = makeResource({ + id: 'img-bare', + type: 'docker-image', + docker: { image: 'nginx' }, + }); + expect(filterDockerResources([image], 'nginx', 'all')).toHaveLength(1); + // repoTags/repoDigests absent — they simply contribute nothing + expect(dockerResourceSearchHaystack(image)).not.toContain('@sha256'); + }); + + it('matches the bare image name without a tag', () => { + const container = makeResource({ + id: 'c-bare-img', + type: 'app-container', + docker: { image: 'redis' }, + }); + expect(filterDockerResources([container], 'redis', 'all')).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// TARGET 3: port-map dedupe (port token shapes and summary fallback) +// --------------------------------------------------------------------------- + +describe('dockerContainerPortLabel — port token shapes', () => { + it('formats a fully mapped port with host IP', () => { + expect( + dockerContainerPortLabel({ ip: '0.0.0.0', publicPort: 8080, privatePort: 80, protocol: 'tcp' }), + ).toBe('0.0.0.0:8080->80/tcp'); + }); + + it('formats a fully mapped port without host IP', () => { + expect(dockerContainerPortLabel({ publicPort: 8080, privatePort: 80 })).toBe('8080->80/tcp'); + }); + + it('exposes only the private port when no host binding exists', () => { + expect(dockerContainerPortLabel({ privatePort: 443, protocol: 'tcp' })).toBe('443/tcp'); + }); + + it('formats a public port with IP when no private port exists', () => { + expect(dockerContainerPortLabel({ ip: '10.0.0.1', publicPort: 53, protocol: 'udp' })).toBe( + '10.0.0.1:53/udp', + ); + }); + + it('formats a public port without IP when no private port exists', () => { + expect(dockerContainerPortLabel({ publicPort: 53 })).toBe('53/tcp'); + }); + + it('falls back to just the protocol when neither port is present', () => { + expect(dockerContainerPortLabel({ protocol: 'udp' })).toBe('udp'); + }); + + it('defaults to tcp when protocol is missing and no ports are set', () => { + expect(dockerContainerPortLabel({})).toBe('tcp'); + }); +}); + +describe('dockerContainerPortsSummary — summary and empty fallback', () => { + it('joins multiple port labels with commas', () => { + const resource = makeResource({ + id: 'c-multi', + type: 'app-container', + docker: { + ports: [ + { ip: '0.0.0.0', publicPort: 80, privatePort: 8080, protocol: 'tcp' }, + { privatePort: 443, protocol: 'tcp' }, + ], + }, + }); + expect(dockerContainerPortsSummary(resource)).toBe('0.0.0.0:80->8080/tcp, 443/tcp'); + }); + + it('returns an em dash when the ports array is empty', () => { + expect( + dockerContainerPortsSummary( + makeResource({ id: 'c-empty-ports', type: 'app-container', docker: { ports: [] } }), + ), + ).toBe('—'); + }); + + it('returns an em dash when no ports field is present', () => { + expect( + dockerContainerPortsSummary(makeResource({ id: 'c-no-ports', type: 'app-container' })), + ).toBe('—'); + }); +}); + +// --------------------------------------------------------------------------- +// TARGET 4: volume mount source-candidate guards (missing source/name) +// --------------------------------------------------------------------------- + +describe('dockerResourceSearchHaystack — volume mount guards', () => { + it('indexes read-only mounts via rw=false', () => { + const container = makeResource({ + id: 'c-ro', + type: 'app-container', + docker: { + mounts: [{ type: 'bind', source: '/host/config', destination: '/config', mode: 'Z', rw: false }], + }, + }); + expect(dockerResourceSearchHaystack(container)).toContain('read-only'); + expect(filterDockerResources([container], 'read-only', 'all')).toHaveLength(1); + }); + + it('indexes read-write mounts via rw=true', () => { + const container = makeResource({ + id: 'c-rw', + type: 'app-container', + docker: { + mounts: [{ type: 'bind', source: '/host/data', destination: '/data', rw: true }], + }, + }); + expect(dockerResourceSearchHaystack(container)).toContain('read-write'); + expect(filterDockerResources([container], 'read-write', 'all')).toHaveLength(1); + }); + + it('omits read-only and read-write when rw is undefined', () => { + const container = makeResource({ + id: 'c-rw-undef', + type: 'app-container', + docker: { + mounts: [{ type: 'volume', source: '/host/data', destination: '/data' }], + }, + }); + const haystack = dockerResourceSearchHaystack(container); + expect(haystack).not.toContain('read-only'); + expect(haystack).not.toContain('read-write'); + }); + + it('still indexes destination when mount source is missing', () => { + const container = makeResource({ + id: 'c-no-src', + type: 'app-container', + docker: { + mounts: [{ type: 'volume', destination: '/var/lib/data' }], + }, + }); + expect(filterDockerResources([container], '/var/lib/data', 'all')).toHaveLength(1); + }); + + it('handles an anonymous volume mount with no source, name, or mode', () => { + const container = makeResource({ + id: 'c-anon', + type: 'app-container', + docker: { + mounts: [{ type: 'volume', destination: '/data' }], + }, + }); + // Does not crash; resource id still indexed + expect(filterDockerResources([container], 'c-anon', 'all')).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// TARGET 5: network alias empty/missing +// --------------------------------------------------------------------------- + +describe('buildDockerNetworkAttachmentRows — network alias and address fallbacks', () => { + it('uses ipv6 address when ipv4 is absent', () => { + const network = makeResource({ + id: 'net-1', + type: 'docker-network', + name: 'frontend', + docker: { hostSourceId: 'host-1' }, + }); + const container = makeResource({ + id: 'c-v6', + type: 'app-container', + name: 'api', + displayName: 'api', + status: 'running', + docker: { + hostSourceId: 'host-1', + containerState: 'running', + networks: [{ name: 'frontend', ipv6: 'fd00::42' }], + }, + }); + const rows = buildDockerNetworkAttachmentRows(network, [network, container]); + expect(rows).toHaveLength(1); + expect(rows[0].address).toBe('fd00::42'); + }); + + it('falls back to relationship metadata ipv4 when no attachment address exists', () => { + const network = makeResource({ + id: 'net-1', + type: 'docker-network', + name: 'frontend', + docker: { hostSourceId: 'host-1' }, + }); + const container = makeResource({ + id: 'c-rel-addr', + type: 'app-container', + name: 'api', + displayName: 'api', + status: 'running', + relationships: [rel('c-rel-addr', 'net-1', { ipv4: '172.20.0.5' })], + docker: { hostSourceId: 'host-1', containerState: 'running' }, + }); + const rows = buildDockerNetworkAttachmentRows(network, [network, container]); + expect(rows).toHaveLength(1); + expect(rows[0].address).toBe('172.20.0.5'); + }); + + it('returns an em dash when no address source is available', () => { + const network = makeResource({ + id: 'net-1', + type: 'docker-network', + name: 'frontend', + docker: { hostSourceId: 'host-1' }, + }); + const container = makeResource({ + id: 'c-no-addr', + type: 'app-container', + name: 'api', + displayName: 'api', + status: 'running', + relationships: [rel('c-no-addr', 'net-1')], + docker: { hostSourceId: 'host-1', containerState: 'running' }, + }); + const rows = buildDockerNetworkAttachmentRows(network, [network, container]); + expect(rows).toHaveLength(1); + expect(rows[0].address).toBe('—'); + }); + + it('uses relationship metadata networkName when attachment is absent', () => { + const network = makeResource({ + id: 'net-1', + type: 'docker-network', + name: 'frontend', + docker: { hostSourceId: 'host-1' }, + }); + const container = makeResource({ + id: 'c-rel-name', + type: 'app-container', + name: 'api', + displayName: 'api', + status: 'running', + relationships: [rel('c-rel-name', 'net-1', { networkName: 'legacy-frontend' })], + docker: { hostSourceId: 'host-1', containerState: 'running' }, + }); + const rows = buildDockerNetworkAttachmentRows(network, [network, container]); + expect(rows).toHaveLength(1); + expect(rows[0].networkName).toBe('legacy-frontend'); + }); + + it('falls back to the network display name when neither attachment nor relationship names exist', () => { + const network = makeResource({ + id: 'net-1', + type: 'docker-network', + name: 'frontend', + docker: { hostSourceId: 'host-1' }, + }); + const container = makeResource({ + id: 'c-fb-name', + type: 'app-container', + name: 'api', + displayName: 'api', + status: 'running', + relationships: [rel('c-fb-name', 'net-1')], + docker: { hostSourceId: 'host-1', containerState: 'running' }, + }); + const rows = buildDockerNetworkAttachmentRows(network, [network, container]); + expect(rows).toHaveLength(1); + expect(rows[0].networkName).toBe('frontend'); + }); + + it('excludes containers when the network has no name and only a host-scope attachment exists', () => { + const network = makeResource({ + id: 'net-2', + type: 'docker-network', + name: '', + displayName: '', + docker: { hostSourceId: 'host-1' }, + }); + const container = makeResource({ + id: 'c-excluded', + type: 'app-container', + name: 'api', + displayName: 'api', + status: 'running', + docker: { + hostSourceId: 'host-1', + containerState: 'running', + networks: [{ name: '', ipv4: '10.0.0.1' }], + }, + }); + // findContainerNetworkAttachment returns undefined when networkName is empty, + // and no relationship exists, so the container is excluded. + expect(buildDockerNetworkAttachmentRows(network, [network, container])).toEqual([]); + }); + + it('matches by hostname when hostSourceId is present on only one side', () => { + const network = makeResource({ + id: 'net-1', + type: 'docker-network', + name: 'frontend', + docker: { hostname: 'edge-01' }, + }); + const container = makeResource({ + id: 'c-hostname', + type: 'app-container', + name: 'api', + displayName: 'api', + status: 'running', + docker: { + hostSourceId: 'host-1', + hostname: 'edge-01', + containerState: 'running', + networks: [{ name: 'frontend', ipv4: '10.0.0.1' }], + }, + }); + const rows = buildDockerNetworkAttachmentRows(network, [network, container]); + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('api'); + }); + + it('excludes containers when hostnames differ and hostSourceId is only on one side', () => { + const network = makeResource({ + id: 'net-1', + type: 'docker-network', + name: 'frontend', + docker: { hostname: 'edge-01' }, + }); + const container = makeResource({ + id: 'c-hostname-mismatch', + type: 'app-container', + name: 'api', + displayName: 'api', + status: 'running', + docker: { + hostSourceId: 'host-2', + hostname: 'edge-02', + containerState: 'running', + networks: [{ name: 'frontend', ipv4: '10.0.0.1' }], + }, + }); + expect(buildDockerNetworkAttachmentRows(network, [network, container])).toEqual([]); + }); + + it('uses em dash for image when container has no image set', () => { + const network = makeResource({ + id: 'net-1', + type: 'docker-network', + name: 'frontend', + docker: { hostSourceId: 'host-1' }, + }); + const container = makeResource({ + id: 'c-no-img', + type: 'app-container', + name: 'api', + displayName: 'api', + status: 'running', + docker: { + hostSourceId: 'host-1', + containerState: 'running', + networks: [{ name: 'frontend', ipv4: '10.0.0.1' }], + }, + }); + const rows = buildDockerNetworkAttachmentRows(network, [network, container]); + expect(rows).toHaveLength(1); + expect(rows[0].image).toBe('—'); + }); +}); + +describe('dockerResourceSearchHaystack — network aliases', () => { + it('indexes network attachment names and addresses', () => { + const container = makeResource({ + id: 'c-net', + type: 'app-container', + docker: { + networks: [{ name: 'bridge', ipv4: '172.17.0.2', ipv6: 'fd00::3' }], + }, + }); + const haystack = dockerResourceSearchHaystack(container); + expect(haystack).toContain('bridge'); + expect(haystack).toContain('172.17.0.2'); + expect(haystack).toContain('fd00::3'); + expect(filterDockerResources([container], 'bridge', 'all')).toHaveLength(1); + }); + + it('handles an undefined networks array without crashing', () => { + const container = makeResource({ + id: 'c-no-nets', + type: 'app-container', + docker: { containerState: 'running' }, + }); + expect(filterDockerResources([container], 'c-no-nets', 'all')).toHaveLength(1); + }); +}); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.coverage.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.coverage.test.ts new file mode 100644 index 0000000000..41c7f645f3 --- /dev/null +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.coverage.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'vitest'; + +import type { PBSBackup } from '@/types/api'; +import type { Resource } from '@/types/resource'; + +import { + buildProxmoxBackupRecoveryModel, + CURRENT_RECOVERY_MS, + getRecoveryAgeBand, + STALE_RECOVERY_MS, +} from '../proxmoxBackupRecoveryModel'; + +// --------------------------------------------------------------------------- +// Fixture builders (same shapes as the sibling test file) +// --------------------------------------------------------------------------- + +const workload = (overrides: Partial): Resource => + ({ + id: 'vm-100', + type: 'vm', + name: 'web-01', + displayName: 'web-01', + platformId: 'pve-a', + platformType: 'proxmox-pve', + sourceType: 'api', + status: 'running', + lastSeen: Date.parse('2026-07-10T00:00:00Z'), + proxmox: { vmid: 100, node: 'node-a' }, + ...overrides, + }) as Resource; + +const pbsBackup = (overrides: Partial = {}): PBSBackup => ({ + id: 'pbs/main/ns1/vm/100/2026-07-09T00:00:00Z', + instance: 'pbs', + datastore: 'main', + namespace: 'ns1', + backupType: 'vm', + vmid: '100', + backupTime: '2026-07-09T00:00:00Z', + size: 1_000_000, + protected: false, + verified: true, + files: ['index.json.blob'], + owner: 'backup@pbs', + ...overrides, +}); + +const NOW = Date.parse('2026-07-10T00:00:00Z'); + +const emptyModel = (pbsBackups: PBSBackup[], workloads: Resource[] = []) => + buildProxmoxBackupRecoveryModel({ + workloads, + pbsBackups, + archives: [], + snapshots: [], + tasks: [], + nowMs: NOW, + }); + +// --------------------------------------------------------------------------- +// getRecoveryAgeBand — threshold boundaries & non-finite inputs +// --------------------------------------------------------------------------- + +describe('getRecoveryAgeBand boundary coverage', () => { + it('returns "current" at exactly the 7-day boundary (inclusive <=)', () => { + expect(getRecoveryAgeBand(NOW - CURRENT_RECOVERY_MS, NOW)).toBe('current'); + }); + + it('returns "aging" one millisecond past the current boundary', () => { + expect(getRecoveryAgeBand(NOW - CURRENT_RECOVERY_MS - 1, NOW)).toBe('aging'); + }); + + it('returns "aging" at exactly the 30-day boundary (inclusive <=)', () => { + expect(getRecoveryAgeBand(NOW - STALE_RECOVERY_MS, NOW)).toBe('aging'); + }); + + it('returns "stale" one millisecond past the stale boundary', () => { + expect(getRecoveryAgeBand(NOW - STALE_RECOVERY_MS - 1, NOW)).toBe('stale'); + }); + + it('returns "current" for a zero-age artifact (createdMs === nowMs)', () => { + expect(getRecoveryAgeBand(NOW, NOW)).toBe('current'); + }); +}); + +describe('getRecoveryAgeBand non-finite inputs', () => { + it('returns "unknown" for NaN createdMs', () => { + expect(getRecoveryAgeBand(NaN, NOW)).toBe('unknown'); + }); + + it('returns "unknown" for Infinity createdMs', () => { + expect(getRecoveryAgeBand(Infinity, NOW)).toBe('unknown'); + }); + + it('returns "unknown" when the computed age is non-finite (NaN nowMs)', () => { + // createdMs is finite so it passes the first guard, but ageMs = NaN - 1000 = NaN + expect(getRecoveryAgeBand(1000, NaN)).toBe('unknown'); + }); +}); + +// --------------------------------------------------------------------------- +// parseTimestampMs — seconds vs ms vs unparseable (observed via createdMs) +// --------------------------------------------------------------------------- + +describe('parseTimestampMs coverage (via PBS backupTime -> createdMs)', () => { + it('parses a valid ISO 8601 timestamp into epoch milliseconds', () => { + const ts = '2026-07-09T12:30:45Z'; + const model = emptyModel([pbsBackup({ backupTime: ts })]); + expect(model.recoverableArtifacts[0].createdMs).toBe(Date.parse(ts)); + }); + + it('returns undefined createdMs for an empty timestamp string', () => { + const model = emptyModel([pbsBackup({ backupTime: '' })]); + expect(model.recoverableArtifacts[0].createdMs).toBeUndefined(); + }); + + it('returns undefined createdMs for an unparseable garbage string', () => { + const model = emptyModel([pbsBackup({ backupTime: 'not-a-date' })]); + expect(model.recoverableArtifacts[0].createdMs).toBeUndefined(); + }); + + it('does not interpret a bare epoch-seconds string as a valid timestamp', () => { + // Date.parse('1752019200') returns NaN — the function must not confuse + // raw epoch-seconds with an ISO date. + const model = emptyModel([pbsBackup({ backupTime: '1752019200' })]); + expect(model.recoverableArtifacts[0].createdMs).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// fallbackWorkloadId — host zero-vmid hint paths (via PBS host backups) +// --------------------------------------------------------------------------- + +describe('fallbackWorkloadId coverage (via host PBS backups with no inventory)', () => { + it('falls back to the first non-root PBS hint when host vmid is zero', () => { + // hints = [namespace, datastore] = ['root', 'main']; 'root' filtered, 'main' survives + const model = emptyModel([ + pbsBackup({ + id: 'host-zero-root-ns', + backupType: 'host', + vmid: '0', + namespace: 'root', + datastore: 'main', + }), + ]); + expect(model.recoverableArtifacts[0].workload.vmid).toBe('main'); + }); + + it('returns empty vmid when every host hint is root-like', () => { + // firstHostHint filters both 'root' and '(root)' + const model = emptyModel([ + pbsBackup({ + id: 'host-root-only', + backupType: 'host', + vmid: '0', + namespace: 'root', + datastore: '(root)', + }), + ]); + expect(model.recoverableArtifacts[0].workload.vmid).toBe(''); + }); + + it('returns empty vmid when host hints are all blank', () => { + // firstHostHint trims to '' which is falsy + const model = emptyModel([ + pbsBackup({ + id: 'host-blank-hints', + backupType: 'host', + vmid: '0', + namespace: '', + datastore: '', + }), + ]); + expect(model.recoverableArtifacts[0].workload.vmid).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// workloadFallbackLabel — label branches for host-no-id, unknown-no-id, +// vm-no-id (existing tests already cover "LXC backup" and " ") +// --------------------------------------------------------------------------- + +describe('workloadFallbackLabel coverage (via fallback workloads)', () => { + it('renders "Host backup" for a host with no resolvable id', () => { + const model = emptyModel([ + pbsBackup({ + id: 'host-no-id', + backupType: 'host', + vmid: '0', + namespace: '', + datastore: '', + }), + ]); + expect(model.recoverableArtifacts[0].workload.label).toBe('Host backup'); + }); + + it('renders "Guest" for an unknown backup type with no vmid', () => { + const model = emptyModel([ + pbsBackup({ + id: 'unknown-no-id', + backupType: 'backup', + vmid: '0', + }), + ]); + expect(model.recoverableArtifacts[0].workload.label).toBe('Guest'); + }); + + it('renders "VM backup" for a VM with no vmid', () => { + const model = emptyModel([ + pbsBackup({ + id: 'vm-no-id', + backupType: 'qemu', + vmid: '0', + }), + ]); + expect(model.recoverableArtifacts[0].workload.label).toBe('VM backup'); + }); +}); + +// --------------------------------------------------------------------------- +// resourceVmid — meta vs platformData precedence (via candidate vmid) +// --------------------------------------------------------------------------- + +describe('resourceVmid coverage (via buildCandidateFromResource)', () => { + it('prefers proxmox meta vmid over platformData', () => { + const model = emptyModel([], [ + workload({ + id: 'vm-300', + proxmox: { vmid: 300, node: 'node-a' }, + platformData: { proxmox: { vmid: 999 } }, + }), + ]); + expect(model.coverageRows[0].workload.vmid).toBe('300'); + }); + + it('reads a numeric vmid from platformData.proxmox when meta vmid is absent', () => { + const model = emptyModel([], [ + workload({ + id: 'vm-500', + proxmox: { node: 'node-a' }, + platformData: { proxmox: { vmid: 500 } }, + }), + ]); + expect(model.coverageRows[0].workload.vmid).toBe('500'); + }); + + it('reads a string vmid from platformData.proxmox when meta vmid is absent', () => { + const model = emptyModel([], [ + workload({ + id: 'vm-600', + proxmox: { node: 'node-a' }, + platformData: { proxmox: { vmid: '600' } }, + }), + ]); + expect(model.coverageRows[0].workload.vmid).toBe('600'); + }); + + it('produces no candidate when neither meta nor platformData provides a vmid', () => { + const model = emptyModel([], [ + workload({ + id: 'vm-noid', + proxmox: { node: 'node-a' }, + }), + ]); + expect(model.coverageRows).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// backupTypeLabel — qemu/vm/lxc/unknown branches (existing tests cover +// 'ct' and 'host') +// --------------------------------------------------------------------------- + +describe('backupTypeLabel coverage (via PBS backupType)', () => { + it.each([ + ['qemu', 'vm'], + ['vm', 'vm'], + ['lxc', 'ct'], + ['backup', 'unknown'], + ])('maps backupType "%s" to workload type "%s"', (backupType, expected) => { + const model = emptyModel([ + pbsBackup({ + id: `pbs-${backupType}`, + backupType, + vmid: '0', + }), + ]); + expect(model.recoverableArtifacts[0].workload.type).toBe(expected); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/aiFindingPresentation.coverage.test.ts b/frontend-modern/src/utils/__tests__/aiFindingPresentation.coverage.test.ts new file mode 100644 index 0000000000..195851d3ba --- /dev/null +++ b/frontend-modern/src/utils/__tests__/aiFindingPresentation.coverage.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest'; + +import type { UnifiedFinding } from '@/stores/aiIntelligence'; +import { + doesFindingNeedAttention, + getInvestigationConfidenceBadgeClasses, + getInvestigationConfidenceBadgeTone, + getInvestigationOutcomeSortOrder, + hasFindingInvestigationHandoffPointer, +} from '@/utils/aiFindingPresentation'; + +// Mirrors the module-private fallback so the test asserts the real fallback +// behaviour rather than echoing a mock. These literals are the contract. +const DEFAULT_BADGE_CLASSES = 'border-border bg-surface-alt text-muted'; + +describe('getInvestigationConfidenceBadgeClasses', () => { + it('returns the high-confidence (emerald) tier classes', () => { + expect(getInvestigationConfidenceBadgeClasses('high')).toBe( + 'border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300', + ); + }); + + it('returns the medium-confidence (neutral surface) tier classes', () => { + expect(getInvestigationConfidenceBadgeClasses('medium')).toBe( + 'border-border bg-surface-alt text-base-content', + ); + }); + + it('returns the low-confidence (amber) tier classes', () => { + expect(getInvestigationConfidenceBadgeClasses('low')).toBe( + 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-300', + ); + }); + + it('falls back to the default classes for an unrecognised confidence', () => { + expect(getInvestigationConfidenceBadgeClasses('critical')).toBe(DEFAULT_BADGE_CLASSES); + }); + + it('falls back to the default classes for the empty string', () => { + expect(getInvestigationConfidenceBadgeClasses('')).toBe(DEFAULT_BADGE_CLASSES); + }); + + it('distinguishes the medium tier from the unknown fallback by content', () => { + // Medium keeps base-content text; the unknown fallback uses text-muted. + expect(getInvestigationConfidenceBadgeClasses('medium')).toContain('text-base-content'); + expect(getInvestigationConfidenceBadgeClasses('unknown')).toContain('text-muted'); + }); +}); + +describe('getInvestigationConfidenceBadgeTone (confidence tone map miss)', () => { + it('maps each known confidence to its tone', () => { + expect(getInvestigationConfidenceBadgeTone('high')).toBe('success'); + expect(getInvestigationConfidenceBadgeTone('medium')).toBe('neutral'); + expect(getInvestigationConfidenceBadgeTone('low')).toBe('warning'); + }); + + it('falls back to the muted tone for an unknown confidence', () => { + expect(getInvestigationConfidenceBadgeTone('very-sure')).toBe('muted'); + }); + + it('falls back to the muted tone for the empty string', () => { + expect(getInvestigationConfidenceBadgeTone('')).toBe('muted'); + }); +}); + +describe('hasFindingInvestigationHandoffPointer', () => { + const base = { + investigationOutcome: undefined as UnifiedFinding['investigationOutcome'], + investigationSessionId: undefined as string | undefined, + lastInvestigatedAt: undefined as string | undefined, + }; + + it('is true when an investigation outcome is recorded', () => { + expect(hasFindingInvestigationHandoffPointer({ ...base, investigationOutcome: 'fix_failed' })) + .toBe(true); + }); + + it('is true when a session id is present even without an outcome', () => { + expect( + hasFindingInvestigationHandoffPointer({ ...base, investigationSessionId: 'sess-123' }), + ).toBe(true); + }); + + it('is true when only a last-investigated timestamp is present', () => { + expect( + hasFindingInvestigationHandoffPointer({ ...base, lastInvestigatedAt: '2026-07-01T00:00:00Z' }), + ).toBe(true); + }); + + it('is false when no handoff field is set', () => { + expect(hasFindingInvestigationHandoffPointer(base)).toBe(false); + }); + + it('is false when every handoff field is an empty string rather than absent', () => { + expect( + hasFindingInvestigationHandoffPointer({ + investigationOutcome: '' as unknown as UnifiedFinding['investigationOutcome'], + investigationSessionId: '', + lastInvestigatedAt: '', + }), + ).toBe(false); + }); +}); + +describe('getInvestigationOutcomeSortOrder', () => { + it.each([ + ['fix_verification_failed', 0], + ['fix_failed', 0], + ['fix_verification_unknown', 1], + ['fix_rejected', 1], + ['timed_out', 1], + ['needs_attention', 1], + ['cannot_fix', 1], + ['fix_queued', 2], + ] as const)('ranks outcome %s at %i', (outcome, expected) => { + expect(getInvestigationOutcomeSortOrder(outcome)).toBe(expected); + }); + + it('defaults positive-track outcomes that are intentionally unmapped to 3', () => { + expect(getInvestigationOutcomeSortOrder('resolved')).toBe(3); + expect(getInvestigationOutcomeSortOrder('fix_executed')).toBe(3); + expect(getInvestigationOutcomeSortOrder('fix_verified')).toBe(3); + }); + + it('defaults an unrecognised outcome to 3', () => { + expect(getInvestigationOutcomeSortOrder('not-a-real-outcome')).toBe(3); + }); + + it('returns 3 for undefined (no outcome)', () => { + expect(getInvestigationOutcomeSortOrder(undefined)).toBe(3); + }); + + it('returns 3 for the empty string (treated as no outcome)', () => { + expect(getInvestigationOutcomeSortOrder('')).toBe(3); + }); + + it('ranks a failed verification before a queued fix', () => { + expect(getInvestigationOutcomeSortOrder('fix_verification_failed')).toBeLessThan( + getInvestigationOutcomeSortOrder('fix_queued'), + ); + }); +}); + +describe('doesFindingNeedAttention (ATTENTION_OUTCOMES set membership)', () => { + const attentionOutcomes = [ + 'fix_verification_failed', + 'fix_verification_unknown', + 'fix_failed', + 'fix_rejected', + 'timed_out', + 'needs_attention', + 'cannot_fix', + ] as const; + + it.each(attentionOutcomes)('flags an active finding with outcome %s as needing attention', ( + outcome, + ) => { + expect( + doesFindingNeedAttention({ + id: 'f1', + status: 'active', + investigationOutcome: outcome, + }), + ).toBe(true); + }); + + it('does not flag a non-attention outcome that is not awaiting approval', () => { + // fix_executed is positive progress, not in the attention set. + expect( + doesFindingNeedAttention({ + id: 'f2', + status: 'active', + investigationOutcome: 'fix_executed', + }), + ).toBe(false); + }); + + it('does not flag resolved/verified outcomes', () => { + expect( + doesFindingNeedAttention({ + id: 'f3', + status: 'active', + investigationOutcome: 'fix_verified', + }), + ).toBe(false); + expect( + doesFindingNeedAttention({ + id: 'f4', + status: 'active', + investigationOutcome: 'resolved', + }), + ).toBe(false); + }); + + it('returns false when the finding is not active, regardless of outcome', () => { + expect( + doesFindingNeedAttention({ + id: 'f5', + status: 'resolved', + investigationOutcome: 'fix_failed', + }), + ).toBe(false); + }); + + it('returns false when an active finding has no investigation outcome', () => { + expect( + doesFindingNeedAttention({ + id: 'f6', + status: 'active', + investigationOutcome: undefined, + }), + ).toBe(false); + }); + + it('flags a queued fix that has no live approval', () => { + expect( + doesFindingNeedAttention( + { id: 'f7', status: 'active', investigationOutcome: 'fix_queued' }, + [], + ), + ).toBe(true); + }); + + it('does not flag a queued fix that still has a live pending approval', () => { + const now = Date.now(); + const approval = { + status: 'pending' as const, + toolId: 'investigation_fix', + targetId: 'f7', + expiresAt: new Date(now + 60_000).toISOString(), + }; + expect( + doesFindingNeedAttention( + { id: 'f7', status: 'active', investigationOutcome: 'fix_queued' }, + [approval], + ), + ).toBe(false); + }); +});