diff --git a/frontend-modern/src/api/__tests__/charts.branchcov0718.test.ts b/frontend-modern/src/api/__tests__/charts.branchcov0718.test.ts new file mode 100644 index 000000000..e60369946 --- /dev/null +++ b/frontend-modern/src/api/__tests__/charts.branchcov0718.test.ts @@ -0,0 +1,277 @@ +/** + * Branch-coverage tests for the currently-uncovered ChartsAPI methods: + * - ChartsAPI.getInfrastructureCharts (deprecated delegate -> getInfrastructureSummaryCharts) + * - ChartsAPI.getStorageSummaryTrend + * + * These tests assert request shaping (final path + query string + signal) and + * response handling. They mock the transport with the same harness used by + * chartsApi.test.ts (vi.mock('@/utils/apiClient', ...)) and intentionally do + * NOT re-assert anything chartsApi.test.ts already covers + * (getCharts, getInfrastructureSummaryCharts metric filters, getWorkload*, + * getMetricsHistory). + * + * Branches exercised here: + * - range default ('1h' for infra, '24h' for storage-trend) vs explicit value + * - signal present vs undefined + * - options.nodeId truthy (string) -> `node=` param appended + * - options.nodeId falsy variants -> `node=` param omitted: + * * null + * * '' (empty string) + * * options undefined entirely + * - URL-encoding of special chars inside nodeId (URLSearchParams.toString) + * - combined range + node + signal in a single request + * - getStorageSummaryTrend forwards the raw TimeRange token without calling + * timeRangeToMinutes() (unlike getStorageSummaryCharts) + * - each function returns the parsed payload from apiFetchJSON verbatim + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/utils/apiClient', () => ({ + apiFetchJSON: vi.fn(), +})); + +import { + ChartsAPI, + type InfrastructureChartsResponse, + type StorageSummaryTrendResponse, + type TimeRange, +} from '@/api/charts'; +import { apiFetchJSON } from '@/utils/apiClient'; + +const ALL_TIME_RANGES: TimeRange[] = [ + '5m', + '15m', + '30m', + '1h', + '4h', + '12h', + '24h', + '7d', + '30d', +]; + +describe('ChartsAPI.getInfrastructureCharts — branch coverage', () => { + const apiFetchJSONMock = vi.mocked(apiFetchJSON); + + beforeEach(() => { + apiFetchJSONMock.mockReset(); + }); + + it('routes to /charts/infrastructure with default range=1h and signal undefined when called with no args', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getInfrastructureCharts(); + + expect(apiFetchJSONMock).toHaveBeenCalledTimes(1); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/infrastructure?range=1h', { + signal: undefined, + }); + }); + + it('passes an explicit range token through to the URL without transformation', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getInfrastructureCharts('24h'); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/infrastructure?range=24h', { + signal: undefined, + }); + }); + + it.each(ALL_TIME_RANGES)('forwards TimeRange="%s" verbatim into the range query param', async (range) => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getInfrastructureCharts(range); + + expect(apiFetchJSONMock).toHaveBeenCalledWith( + `/api/charts/infrastructure?range=${range}`, + { signal: undefined }, + ); + }); + + it('forwards an AbortSignal through to apiFetchJSON', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + const controller = new AbortController(); + + await ChartsAPI.getInfrastructureCharts('1h', controller.signal); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/infrastructure?range=1h', { + signal: controller.signal, + }); + }); + + it('appends node= when options.nodeId is a non-empty string', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getInfrastructureCharts('1h', undefined, { nodeId: 'cluster-a-node-1' }); + + expect(apiFetchJSONMock).toHaveBeenCalledWith( + '/api/charts/infrastructure?range=1h&node=cluster-a-node-1', + { signal: undefined }, + ); + }); + + it('omits the node query param when options.nodeId is explicitly null (falsy branch)', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getInfrastructureCharts('1h', undefined, { nodeId: null }); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/infrastructure?range=1h', { + signal: undefined, + }); + }); + + it('omits the node query param when options.nodeId is an empty string (falsy branch)', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getInfrastructureCharts('1h', undefined, { nodeId: '' }); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/infrastructure?range=1h', { + signal: undefined, + }); + }); + + it('omits the node query param when options is undefined entirely', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getInfrastructureCharts('1h', undefined, undefined); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/infrastructure?range=1h', { + signal: undefined, + }); + }); + + it('URL-encodes special characters in the node id (URLSearchParams.toString)', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getInfrastructureCharts('1h', undefined, { nodeId: 'node a/b' }); + + // space -> '+', '/' -> '%2F' + expect(apiFetchJSONMock).toHaveBeenCalledWith( + '/api/charts/infrastructure?range=1h&node=node+a%2Fb', + { signal: undefined }, + ); + }); + + it('combines range + node + signal in a single request with range-first/node-second ordering', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + const controller = new AbortController(); + + await ChartsAPI.getInfrastructureCharts('4h', controller.signal, { nodeId: 'pve1' }); + + expect(apiFetchJSONMock).toHaveBeenCalledWith( + '/api/charts/infrastructure?range=4h&node=pve1', + { signal: controller.signal }, + ); + }); + + it('returns the parsed InfrastructureChartsResponse payload verbatim from apiFetchJSON', async () => { + const payload: InfrastructureChartsResponse = { + nodeData: { + pve1: { cpu: [{ timestamp: 1000, value: 12.5 }] }, + }, + dockerHostData: { 'dh-1': { memory: [{ timestamp: 2000, value: 70 }] } }, + agentData: { 'agent-7': { disk: [{ timestamp: 3000, value: 5 }] } }, + timestamp: 1733700000000, + stats: { + oldestDataTimestamp: 1733696400000, + range: '1h', + rangeSeconds: 3600, + metricsStoreEnabled: true, + }, + }; + apiFetchJSONMock.mockResolvedValueOnce(payload as never); + + const result = await ChartsAPI.getInfrastructureCharts('1h'); + + expect(result).toBe(payload); + }); +}); + +describe('ChartsAPI.getStorageSummaryTrend — branch coverage', () => { + const apiFetchJSONMock = vi.mocked(apiFetchJSON); + + beforeEach(() => { + apiFetchJSONMock.mockReset(); + }); + + it('routes to /charts/storage-summary with default range=24h and signal undefined when called with no args', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getStorageSummaryTrend(); + + expect(apiFetchJSONMock).toHaveBeenCalledTimes(1); + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/storage-summary?range=24h', { + signal: undefined, + }); + }); + + it('passes the range token through WITHOUT minutes conversion (key difference from getStorageSummaryCharts)', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getStorageSummaryTrend('5m'); + + // NOTE: getStorageSummaryCharts('5m') would build range=5 (minutes via + // timeRangeToMinutes). getStorageSummaryTrend keeps the raw token '5m'. + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/storage-summary?range=5m', { + signal: undefined, + }); + }); + + it.each(ALL_TIME_RANGES)( + 'forwards TimeRange="%s" verbatim into range param (no minutes conversion)', + async (range) => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getStorageSummaryTrend(range); + + expect(apiFetchJSONMock).toHaveBeenCalledWith( + `/api/charts/storage-summary?range=${range}`, + { signal: undefined }, + ); + }, + ); + + it('forwards an AbortSignal through to apiFetchJSON', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + const controller = new AbortController(); + + await ChartsAPI.getStorageSummaryTrend('24h', controller.signal); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/storage-summary?range=24h', { + signal: controller.signal, + }); + }); + + it('passes signal=undefined to apiFetchJSON when no AbortSignal is supplied', async () => { + apiFetchJSONMock.mockResolvedValueOnce({} as never); + + await ChartsAPI.getStorageSummaryTrend('12h'); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/charts/storage-summary?range=12h', { + signal: undefined, + }); + }); + + it('returns the parsed StorageSummaryTrendResponse payload verbatim from apiFetchJSON', async () => { + const payload: StorageSummaryTrendResponse = { + capacity: [ + { timestamp: 1700000000000, value: 80 }, + { timestamp: 1700000060000, value: 81 }, + ], + timestamp: 1700000060000, + stats: { + oldestDataTimestamp: 1699900000000, + range: '24h', + rangeSeconds: 86400, + metricsStoreEnabled: true, + primarySourceHint: 'store', + }, + }; + apiFetchJSONMock.mockResolvedValueOnce(payload as never); + + const result = await ChartsAPI.getStorageSummaryTrend(); + + expect(result).toBe(payload); + }); +}); diff --git a/frontend-modern/src/api/__tests__/discovery.branchcov0718.test.ts b/frontend-modern/src/api/__tests__/discovery.branchcov0718.test.ts new file mode 100644 index 000000000..ad65c909f --- /dev/null +++ b/frontend-modern/src/api/__tests__/discovery.branchcov0718.test.ts @@ -0,0 +1,689 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/utils/apiClient', () => ({ + apiFetch: vi.fn(), +})); + +import { + deleteDiscovery, + getConnectedAgents, + getDiscoveryProgress, + getDiscoveryStatus, + listDiscoveries, + listDiscoveriesByAgent, + updateDiscoveryNotes, + getDiscovery, +} from '@/api/discovery'; +import { apiFetch } from '@/utils/apiClient'; +import type { + DiscoveryListResponse, + DiscoveryProgress, + DiscoveryStatus, + DiscoverySummary, + ResourceDiscovery, + UpdateNotesRequest, +} from '@/types/discovery'; + +const okJson = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +const progressFixture = (): DiscoveryProgress => ({ + resource_id: '100', + status: 'running', + current_step: 'collecting facts', + current_command: 'uname -a', + total_steps: 5, + completed_steps: 2, + elapsed_ms: 1234, + percent_complete: 40, + started_at: '2026-07-18T00:00:00Z', + updated_at: '2026-07-18T00:00:01Z', +}); + +const statusFixture = (): DiscoveryStatus => ({ + running: true, + last_run: '2026-07-18T00:00:00Z', + interval: '0 */6 * * *', + cache_size: 12, + ai_analyzer_set: true, + scanner_set: true, + store_set: true, + max_discovery_age: '720h', + fingerprint_count: 7, + last_fingerprint_scan: '2026-07-18T00:00:00Z', + changed_count: 1, + stale_count: 0, +}); + +const discoveryDetailFixture = (): ResourceDiscovery => ({ + id: 'vm:node-1:100', + resource_type: 'vm', + resource_id: '100', + target_id: 'node-1', + hostname: 'vm-100', + service_type: 'linux', + service_name: 'VM', + service_version: '1.0', + category: 'unknown', + cli_access: '', + facts: [], + config_paths: [], + data_paths: [], + log_paths: [], + ports: [], + user_notes: 'updated notes', + user_secrets: { token: 'abc' }, + confidence: 0.7, + ai_reasoning: '', + discovered_at: '2026-07-18T00:00:00Z', + updated_at: '2026-07-18T00:00:01Z', + scan_duration: 1, +}); + +describe('discovery api branch coverage', () => { + const apiFetchMock = vi.mocked(apiFetch); + + beforeEach(() => { + apiFetchMock.mockReset(); + }); + + describe('buildTypedDiscoverySubresourcePath / getDiscoveryProgress', () => { + it('GETs the typed progress subresource and parses the payload', async () => { + apiFetchMock.mockResolvedValueOnce(okJson(progressFixture())); + + const result = await getDiscoveryProgress('vm', 'node-1', '100'); + + expect(apiFetchMock).toHaveBeenCalledTimes(1); + expect(apiFetchMock).toHaveBeenCalledWith( + '/api/discovery/vm/node-1/100/progress', + ); + expect(result).toMatchObject({ + resource_id: '100', + status: 'running', + percent_complete: 40, + completed_steps: 2, + total_steps: 5, + }); + }); + + it('maps pod -> k8s when building the progress subresource path', async () => { + apiFetchMock.mockResolvedValueOnce(okJson(progressFixture())); + + await getDiscoveryProgress('pod', 'cluster-a', 'default/api'); + + expect(apiFetchMock).toHaveBeenCalledWith( + '/api/discovery/k8s/cluster-a/default%2Fapi/progress', + ); + }); + + it('encodes slashes in the target and resource id segments', async () => { + apiFetchMock.mockResolvedValueOnce(okJson(progressFixture())); + + await getDiscoveryProgress( + 'vm/root' as never, + 'node/1' as never, + 'id/with/slash' as never, + ); + + expect(apiFetchMock).toHaveBeenCalledWith( + '/api/discovery/vm%2Froot/node%2F1/id%2Fwith%2Fslash/progress', + ); + }); + + it('throws a request error on non-ok progress response', async () => { + apiFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'scanner offline' }), { + status: 503, + }), + ); + + await expect(getDiscoveryProgress('vm', 'node-1', '100')).rejects.toThrow( + 'scanner offline', + ); + }); + + it('throws a parse error when progress body is empty', async () => { + apiFetchMock.mockResolvedValueOnce(new Response('', { status: 200 })); + + await expect(getDiscoveryProgress('vm', 'node-1', '100')).rejects.toThrow( + 'Failed to parse discovery progress', + ); + }); + }); + + describe('listDiscoveries', () => { + it('GETs the discovery root and returns the parsed list payload', async () => { + const payload: DiscoveryListResponse = { + discoveries: [ + { + id: 'vm:node-1:100', + resource_type: 'vm', + resource_id: '100', + target_id: 'node-1', + hostname: 'vm-100', + service_type: 'linux', + service_name: 'VM', + service_version: '', + category: 'unknown', + confidence: 0.7, + has_user_notes: false, + updated_at: '2026-07-18T00:00:00Z', + }, + ], + total: 1, + }; + apiFetchMock.mockResolvedValueOnce(okJson(payload)); + + const result = await listDiscoveries(); + + expect(apiFetchMock).toHaveBeenCalledTimes(1); + expect(apiFetchMock).toHaveBeenCalledWith('/api/discovery'); + expect(result.total).toBe(1); + expect(result.discoveries).toHaveLength(1); + expect(result.discoveries[0].id).toBe('vm:node-1:100'); + }); + + it('returns the empty list payload unchanged', async () => { + apiFetchMock.mockResolvedValueOnce( + okJson({ discoveries: [], total: 0 } satisfies DiscoveryListResponse), + ); + + const result = await listDiscoveries(); + + expect(result.total).toBe(0); + expect(result.discoveries).toEqual([]); + }); + + it('surfaces backend error messages on non-ok list responses', async () => { + apiFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ message: 'discovery store offline' }), { + status: 500, + }), + ); + + await expect(listDiscoveries()).rejects.toThrow('discovery store offline'); + }); + + it('throws a parse error on empty body', async () => { + apiFetchMock.mockResolvedValueOnce(new Response('', { status: 200 })); + + await expect(listDiscoveries()).rejects.toThrow('Failed to parse discoveries'); + }); + }); + + describe('listDiscoveriesByAgent', () => { + it('GETs the agent collection route with an encoded agent id', async () => { + apiFetchMock.mockResolvedValueOnce( + okJson({ discoveries: [], total: 0 } satisfies DiscoveryListResponse), + ); + + await listDiscoveriesByAgent('host-1'); + + expect(apiFetchMock).toHaveBeenCalledTimes(1); + expect(apiFetchMock).toHaveBeenCalledWith('/api/discovery/agent/host-1'); + }); + + it('encodes special characters in the agent id segment', async () => { + apiFetchMock.mockResolvedValueOnce( + okJson({ discoveries: [], total: 0 } satisfies DiscoveryListResponse), + ); + + await listDiscoveriesByAgent('agent/with slash'); + + expect(apiFetchMock).toHaveBeenCalledWith( + '/api/discovery/agent/agent%2Fwith%20slash', + ); + }); + + it('returns the parsed agent discovery list', async () => { + apiFetchMock.mockResolvedValueOnce( + okJson({ + discoveries: [ + { + id: 'agent:host-1:host-1', + resource_type: 'agent', + resource_id: 'host-1', + target_id: 'host-1', + hostname: 'host-1.local', + service_type: 'linux', + service_name: 'Agent', + service_version: '', + category: 'unknown', + confidence: 0.9, + has_user_notes: false, + updated_at: '2026-07-18T00:00:00Z', + }, + ], + total: 1, + } satisfies DiscoveryListResponse), + ); + + const result = await listDiscoveriesByAgent('host-1'); + + expect(result.total).toBe(1); + expect(result.discoveries[0].hostname).toBe('host-1.local'); + }); + + it('throws backend error message on non-ok agent list response', async () => { + apiFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'agent not connected' }), { + status: 404, + }), + ); + + await expect(listDiscoveriesByAgent('ghost')).rejects.toThrow( + 'agent not connected', + ); + }); + }); + + describe('updateDiscoveryNotes', () => { + it('PUTs notes + secrets body to the typed notes subresource', async () => { + apiFetchMock.mockResolvedValueOnce(okJson(discoveryDetailFixture())); + + const notes: UpdateNotesRequest = { + user_notes: 'updated notes', + user_secrets: { token: 'abc' }, + }; + + const result = await updateDiscoveryNotes('vm', 'node-1', '100', notes); + + expect(apiFetchMock).toHaveBeenCalledTimes(1); + expect(apiFetchMock).toHaveBeenCalledWith('/api/discovery/vm/node-1/100/notes', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(notes), + }); + expect(result.id).toBe('vm:node-1:100'); + expect(result.user_notes).toBe('updated notes'); + }); + + it('PUTs notes-only body when user_secrets is omitted (optional param absent)', async () => { + apiFetchMock.mockResolvedValueOnce(okJson(discoveryDetailFixture())); + + const notes: UpdateNotesRequest = { user_notes: 'just text' }; + + await updateDiscoveryNotes('agent', 'host-1', 'host-1', notes); + + expect(apiFetchMock).toHaveBeenCalledWith( + '/api/discovery/agent/host-1/host-1/notes', + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_notes: 'just text' }), + }, + ); + }); + + it('maps pod -> k8s for the notes subresource', async () => { + apiFetchMock.mockResolvedValueOnce(okJson(discoveryDetailFixture())); + + await updateDiscoveryNotes('pod', 'cluster-a', 'default/api', { + user_notes: 'k8s notes', + }); + + expect(apiFetchMock).toHaveBeenCalledWith( + '/api/discovery/k8s/cluster-a/default%2Fapi/notes', + expect.objectContaining({ method: 'PUT' }), + ); + }); + + it('throws on non-ok notes update response', async () => { + apiFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'note too long' }), { status: 413 }), + ); + + await expect( + updateDiscoveryNotes('vm', 'node-1', '100', { user_notes: 'x'.repeat(10_000) }), + ).rejects.toThrow('note too long'); + }); + }); + + describe('deleteDiscovery', () => { + it('sends DELETE without a body and resolves void on ok', async () => { + apiFetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); + + await expect(deleteDiscovery('vm', 'node-1', '100')).resolves.toBeUndefined(); + + expect(apiFetchMock).toHaveBeenCalledTimes(1); + expect(apiFetchMock).toHaveBeenCalledWith('/api/discovery/vm/node-1/100', { + method: 'DELETE', + }); + }); + + it('encodes a pod/k8s delete route', async () => { + apiFetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); + + await deleteDiscovery('pod', 'cluster-a', 'default/api'); + + expect(apiFetchMock).toHaveBeenCalledWith( + '/api/discovery/k8s/cluster-a/default%2Fapi', + { method: 'DELETE' }, + ); + }); + + it('throws the backend error message on non-ok delete response', async () => { + apiFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'discovery in use' }), { status: 409 }), + ); + + await expect(deleteDiscovery('vm', 'node-1', '100')).rejects.toThrow( + 'discovery in use', + ); + }); + + it('falls back to the default message when the error body is empty', async () => { + apiFetchMock.mockResolvedValueOnce(new Response('', { status: 500 })); + + await expect(deleteDiscovery('vm', 'node-1', '100')).rejects.toThrow( + 'Failed to delete discovery', + ); + }); + }); + + describe('getDiscoveryStatus', () => { + it('GETs /api/discovery/status and parses the status payload', async () => { + apiFetchMock.mockResolvedValueOnce(okJson(statusFixture())); + + const result = await getDiscoveryStatus(); + + expect(apiFetchMock).toHaveBeenCalledTimes(1); + expect(apiFetchMock).toHaveBeenCalledWith('/api/discovery/status'); + expect(result.running).toBe(true); + expect(result.fingerprint_count).toBe(7); + expect(result.changed_count).toBe(1); + }); + + it('parses a status payload that omits the optional fingerprint fields', async () => { + apiFetchMock.mockResolvedValueOnce( + okJson({ + running: false, + last_run: '', + interval: '', + cache_size: 0, + ai_analyzer_set: false, + scanner_set: false, + store_set: false, + } satisfies DiscoveryStatus), + ); + + const result = await getDiscoveryStatus(); + + expect(result.running).toBe(false); + expect(result.fingerprint_count).toBeUndefined(); + expect(result.changed_count).toBeUndefined(); + }); + + it('throws backend error on non-ok status response', async () => { + apiFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ message: 'status unavailable' }), { + status: 503, + }), + ); + + await expect(getDiscoveryStatus()).rejects.toThrow('status unavailable'); + }); + }); + + describe('getConnectedAgents', () => { + it('GETs /api/ai/agents and parses the connected-agent list', async () => { + apiFetchMock.mockResolvedValueOnce( + okJson({ + count: 2, + agents: [ + { + agent_id: 'host-1', + hostname: 'host-1.local', + version: '1.2.3', + platform: 'linux', + connected_at: '2026-07-18T00:00:00Z', + }, + { + agent_id: 'host-2', + hostname: 'host-2.local', + version: '1.2.3', + platform: 'darwin', + connected_at: '2026-07-18T00:00:01Z', + }, + ], + }), + ); + + const result = await getConnectedAgents(); + + expect(apiFetchMock).toHaveBeenCalledTimes(1); + expect(apiFetchMock).toHaveBeenCalledWith('/api/ai/agents'); + expect(result.count).toBe(2); + expect(result.agents).toHaveLength(2); + expect(result.agents[0].agent_id).toBe('host-1'); + }); + + it('returns an empty agent list unchanged', async () => { + apiFetchMock.mockResolvedValueOnce(okJson({ count: 0, agents: [] })); + + const result = await getConnectedAgents(); + + expect(result.count).toBe(0); + expect(result.agents).toEqual([]); + }); + + it('throws backend error on non-ok connected-agents response', async () => { + apiFetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'websocket server down' }), { + status: 502, + }), + ); + + await expect(getConnectedAgents()).rejects.toThrow('websocket server down'); + }); + }); + + describe('getDiscovery agent-resolution branches', () => { + it('matches a summary where resource_id equals the requested target_id (not resourceId)', async () => { + const summary: DiscoverySummary = { + id: 'agent:host-1:agent-X', + resource_type: 'agent', + resource_id: 'agent-X', + target_id: 'host-1', + hostname: 'host-1.local', + service_type: 'linux', + service_name: 'Agent', + service_version: '', + category: 'unknown', + confidence: 0.9, + has_user_notes: false, + updated_at: '2026-07-18T00:00:00Z', + }; + apiFetchMock.mockResolvedValueOnce( + okJson({ discoveries: [summary], total: 1 } satisfies DiscoveryListResponse), + ); + apiFetchMock.mockResolvedValueOnce( + okJson({ + ...discoveryDetailFixture(), + id: 'agent:host-1:agent-X', + resource_type: 'agent', + target_id: 'host-1', + }), + ); + + const result = await getDiscovery('agent', 'host-1', 'does-not-match'); + + expect(result?.id).toBe('agent:host-1:agent-X'); + // resolveDiscoveryAgentId picks target_id ('host-1') because agent_id is absent + expect(apiFetchMock).toHaveBeenNthCalledWith( + 2, + '/api/discovery/agent/host-1/agent-X', + ); + }); + + it('matches a summary through resolveDiscoveryAgentId when target_id equals the request', async () => { + const summary: DiscoverySummary = { + id: 'agent:legacy:agent-7', + resource_type: 'agent', + resource_id: 'agent-7', + agent_id: 'agent-7-canonical', + target_id: 'agent-7-canonical', + hostname: 'agent-7.local', + service_type: 'linux', + service_name: 'Agent', + service_version: '', + category: 'unknown', + confidence: 0.9, + has_user_notes: false, + updated_at: '2026-07-18T00:00:00Z', + }; + apiFetchMock.mockResolvedValueOnce( + okJson({ discoveries: [summary], total: 1 } satisfies DiscoveryListResponse), + ); + apiFetchMock.mockResolvedValueOnce( + okJson({ + ...discoveryDetailFixture(), + id: 'agent:agent-7-canonical:agent-7', + resource_type: 'agent', + target_id: 'agent-7-canonical', + }), + ); + + const result = await getDiscovery('agent', 'agent-7-canonical', 'nope'); + + expect(result?.id).toBe('agent:agent-7-canonical:agent-7'); + // First find() matches because resolveDiscoveryAgentId(d) === targetId + expect(apiFetchMock).toHaveBeenNthCalledWith( + 2, + '/api/discovery/agent/agent-7-canonical/agent-7', + ); + }); + + it('falls back to the first agent discovery when nothing matches the predicate', async () => { + const summary: DiscoverySummary = { + id: 'agent:other:any-id', + resource_type: 'agent', + resource_id: 'unrelated-id', + target_id: 'unrelated-target', + agent_id: 'resolved-canonical', + hostname: 'agent-other.local', + service_type: 'linux', + service_name: 'Agent', + service_version: '', + category: 'unknown', + confidence: 0.9, + has_user_notes: false, + updated_at: '2026-07-18T00:00:00Z', + }; + apiFetchMock.mockResolvedValueOnce( + okJson({ discoveries: [summary], total: 1 } satisfies DiscoveryListResponse), + ); + apiFetchMock.mockResolvedValueOnce( + okJson({ + ...discoveryDetailFixture(), + id: 'agent:resolved-canonical:unrelated-id', + resource_type: 'agent', + target_id: 'resolved-canonical', + }), + ); + + const result = await getDiscovery('agent', 'host-1', 'host-1'); + + expect(result?.id).toBe('agent:resolved-canonical:unrelated-id'); + // Fallback find picks the first agent discovery; canonical agent_id drives the detail path + expect(apiFetchMock).toHaveBeenNthCalledWith( + 2, + '/api/discovery/agent/resolved-canonical/unrelated-id', + ); + }); + + it('returns null when the agent list has no agent-typed summaries', async () => { + const nonAgentSummary: DiscoverySummary = { + id: 'vm:node-1:100', + resource_type: 'vm', + resource_id: '100', + target_id: 'node-1', + hostname: 'vm-100', + service_type: 'linux', + service_name: 'VM', + service_version: '', + category: 'unknown', + confidence: 0.7, + has_user_notes: false, + updated_at: '2026-07-18T00:00:00Z', + }; + apiFetchMock.mockResolvedValueOnce( + okJson({ + discoveries: [nonAgentSummary], + total: 1, + } satisfies DiscoveryListResponse), + ); + + const result = await getDiscovery('agent', 'host-1', 'host-1'); + + expect(result).toBeNull(); + expect(apiFetchMock).toHaveBeenCalledTimes(1); + expect(apiFetchMock).toHaveBeenNthCalledWith(1, '/api/discovery/agent/host-1'); + }); + + it('returns null when the resolved agent discovery has no usable agent id', async () => { + // All three candidate id fields are empty strings, so resolveDiscoveryAgentId returns '' + const summary: DiscoverySummary = { + id: 'agent::', + resource_type: 'agent', + resource_id: '', + target_id: '', + agent_id: '', + hostname: 'empty.local', + service_type: 'linux', + service_name: 'Agent', + service_version: '', + category: 'unknown', + confidence: 0.9, + has_user_notes: false, + updated_at: '2026-07-18T00:00:00Z', + }; + apiFetchMock.mockResolvedValueOnce( + okJson({ discoveries: [summary], total: 1 } satisfies DiscoveryListResponse), + ); + + const result = await getDiscovery('agent', 'host-1', 'host-1'); + + expect(result).toBeNull(); + expect(apiFetchMock).toHaveBeenCalledTimes(1); + }); + + it('uses resource_id as the agent id when agent_id and target_id are absent', async () => { + const summary: DiscoverySummary = { + id: 'agent:rid-only:rid-9', + resource_type: 'agent', + resource_id: 'rid-9', + hostname: 'rid-9.local', + service_type: 'linux', + service_name: 'Agent', + service_version: '', + category: 'unknown', + confidence: 0.9, + has_user_notes: false, + updated_at: '2026-07-18T00:00:00Z', + }; + apiFetchMock.mockResolvedValueOnce( + okJson({ discoveries: [summary], total: 1 } satisfies DiscoveryListResponse), + ); + apiFetchMock.mockResolvedValueOnce( + okJson({ + ...discoveryDetailFixture(), + id: 'agent:rid-9:rid-9', + resource_type: 'agent', + target_id: 'rid-9', + }), + ); + + const result = await getDiscovery('agent', 'rid-9', 'rid-9'); + + expect(result?.id).toBe('agent:rid-9:rid-9'); + expect(apiFetchMock).toHaveBeenNthCalledWith( + 2, + '/api/discovery/agent/rid-9/rid-9', + ); + }); + }); +}); diff --git a/frontend-modern/src/api/__tests__/truenas.branchcov0718.test.ts b/frontend-modern/src/api/__tests__/truenas.branchcov0718.test.ts new file mode 100644 index 000000000..ebd3e3b7b --- /dev/null +++ b/frontend-modern/src/api/__tests__/truenas.branchcov0718.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { apiFetchJSON } from '@/utils/apiClient'; +import { TrueNASAPI, type TrueNASConnectionInput } from '@/api/truenas'; + +vi.mock('@/utils/apiClient', () => ({ + apiFetchJSON: vi.fn(), +})); + +const fullInput: TrueNASConnectionInput = { + name: 'tower', + host: 'truenas.local', + port: 443, + apiKey: 'secret', + username: 'admin', + password: '********', + useHttps: true, + insecureSkipVerify: false, + fingerprint: 'AA:BB:CC', + enabled: true, + pollIntervalSeconds: 60, + monitorDatasets: true, + monitorPools: false, + monitorReplication: true, +}; + +const emptyPreviewResponse = { + current_count: 0, + projected_count: 0, + additional_count: 0, + effect: 'no_change', + current_systems: [], + projected_systems: [], +}; + +describe('TrueNASAPI preview* branch coverage', () => { + const mock = vi.mocked(apiFetchJSON); + + beforeEach(() => { + mock.mockReset(); + }); + + describe('previewConnection', () => { + it('POSTs to /connections/preview and serializes every populated optional field', async () => { + mock.mockResolvedValueOnce({ + current_count: 1, + projected_count: 1, + additional_count: 0, + effect: 'no_change', + current_systems: [{ name: 'tower', type: 'truenas', status: 'online', source: 'truenas' }], + projected_systems: [ + { name: 'tower', type: 'truenas', status: 'online', source: 'truenas' }, + ], + }); + + const result = await TrueNASAPI.previewConnection(fullInput); + + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith('/api/truenas/connections/preview', { + method: 'POST', + body: JSON.stringify({ + name: 'tower', + host: 'truenas.local', + port: 443, + apiKey: 'secret', + username: 'admin', + password: '********', + useHttps: true, + insecureSkipVerify: false, + fingerprint: 'AA:BB:CC', + enabled: true, + pollIntervalSeconds: 60, + monitorDatasets: true, + monitorPools: false, + monitorReplication: true, + }), + }); + // Single projected system is hoisted into projected_system by the normalizer + expect(result.current_system).toMatchObject({ name: 'tower' }); + expect(result.projected_system).toMatchObject({ name: 'tower' }); + expect(result.effect).toBe('no_change'); + expect(result.projected_count).toBe(1); + }); + + it('emits a host-only body when every optional field is absent', async () => { + mock.mockResolvedValueOnce(emptyPreviewResponse); + + await TrueNASAPI.previewConnection({ host: 'truenas.local' }); + + expect(mock).toHaveBeenCalledWith('/api/truenas/connections/preview', { + method: 'POST', + body: JSON.stringify({ host: 'truenas.local' }), + }); + }); + + it('selectively includes only the optional fields that are defined', async () => { + mock.mockResolvedValueOnce(emptyPreviewResponse); + + await TrueNASAPI.previewConnection({ + host: 'truenas.local', + port: 8080, + apiKey: 'k', + useHttps: false, + monitorDatasets: true, + }); + + expect(mock).toHaveBeenCalledWith('/api/truenas/connections/preview', { + method: 'POST', + body: JSON.stringify({ + host: 'truenas.local', + port: 8080, + apiKey: 'k', + useHttps: false, + monitorDatasets: true, + }), + }); + }); + + it('derives current_system from a single current_systems entry when current_system is null', async () => { + mock.mockResolvedValueOnce({ + current_count: 1, + projected_count: 0, + additional_count: -1, + effect: 'removes_existing', + current_systems: [ + { name: 'orphan', type: 'truenas', status: 'offline', source: 'truenas' }, + ], + projected_systems: [], + current_system: null, + projected_system: null, + }); + + const result = await TrueNASAPI.previewConnection({ host: 'h' }); + + expect(result.current_system).toMatchObject({ name: 'orphan', status: 'offline' }); + expect(result.projected_system).toBeNull(); + expect(result.projected_systems).toEqual([]); + }); + + it('returns null current/projected_system when both system lists are empty', async () => { + mock.mockResolvedValueOnce(emptyPreviewResponse); + + const result = await TrueNASAPI.previewConnection({ host: 'h' }); + + expect(result.current_system).toBeNull(); + expect(result.projected_system).toBeNull(); + expect(result.current_systems).toEqual([]); + expect(result.projected_systems).toEqual([]); + }); + + it('propagates transport errors from a non-ok preview response', async () => { + mock.mockRejectedValueOnce(new Error('preview backend unavailable')); + + await expect(TrueNASAPI.previewConnection({ host: 'h' })).rejects.toThrow( + 'preview backend unavailable', + ); + }); + }); + + describe('previewSavedConnection', () => { + it('encodes the connection id and sends NO body when input is undefined', async () => { + mock.mockResolvedValueOnce(emptyPreviewResponse); + + await TrueNASAPI.previewSavedConnection('conn/with slash'); + + expect(mock).toHaveBeenCalledWith( + '/api/truenas/connections/conn%2Fwith%20slash/preview', + { method: 'POST' }, + ); + }); + + it('includes the serialized body when an input override is provided', async () => { + mock.mockResolvedValueOnce(emptyPreviewResponse); + + await TrueNASAPI.previewSavedConnection('conn-1', { + host: 'truenas.local', + apiKey: 'rotated', + useHttps: true, + }); + + expect(mock).toHaveBeenCalledWith('/api/truenas/connections/conn-1/preview', { + method: 'POST', + body: JSON.stringify({ host: 'truenas.local', apiKey: 'rotated', useHttps: true }), + }); + }); + + it('serializes the full optional-field set when input is fully populated', async () => { + mock.mockResolvedValueOnce(emptyPreviewResponse); + + await TrueNASAPI.previewSavedConnection('conn-1', fullInput); + + expect(mock).toHaveBeenCalledWith('/api/truenas/connections/conn-1/preview', { + method: 'POST', + body: JSON.stringify({ + name: 'tower', + host: 'truenas.local', + port: 443, + apiKey: 'secret', + username: 'admin', + password: '********', + useHttps: true, + insecureSkipVerify: false, + fingerprint: 'AA:BB:CC', + enabled: true, + pollIntervalSeconds: 60, + monitorDatasets: true, + monitorPools: false, + monitorReplication: true, + }), + }); + }); + + it('prefers an explicit projected_system over a multi-element projected_systems list', async () => { + mock.mockResolvedValueOnce({ + current_count: 0, + projected_count: 2, + additional_count: 2, + effect: 'creates_multiple', + current_systems: [], + projected_systems: [ + { name: 'p1', type: 'truenas', status: 'online', source: 'truenas' }, + { name: 'p2', type: 'truenas', status: 'warning', source: 'truenas' }, + ], + projected_system: { + name: 'primary', + type: 'truenas', + status: 'online', + source: 'truenas', + }, + }); + + const result = await TrueNASAPI.previewSavedConnection('conn-1'); + + expect(result.projected_system).toMatchObject({ name: 'primary' }); + expect(result.projected_systems).toHaveLength(2); + }); + + it('falls back to projected_systems[0] only when the list has exactly one element', async () => { + mock.mockResolvedValueOnce({ + current_count: 0, + projected_count: 1, + additional_count: 1, + effect: 'creates_new', + current_systems: [], + projected_systems: [ + { name: 'solo', type: 'truenas', status: 'online', source: 'truenas' }, + ], + projected_system: undefined, + }); + + const result = await TrueNASAPI.previewSavedConnection('conn-1'); + + expect(result.projected_system).toMatchObject({ name: 'solo' }); + }); + + it('leaves projected_system null when projected_systems is empty and no explicit value is set', async () => { + mock.mockResolvedValueOnce(emptyPreviewResponse); + + const result = await TrueNASAPI.previewSavedConnection('conn-1'); + + expect(result.projected_system).toBeNull(); + expect(result.current_system).toBeNull(); + }); + + it('propagates transport errors from a non-ok saved-preview response', async () => { + mock.mockRejectedValueOnce(new Error('connection not found')); + + await expect(TrueNASAPI.previewSavedConnection('ghost')).rejects.toThrow( + 'connection not found', + ); + }); + }); +}); diff --git a/frontend-modern/src/features/storageBackups/__tests__/recordPresentation.branchcov0718.test.ts b/frontend-modern/src/features/storageBackups/__tests__/recordPresentation.branchcov0718.test.ts new file mode 100644 index 000000000..9a1e6bb9c --- /dev/null +++ b/frontend-modern/src/features/storageBackups/__tests__/recordPresentation.branchcov0718.test.ts @@ -0,0 +1,702 @@ +import { describe, expect, it } from 'vitest'; +import type { ZFSPool } from '@/types/api'; +import type { NormalizedHealth, StorageRecord } from '@/features/storageBackups/models'; +import { + getStorageRecordActionSummary, + getStorageRecordContent, + getStorageRecordHostLabel, + getStorageRecordImpactSummary, + getStorageRecordIssueLabel, + getStorageRecordIssueSummary, + getStorageRecordNodeHints, + getStorageRecordNodeLabel, + getStorageRecordPlatformLabel, + getStorageRecordProtectionLabel, + getStorageRecordShared, + getStorageRecordStats, + getStorageRecordStatus, + getStorageRecordTopologyLabel, + getStorageRecordType, + getStorageRecordUsagePercent, + getStorageRecordZfsPool, +} from '@/features/storageBackups/recordPresentation'; + +// --------------------------------------------------------------------------- +// Fixture builder — mirrors recordPresentation.test.ts verbatim so casts, +// import paths and field defaults match the sibling suite. Each case below +// overrides only what its target branch needs. +// --------------------------------------------------------------------------- + +const makeRecord = (overrides: Partial = {}): StorageRecord => ({ + id: 'storage-1', + name: 'tank', + category: 'pool', + health: 'healthy', + location: { label: 'truenas01/pool/tank', scope: 'host' }, + capacity: { totalBytes: 1_000, usedBytes: 400, freeBytes: 600, usagePercent: 40 }, + capabilities: ['capacity', 'health'], + source: { + platform: 'truenas', + family: 'onprem', + origin: 'resource', + adapterId: 'resource-storage', + }, + observedAt: Date.now(), + ...overrides, +}); + +// =========================================================================== +// getStorageRecordNodeHints — defensive-coercion and filter branches +// =========================================================================== + +describe('getStorageRecordNodeHints branch coverage', () => { + it('coerces non-string detail.node / detail.parentId / detail.parentName to "" via the typeof guards', () => { + // All three typeof guards take their false arm simultaneously; the only + // surviving hint is the location.label root + full label. + const record = makeRecord({ + details: { + node: 42, + parentId: ['x'], + parentName: { deep: true }, + }, + }); + expect(getStorageRecordNodeHints(record)).toEqual([ + 'truenas01', + 'truenas01/pool/tank', + ]); + }); + + it('returns an empty array when every candidate hint trims to empty', () => { + // detail.* missing, location.label is whitespace, refs undefined. + // Every entry falls into the `.map((v) => v ? ... : '')` false arm and is + // then dropped by the `.filter((v) => v.length > 0)` predicate. + const record = makeRecord({ + location: { label: ' ', scope: 'host' }, + refs: undefined, + details: {}, + }); + expect(getStorageRecordNodeHints(record)).toEqual([]); + }); + + it('exercises the `(record.details || {})` falsy arm and the location.label no-slash arm', () => { + // details is undefined → `(record.details || {})` returns `{}`. location + // label has no '/', so `label.split('/')[0]` is the whole label; both the + // `locationRoot` and `record.location.label` candidates therefore resolve + // to the same string and the function emits it twice (deduplication is + // NOT performed — only filtering of empty strings). + const record = makeRecord({ + details: undefined, + location: { label: 'solo-host', scope: 'host' }, + refs: undefined, + }); + expect(getStorageRecordNodeHints(record)).toEqual(['solo-host', 'solo-host']); + }); + + it('drops whitespace-only nodeHint entries and trims surrounding whitespace from valid ones', () => { + // getRecordStringArrayDetail keeps only non-blank strings after trim. + // The `value` argument also exercises the `Array.isArray` true arm with a + // mixed-content array (some entries filtered, some kept). The + // location.label has no '/', so its root and full-label candidates both + // resolve to 'host-only' and appear twice in the output. + const record = makeRecord({ + details: { + nodeHints: [' kept-hint ', ' ', 'second-hint', 7] as unknown as string[], + }, + refs: undefined, + location: { label: 'host-only', scope: 'host' }, + }); + expect(getStorageRecordNodeHints(record)).toEqual([ + 'kept-hint', + 'second-hint', + 'host-only', + 'host-only', + ]); + }); + + it('returns [] from getRecordStringArrayDetail when the nodeHints detail is not an array', () => { + // Array.isArray false arm: the `nodeHints` value is an object, so the + // helper short-circuits to []. Only the location-derived candidates + // remain — and because the label has no slash, both resolve to the same + // value, so 'host-only' appears twice. + const record = makeRecord({ + details: { nodeHints: { not: 'array' } }, + refs: undefined, + location: { label: 'host-only', scope: 'host' }, + }); + expect(getStorageRecordNodeHints(record)).toEqual(['host-only', 'host-only']); + }); +}); + +// =========================================================================== +// getStorageRecordType — three-way fallback chain +// =========================================================================== + +describe('getStorageRecordType branch coverage', () => { + it('returns detail.type verbatim when it is a non-empty string', () => { + const record = makeRecord({ details: { type: 'cephfs' } }); + expect(getStorageRecordType(record)).toBe('cephfs'); + }); + + it('falls back to record.category when detail.type is absent', () => { + // getRecordStringDetail returns '' (typeof undefined !== 'string') → + // `'' || record.category` truthy arm returns category. + const record = makeRecord({ details: {}, category: 'datastore' }); + expect(getStorageRecordType(record)).toBe('datastore'); + }); + + it('returns "other" when detail.type is absent and category coerces to empty', () => { + // Both `||` operands falsy → final 'other' fallback. category is typed as + // a non-empty StorageCategory so the empty string requires a cast. + const record = makeRecord({ + details: {}, + category: '' as StorageRecord['category'], + }); + expect(getStorageRecordType(record)).toBe('other'); + }); + + it('treats a non-string detail.type as absent and falls through to category', () => { + // typeof value !== 'string' → '' → category wins. + const record = makeRecord({ + details: { type: 99 }, + category: 'volume', + }); + expect(getStorageRecordType(record)).toBe('volume'); + }); +}); + +// =========================================================================== +// getStorageRecordContent — typeof guard both arms +// =========================================================================== + +describe('getStorageRecordContent branch coverage', () => { + it('returns the detail.content string when present', () => { + const record = makeRecord({ details: { content: 'root dataset' } }); + expect(getStorageRecordContent(record)).toBe('root dataset'); + }); + + it('returns "" when detail.content is absent', () => { + const record = makeRecord({ details: {} }); + expect(getStorageRecordContent(record)).toBe(''); + }); + + it('returns "" when detail.content is a non-string value', () => { + // typeof false arm of the inner guard. + const record = makeRecord({ + details: { content: { nested: 'object' } }, + }); + expect(getStorageRecordContent(record)).toBe(''); + }); +}); + +// =========================================================================== +// getStorageRecordStatus — every health enum arm + detail.status override +// =========================================================================== + +describe('getStorageRecordStatus branch coverage', () => { + it('prefers detail.status over health-derived status', () => { + const record = makeRecord({ + health: 'healthy', + details: { status: 'scrubbing' }, + }); + expect(getStorageRecordStatus(record)).toBe('scrubbing'); + }); + + it('maps health "warning" to "degraded"', () => { + const record = makeRecord({ health: 'warning', details: {} }); + expect(getStorageRecordStatus(record)).toBe('degraded'); + }); + + it('maps health "offline" to "offline"', () => { + const record = makeRecord({ health: 'offline', details: {} }); + expect(getStorageRecordStatus(record)).toBe('offline'); + }); + + it('maps health "critical" to "critical"', () => { + const record = makeRecord({ health: 'critical', details: {} }); + expect(getStorageRecordStatus(record)).toBe('critical'); + }); + + it('maps health "unknown" to "unknown" (final fallback arm)', () => { + const record = makeRecord({ health: 'unknown', details: {} }); + expect(getStorageRecordStatus(record)).toBe('unknown'); + }); +}); + +// =========================================================================== +// getStorageRecordPlatformLabel — platformLabel override both arms +// =========================================================================== + +describe('getStorageRecordPlatformLabel branch coverage', () => { + it('returns record.platformLabel verbatim when it trims to non-empty', () => { + const record = makeRecord({ + platformLabel: 'Custom NAS Appliance', + source: { platform: 'truenas', family: 'onprem', origin: 'resource', adapterId: 'a' }, + }); + expect(getStorageRecordPlatformLabel(record)).toBe('Custom NAS Appliance'); + }); + + it('falls back to getSourcePlatformLabel when platformLabel is whitespace-only', () => { + // ' '.trim() === '' → falsy → fallback arm fires. + const record = makeRecord({ + platformLabel: ' ', + source: { platform: 'truenas', family: 'onprem', origin: 'resource', adapterId: 'a' }, + }); + expect(getStorageRecordPlatformLabel(record)).toBe('TrueNAS'); + }); + + it('returns a title-cased label for an unrecognized platform via the getSourcePlatformLabel fallback', () => { + // Unknown platform key → SOURCE_PLATFORM_PRESENTATION has no entry → + // titleCaseDelimitedLabel('acme-store') → 'Acme Store'. + const record = makeRecord({ + platformLabel: undefined, + source: { + platform: 'acme-store', + family: 'generic', + origin: 'resource', + adapterId: 'a', + }, + }); + expect(getStorageRecordPlatformLabel(record)).toBe('Acme Store'); + }); +}); + +// =========================================================================== +// getStorageRecordNodeLabel — parentName → node → location.label → 'unassigned' +// =========================================================================== + +describe('getStorageRecordNodeLabel branch coverage', () => { + it('falls back to detail.node when detail.parentName is absent', () => { + const record = makeRecord({ details: { node: 'node-7' } }); + expect(getStorageRecordNodeLabel(record)).toBe('node-7'); + }); + + it('falls back to location.label when both parentName and node are absent', () => { + const record = makeRecord({ + details: {}, + location: { label: 'bare-host', scope: 'host' }, + }); + expect(getStorageRecordNodeLabel(record)).toBe('bare-host'); + }); + + it('returns "unassigned" when parentName, node, and location.label are all empty', () => { + const record = makeRecord({ + details: {}, + location: { label: '', scope: 'host' }, + }); + expect(getStorageRecordNodeLabel(record)).toBe('unassigned'); + }); + + it('skips a whitespace-only detail.parentName and falls through to detail.node', () => { + // parentName.trim() === '' → falsy → next arm fires. + const record = makeRecord({ details: { parentName: ' ', node: 'node-9' } }); + expect(getStorageRecordNodeLabel(record)).toBe('node-9'); + }); +}); + +// =========================================================================== +// getStorageRecordHostLabel — hostLabel truthy arm +// =========================================================================== + +describe('getStorageRecordHostLabel branch coverage', () => { + it('returns record.hostLabel verbatim when it trims to non-empty', () => { + const record = makeRecord({ + hostLabel: 'primary-storage-host', + details: {}, + }); + expect(getStorageRecordHostLabel(record)).toBe('primary-storage-host'); + }); + + it('falls back to getStorageRecordNodeLabel when hostLabel is whitespace-only', () => { + // hostLabel ' '.trim() === '' → falsy → node label derived from location. + const record = makeRecord({ + hostLabel: ' ', + details: {}, + location: { label: 'lonely-host', scope: 'host' }, + }); + expect(getStorageRecordHostLabel(record)).toBe('lonely-host'); + }); +}); + +// =========================================================================== +// getStorageRecordTopologyLabel — topologyLabel override both arms +// =========================================================================== + +describe('getStorageRecordTopologyLabel branch coverage', () => { + it('returns record.topologyLabel verbatim when it trims to non-empty', () => { + const record = makeRecord({ + topologyLabel: 'Stretched Cluster', + details: {}, + }); + expect(getStorageRecordTopologyLabel(record)).toBe('Stretched Cluster'); + }); + + it('falls back to getStorageRecordType when topologyLabel is absent', () => { + // topologyLabel undefined → falsy → getStorageRecordType returns + // detail.type ('rbd') via the truthy arm of its first `||`. + const record = makeRecord({ + topologyLabel: undefined, + details: { type: 'rbd' }, + }); + expect(getStorageRecordTopologyLabel(record)).toBe('rbd'); + }); +}); + +// =========================================================================== +// getStorageRecordProtectionLabel — both arms +// =========================================================================== + +describe('getStorageRecordProtectionLabel branch coverage', () => { + it('returns record.protectionLabel verbatim when it trims to non-empty', () => { + const record = makeRecord({ protectionLabel: 'Replicated · Snapshots' }); + expect(getStorageRecordProtectionLabel(record)).toBe('Replicated · Snapshots'); + }); + + it('returns "Healthy" when protectionLabel is absent', () => { + const record = makeRecord({ protectionLabel: undefined }); + expect(getStorageRecordProtectionLabel(record)).toBe('Healthy'); + }); + + it('returns "Healthy" when protectionLabel is whitespace-only', () => { + const record = makeRecord({ protectionLabel: ' ' }); + expect(getStorageRecordProtectionLabel(record)).toBe('Healthy'); + }); +}); + +// =========================================================================== +// getStorageRecordIssueLabel — truthy arm +// =========================================================================== + +describe('getStorageRecordIssueLabel branch coverage', () => { + it('returns record.issueLabel verbatim when it trims to non-empty', () => { + const record = makeRecord({ issueLabel: 'Scrub errors detected' }); + expect(getStorageRecordIssueLabel(record)).toBe('Scrub errors detected'); + }); +}); + +// =========================================================================== +// getStorageRecordIssueSummary — three-way fallback +// =========================================================================== + +describe('getStorageRecordIssueSummary branch coverage', () => { + it('returns record.issueSummary verbatim when it trims to non-empty', () => { + const record = makeRecord({ + issueSummary: '3 scrub errors on disk da1', + issueLabel: 'placeholder', + }); + expect(getStorageRecordIssueSummary(record)).toBe('3 scrub errors on disk da1'); + }); + + it('falls back to record.issueLabel when issueSummary is absent', () => { + // issueSummary undefined → `record.issueSummary?.trim()` is undefined → + // the `||` chain advances to issueLabel. + const record = makeRecord({ + issueSummary: undefined, + issueLabel: 'Degraded mirror', + }); + expect(getStorageRecordIssueSummary(record)).toBe('Degraded mirror'); + }); + + it('returns "" when both issueSummary and issueLabel are absent', () => { + const record = makeRecord({ issueSummary: undefined, issueLabel: undefined }); + expect(getStorageRecordIssueSummary(record)).toBe(''); + }); + + it('returns "" when issueSummary and issueLabel are both whitespace-only', () => { + // Both `.trim()` calls yield '' → final '' fallback. + const record = makeRecord({ issueSummary: ' ', issueLabel: '\t' }); + expect(getStorageRecordIssueSummary(record)).toBe(''); + }); +}); + +// =========================================================================== +// getStorageRecordImpactSummary — both arms +// =========================================================================== + +describe('getStorageRecordImpactSummary branch coverage', () => { + it('returns record.impactSummary verbatim when it trims to non-empty', () => { + const record = makeRecord({ impactSummary: '12 VMs affected' }); + expect(getStorageRecordImpactSummary(record)).toBe('12 VMs affected'); + }); + + it('returns "No dependent resources" when impactSummary is absent', () => { + const record = makeRecord({ impactSummary: undefined }); + expect(getStorageRecordImpactSummary(record)).toBe('No dependent resources'); + }); +}); + +// =========================================================================== +// getStorageRecordActionSummary — truthy arm +// =========================================================================== + +describe('getStorageRecordActionSummary branch coverage', () => { + it('returns record.actionSummary verbatim when it trims to non-empty', () => { + const record = makeRecord({ actionSummary: 'Replace disk da2 within 24h' }); + expect(getStorageRecordActionSummary(record)).toBe('Replace disk da2 within 24h'); + }); +}); + +// =========================================================================== +// getStorageRecordShared — boolean true/false/non-boolean arms +// =========================================================================== + +describe('getStorageRecordShared branch coverage', () => { + it('returns false when detail.shared is explicitly false', () => { + const record = makeRecord({ details: { shared: false } }); + expect(getStorageRecordShared(record)).toBe(false); + }); + + it('returns null when detail.shared is a non-boolean primitive', () => { + // typeof shared !== 'boolean' → null. Use a string to defeat TS. + const record = makeRecord({ + details: { shared: 'true' } as Record, + }); + expect(getStorageRecordShared(record)).toBe(null); + }); + + it('returns null when detail.shared is missing entirely', () => { + const record = makeRecord({ details: {} }); + expect(getStorageRecordShared(record)).toBe(null); + }); + + it('returns null when details itself is undefined (defensive `(record.details || {})` arm)', () => { + const record = makeRecord({ details: undefined }); + expect(getStorageRecordShared(record)).toBe(null); + }); +}); + +// =========================================================================== +// getStorageRecordUsagePercent — fallback / NaN / division-guard branches +// =========================================================================== + +describe('getStorageRecordUsagePercent branch coverage', () => { + it('falls back to the total/used computation when usagePercent is null', () => { + // usagePercent null → typeof check false → total 1000 / used 250 → 25. + const record = makeRecord({ + capacity: { totalBytes: 1_000, usedBytes: 250, freeBytes: 750, usagePercent: null }, + }); + expect(getStorageRecordUsagePercent(record)).toBe(25); + }); + + it('treats NaN usagePercent as missing (Number.isFinite false arm)', () => { + const record = makeRecord({ + capacity: { totalBytes: 1_000, usedBytes: 500, freeBytes: 500, usagePercent: NaN }, + }); + expect(getStorageRecordUsagePercent(record)).toBe(50); + }); + + it('falls back when usagePercent is a non-number value (typeof guard false arm)', () => { + // Cast required: CapacitySnapshot.usagePercent is `number | null`. + const record = makeRecord({ + capacity: { + totalBytes: 200, + usedBytes: 50, + freeBytes: 150, + usagePercent: '50' as unknown as number, + }, + }); + expect(getStorageRecordUsagePercent(record)).toBe(25); + }); + + it('returns 0 when total <= 0 (the `total <= 0` true arm of the guard)', () => { + const record = makeRecord({ + capacity: { totalBytes: 0, usedBytes: 500, freeBytes: 0, usagePercent: null }, + }); + expect(getStorageRecordUsagePercent(record)).toBe(0); + }); + + it('returns 0 when total is negative (boundary below zero)', () => { + const record = makeRecord({ + capacity: { totalBytes: -100, usedBytes: 50, freeBytes: 0, usagePercent: null }, + }); + expect(getStorageRecordUsagePercent(record)).toBe(0); + }); + + it('coerces null total/used bytes to 0 via the `|| 0` arms before the guard', () => { + // total null → 0 → `0 <= 0` true → 0. Exercises both `|| 0` falsy arms. + const record = makeRecord({ + capacity: { totalBytes: null, usedBytes: null, freeBytes: null, usagePercent: null }, + }); + expect(getStorageRecordUsagePercent(record)).toBe(0); + }); +}); + +// =========================================================================== +// getStorageRecordZfsPool — toZfsPool null/invalid arms reached via the +// public surface (the module-private toZfsPool is only exercised here). +// =========================================================================== + +describe('getStorageRecordZfsPool branch coverage', () => { + it('returns null when details.zfsPool is missing', () => { + const record = makeRecord({ details: {} }); + expect(getStorageRecordZfsPool(record)).toBeNull(); + }); + + it('returns null when details.zfsPool is null (`!value` truthy arm)', () => { + const record = makeRecord({ + details: { zfsPool: null } as Record, + }); + expect(getStorageRecordZfsPool(record)).toBeNull(); + }); + + it('returns null when details.zfsPool is a primitive (`typeof !== "object"` arm)', () => { + const record = makeRecord({ + details: { zfsPool: 'DEGRADED' } as Record, + }); + expect(getStorageRecordZfsPool(record)).toBeNull(); + }); + + it('returns null when zfsPool.state is a non-string', () => { + // typeof candidate.state !== 'string' false arm. + const record = makeRecord({ + details: { + zfsPool: { state: 7, devices: [] }, + } as Record, + }); + expect(getStorageRecordZfsPool(record)).toBeNull(); + }); + + it('returns null when zfsPool.devices is not an array', () => { + // state is a valid string but devices is an object → Array.isArray false. + const record = makeRecord({ + details: { + zfsPool: { state: 'ONLINE', devices: { count: 0 } }, + } as Record, + }); + expect(getStorageRecordZfsPool(record)).toBeNull(); + }); + + it('returns the validated zfsPool payload (with extra fields) when both guards pass', () => { + const pool = { + state: 'DEGRADED', + devices: [{ name: 'da0', type: 'disk', state: 'ONLINE' }], + name: 'tank', + status: 'Degraded', + scan: 'none', + readErrors: 0, + writeErrors: 0, + checksumErrors: 0, + } as ZFSPool; + const record = makeRecord({ details: { zfsPool: pool } }); + expect(getStorageRecordZfsPool(record)).toEqual(pool); + }); +}); + +// =========================================================================== +// getStorageRecordStats — empty input, every health arm, shared=false, +// total=0, shared dedup with differing platforms. +// =========================================================================== + +describe('getStorageRecordStats branch coverage', () => { + it('returns all-zero totals for an empty items array', () => { + expect(getStorageRecordStats([])).toEqual({ + totalBytes: 0, + usedBytes: 0, + usagePercent: 0, + byHealth: { healthy: 0, warning: 0, critical: 0, offline: 0, unknown: 0 }, + }); + }); + + it('counts critical / offline / unknown records in byHealth', () => { + const critical = makeRecord({ + id: 'crit', + health: 'critical', + capacity: { totalBytes: 100, usedBytes: 100, freeBytes: 0, usagePercent: 100 }, + }); + const offline = makeRecord({ + id: 'off', + health: 'offline', + capacity: { totalBytes: 200, usedBytes: 0, freeBytes: 200, usagePercent: 0 }, + }); + const unknown = makeRecord({ + id: 'unk', + health: 'unknown', + capacity: { totalBytes: 300, usedBytes: 150, freeBytes: 150, usagePercent: 50 }, + }); + expect(getStorageRecordStats([critical, offline, unknown])).toEqual({ + totalBytes: 600, + usedBytes: 250, + usagePercent: (250 / 600) * 100, + byHealth: { healthy: 0, warning: 0, critical: 1, offline: 1, unknown: 1 }, + }); + }); + + it('counts shared=false records toward totals without registering them in the dedup set', () => { + // shared=false → `getStorageRecordShared(record) === true` is false → the + // `if (isShared)` block is skipped entirely; totals still aggregate. + const a = makeRecord({ + id: 'a', + name: 'tank', + capacity: { totalBytes: 500, usedBytes: 100, freeBytes: 400, usagePercent: 20 }, + details: { shared: false }, + }); + const b = makeRecord({ + id: 'b', + name: 'tank', + capacity: { totalBytes: 500, usedBytes: 200, freeBytes: 300, usagePercent: 40 }, + details: { shared: false }, + }); + expect(getStorageRecordStats([a, b])).toEqual({ + totalBytes: 1_000, + usedBytes: 300, + usagePercent: 30, + byHealth: { healthy: 2, warning: 0, critical: 0, offline: 0, unknown: 0 }, + }); + }); + + it('returns usagePercent 0 via the `totals.total > 0` false arm when totals sum to zero', () => { + // totalBytes 0 on every record → totals.total = 0 → guard returns 0 + // instead of dividing. + const dead = makeRecord({ + id: 'dead', + health: 'offline', + capacity: { totalBytes: 0, usedBytes: 0, freeBytes: 0, usagePercent: null }, + }); + expect(getStorageRecordStats([dead])).toEqual({ + totalBytes: 0, + usedBytes: 0, + usagePercent: 0, + byHealth: { healthy: 0, warning: 0, critical: 0, offline: 1, unknown: 0 }, + }); + }); + + it('deduplicates shared records only within the same platform+name key', () => { + // Two shared records with the SAME name but DIFFERENT platforms are NOT + // deduplicated — both contribute to totals. Exercises the + // `seenShared.has(sharedKey)` false arm for a differing-platform key. + const pbsShared = makeRecord({ + id: 'pbs', + name: 'tank', + source: { platform: 'proxmox-pbs', family: 'onprem', origin: 'resource', adapterId: 'x' }, + capacity: { totalBytes: 1_000, usedBytes: 500, freeBytes: 500, usagePercent: null }, + details: { shared: true }, + }); + const truenasShared = makeRecord({ + id: 'tn', + name: 'tank', + source: { platform: 'truenas', family: 'onprem', origin: 'resource', adapterId: 'y' }, + capacity: { totalBytes: 1_000, usedBytes: 200, freeBytes: 800, usagePercent: null }, + details: { shared: true }, + }); + expect(getStorageRecordStats([pbsShared, truenasShared])).toEqual({ + totalBytes: 2_000, + usedBytes: 700, + usagePercent: 35, + byHealth: { healthy: 2, warning: 0, critical: 0, offline: 0, unknown: 0 }, + }); + }); + + it('coerces null capacity bytes to 0 in the totals accumulator via the `|| 0` arms', () => { + // record.capacity.totalBytes null → `|| 0` falsy arm → 0. Same for used. + const nullish = makeRecord({ + id: 'nul', + health: 'warning' as NormalizedHealth, + capacity: { totalBytes: null, usedBytes: null, freeBytes: null, usagePercent: null }, + }); + expect(getStorageRecordStats([nullish])).toEqual({ + totalBytes: 0, + usedBytes: 0, + usagePercent: 0, + byHealth: { healthy: 0, warning: 1, critical: 0, offline: 0, unknown: 0 }, + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/aiSettingsPresentation.branchcov0718.test.ts b/frontend-modern/src/utils/__tests__/aiSettingsPresentation.branchcov0718.test.ts new file mode 100644 index 000000000..2d94502cb --- /dev/null +++ b/frontend-modern/src/utils/__tests__/aiSettingsPresentation.branchcov0718.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest'; + +import { + getAICredentialsClearErrorMessage, + getAIChatSessionsLoadErrorMessage, + getAIModelsLoadErrorMessage, + getAISessionSummarizeErrorMessage, + getAISettingsReadinessPresentation, + getAISettingsSaveErrorMessage, + getAISettingsToggleErrorMessage, +} from '@/utils/aiSettingsPresentation'; + +// Supplemental branch-coverage suite. The sibling aiSettingsPresentation.test.ts +// already pins the canonical copy and exercises the happy arms of every error +// helper (`undefined` and a populated detail string). This file targets the +// *residual* arms: +// * getAISettingsReadinessPresentation — the singular `providerCount === 1` +// arm of the `providerCount !== 1 ? 's' : ''` ternary, plus the +// `configured === false` short-circuit when non-zero counts are supplied, +// and the zero/one model-count boundary. +// * The optional-message error helpers — null, empty-string and +// whitespace-only inputs that route through `(message || '').trim()` to the +// fallback (`detail || fallback`) arm, plus the trimmed-non-empty arm and, +// for getAISettingsSaveErrorMessage, the custom-fallback + truthy-message +// pairing. + +describe('getAISettingsReadinessPresentation — branch coverage', () => { + it('singularises "provider" when configured with exactly one provider (false arm of !== 1)', () => { + expect( + getAISettingsReadinessPresentation({ + configured: true, + providerCount: 1, + modelCount: 5, + }), + ).toEqual({ + containerClassName: 'bg-green-50 dark:bg-green-900 text-green-800 dark:text-green-200', + dotClassName: 'bg-emerald-400', + summary: 'Ready • 1 provider • 5 models', + }); + }); + + it('keeps the singular form alongside a zero model-count boundary', () => { + expect( + getAISettingsReadinessPresentation({ + configured: true, + providerCount: 1, + modelCount: 0, + }), + ).toEqual({ + containerClassName: 'bg-green-50 dark:bg-green-900 text-green-800 dark:text-green-200', + dotClassName: 'bg-emerald-400', + summary: 'Ready • 1 provider • 0 models', + }); + }); + + it('pluralises when configured with zero providers (0 !== 1 truthy arm boundary)', () => { + // providerCount === 0 still satisfies `!== 1`, exercising the truthy arm at + // its lower boundary. Documents that this branch keys only off unity. + expect( + getAISettingsReadinessPresentation({ + configured: true, + providerCount: 0, + modelCount: 3, + }), + ).toEqual({ + containerClassName: 'bg-green-50 dark:bg-green-900 text-green-800 dark:text-green-200', + dotClassName: 'bg-emerald-400', + summary: 'Ready • 0 providers • 3 models', + }); + }); + + it('renders the unit model count without singularising the model noun', () => { + // The model count is interpolated verbatim (no pluralisation logic); locks + // the boundary so a future "models" → "model" tweak cannot pass silently. + expect( + getAISettingsReadinessPresentation({ + configured: true, + providerCount: 3, + modelCount: 1, + }), + ).toEqual({ + containerClassName: 'bg-green-50 dark:bg-green-900 text-green-800 dark:text-green-200', + dotClassName: 'bg-emerald-400', + summary: 'Ready • 3 providers • 1 models', + }); + }); + + it('short-circuits to the not-configured presentation even when provider/model counts are non-zero', () => { + // The `if (configured)` false arm must ignore the supplied counts entirely; + // a configured=false state with stray positive counts still yields the + // amber "Configure at least one provider..." copy. + expect( + getAISettingsReadinessPresentation({ + configured: false, + providerCount: 4, + modelCount: 9, + }), + ).toEqual({ + containerClassName: 'bg-amber-50 dark:bg-amber-900 text-amber-800 dark:text-amber-200', + dotClassName: 'bg-amber-400', + summary: 'Configure at least one provider above to enable Pulse Assistant and Patrol.', + }); + }); +}); + +describe('getAIModelsLoadErrorMessage — branch coverage', () => { + it('falls back when message is null (null arm of `message || ""`)', () => { + expect(getAIModelsLoadErrorMessage(null)).toBe('Unable to load models.'); + }); + + it('falls back when message is the empty string (trim() === "" arm)', () => { + expect(getAIModelsLoadErrorMessage('')).toBe('Unable to load models.'); + }); + + it('falls back when message is whitespace-only (trim() collapses to "")', () => { + expect(getAIModelsLoadErrorMessage(' \t ')).toBe('Unable to load models.'); + }); + + it('returns the trimmed detail when the input has surrounding whitespace (truthy trim() arm)', () => { + expect(getAIModelsLoadErrorMessage(' Network request failed ')).toBe( + 'Network request failed', + ); + }); +}); + +describe('getAIChatSessionsLoadErrorMessage — branch coverage', () => { + it('falls back when message is null', () => { + expect(getAIChatSessionsLoadErrorMessage(null)).toBe('Unable to load chat sessions.'); + }); + + it('falls back when message is the empty string', () => { + expect(getAIChatSessionsLoadErrorMessage('')).toBe('Unable to load chat sessions.'); + }); + + it('falls back when message is whitespace-only', () => { + expect(getAIChatSessionsLoadErrorMessage('\n\t ')).toBe('Unable to load chat sessions.'); + }); + + it('returns the trimmed detail for a padded non-empty message', () => { + expect(getAIChatSessionsLoadErrorMessage(' Session API offline ')).toBe( + 'Session API offline', + ); + }); +}); + +describe('getAISessionSummarizeErrorMessage — branch coverage', () => { + it('falls back when message is null', () => { + expect(getAISessionSummarizeErrorMessage(null)).toBe('Unable to summarize the session.'); + }); + + it('falls back when message is the empty string', () => { + expect(getAISessionSummarizeErrorMessage('')).toBe('Unable to summarize the session.'); + }); + + it('falls back when message is whitespace-only', () => { + expect(getAISessionSummarizeErrorMessage(' ')).toBe('Unable to summarize the session.'); + }); + + it('returns the trimmed detail for a padded non-empty message', () => { + expect(getAISessionSummarizeErrorMessage(' provider offline ')).toBe('provider offline'); + }); +}); + +describe('getAISettingsSaveErrorMessage — branch coverage', () => { + it('falls back to the default fallback when message is null', () => { + expect(getAISettingsSaveErrorMessage(null)).toBe( + 'Unable to save Provider & Models settings.', + ); + }); + + it('falls back to the default fallback when message is the empty string', () => { + expect(getAISettingsSaveErrorMessage('')).toBe( + 'Unable to save Provider & Models settings.', + ); + }); + + it('falls back to the default fallback when message is whitespace-only', () => { + expect(getAISettingsSaveErrorMessage(' \t ')).toBe( + 'Unable to save Provider & Models settings.', + ); + }); + + it('honours the custom fallback when message is null (right operand of `detail || fallback`)', () => { + expect(getAISettingsSaveErrorMessage(null, 'Unable to save Patrol settings.')).toBe( + 'Unable to save Patrol settings.', + ); + }); + + it('honours the custom fallback when message is whitespace-only', () => { + expect(getAISettingsSaveErrorMessage(' ', 'Unable to save Patrol settings.')).toBe( + 'Unable to save Patrol settings.', + ); + }); + + it('prefers a truthy trimmed message over the custom fallback (left operand wins)', () => { + // Confirms the `detail || fallback` ordering: a real error message always + // wins over any caller-supplied fallback string. + expect( + getAISettingsSaveErrorMessage(' bad request ', 'Unable to save Patrol settings.'), + ).toBe('bad request'); + }); +}); + +describe('getAICredentialsClearErrorMessage — branch coverage', () => { + it('falls back when message is null', () => { + expect(getAICredentialsClearErrorMessage(null)).toBe('Unable to clear credentials.'); + }); + + it('falls back when message is the empty string', () => { + expect(getAICredentialsClearErrorMessage('')).toBe('Unable to clear credentials.'); + }); + + it('falls back when message is whitespace-only', () => { + expect(getAICredentialsClearErrorMessage('\t\n ')).toBe('Unable to clear credentials.'); + }); + + it('returns the trimmed detail for a padded non-empty message', () => { + expect(getAICredentialsClearErrorMessage(' permission denied ')).toBe( + 'permission denied', + ); + }); +}); + +describe('getAISettingsToggleErrorMessage — branch coverage', () => { + it('falls back when message is null', () => { + expect(getAISettingsToggleErrorMessage(null)).toBe( + 'Unable to update Pulse Intelligence settings.', + ); + }); + + it('falls back when message is the empty string', () => { + expect(getAISettingsToggleErrorMessage('')).toBe( + 'Unable to update Pulse Intelligence settings.', + ); + }); + + it('falls back when message is whitespace-only', () => { + expect(getAISettingsToggleErrorMessage(' ')).toBe( + 'Unable to update Pulse Intelligence settings.', + ); + }); + + it('returns the trimmed detail for a padded non-empty message', () => { + expect(getAISettingsToggleErrorMessage(' rate limited ')).toBe('rate limited'); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.branchcov0718.test.ts b/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.branchcov0718.test.ts new file mode 100644 index 000000000..b412821bd --- /dev/null +++ b/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.branchcov0718.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest'; +import { + // Status vocabulary — the sibling test exercises getAlertDestinationsStatusLabel + // but never imports the underlying label constants. + ALERT_DESTINATIONS_DISABLED_LABEL, + ALERT_DESTINATIONS_ENABLED_LABEL, + // Panel descriptions (sibling test only asserts the two panel TITLES). + ALERT_DESTINATIONS_APPRISE_PANEL_DESCRIPTION, + ALERT_DESTINATIONS_EMAIL_PANEL_DESCRIPTION, + // Apprise action / mode / targets copy (sibling asserts TESTING/TEST labels + // via the helper but not the constants; mode + targets labels are unasserted). + ALERT_DESTINATIONS_APPRISE_TEST_LABEL, + ALERT_DESTINATIONS_APPRISE_TESTING_LABEL, + ALERT_DESTINATIONS_APPRISE_MODE_LABEL, + ALERT_DESTINATIONS_APPRISE_MODE_CLI_LABEL, + ALERT_DESTINATIONS_APPRISE_MODE_HTTP_LABEL, + ALERT_DESTINATIONS_APPRISE_TARGETS_LABEL, + ALERT_DESTINATIONS_APPRISE_TARGETS_HELP_CLI, + ALERT_DESTINATIONS_APPRISE_TARGETS_HELP_HTTP, + // Apprise CLI-path / server-URL / config-key / API-key / TLS / timeout + // vocabulary blocks — none imported by the sibling test. + ALERT_DESTINATIONS_APPRISE_CLI_PATH_LABEL, + ALERT_DESTINATIONS_APPRISE_CLI_PATH_PLACEHOLDER, + ALERT_DESTINATIONS_APPRISE_CLI_PATH_HELP, + ALERT_DESTINATIONS_APPRISE_SERVER_URL_LABEL, + ALERT_DESTINATIONS_APPRISE_SERVER_URL_PLACEHOLDER, + ALERT_DESTINATIONS_APPRISE_SERVER_URL_HELP, + ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_LABEL, + ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_PLACEHOLDER, + ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_HELP, + ALERT_DESTINATIONS_APPRISE_API_KEY_LABEL, + ALERT_DESTINATIONS_APPRISE_API_KEY_PLACEHOLDER, + ALERT_DESTINATIONS_APPRISE_API_KEY_HELP, + ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_LABEL, + ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_PLACEHOLDER, + ALERT_DESTINATIONS_APPRISE_TLS_LABEL, + ALERT_DESTINATIONS_APPRISE_TLS_CHECKBOX_LABEL, + ALERT_DESTINATIONS_APPRISE_TLS_HELP, + ALERT_DESTINATIONS_APPRISE_TIMEOUT_LABEL, + ALERT_DESTINATIONS_APPRISE_TIMEOUT_HELP, + // Error-copy constants reused as comparison anchors for the new branches. + ALERT_DESTINATIONS_APPRISE_ENABLE_FOR_TEST_ERROR, + ALERT_DESTINATIONS_APPRISE_MISSING_TARGETS_ERROR, + ALERT_DESTINATIONS_LOAD_ERROR_RISK_NOTICE, + getAlertDestinationsAppriseValidationError, + getAlertDestinationsLoadErrorBanner, +} from '@/utils/alertDestinationsPresentation'; + +// Residual branch-coverage probes for the alert-destination presentation +// module. The sibling test (alertDestinationsPresentation.test.ts) already +// exercises the boolean/enum happy arms of the helpers and the +// 'missingServerUrl' early-return of getAlertDestinationsAppriseValidationError. +// This file targets the residual: +// (a) the two fall-through arms of getAlertDestinationsAppriseValidationError +// that delegate to getAlertDestinationsAppriseTestError, plus the +// defensive default when an unknown variant slips past the union, and +// (b) the canonical UI copy constants the sibling test never imports — each +// `export const` is its own coverage statement, so pinning them guards +// against silent renames. + +describe('alertDestinationsPresentation.branchcov0718', () => { + describe('getAlertDestinationsAppriseValidationError — uncovered fall-through arms', () => { + // The sibling test only invokes this wrapper with 'missingServerUrl' + // (the early-return arm). The 'disabled' and 'missingTargets' variants + // fall through to getAlertDestinationsAppriseTestError; those two + // delegation edges are uncovered until exercised here. + + it("delegates 'disabled' to the enable-for-test error copy", () => { + expect(getAlertDestinationsAppriseValidationError('disabled')).toBe( + ALERT_DESTINATIONS_APPRISE_ENABLE_FOR_TEST_ERROR, + ); + expect(getAlertDestinationsAppriseValidationError('disabled')).toBe( + 'Enable Apprise notifications before sending a test.', + ); + }); + + it("delegates 'missingTargets' to the missing-targets error copy", () => { + expect(getAlertDestinationsAppriseValidationError('missingTargets')).toBe( + ALERT_DESTINATIONS_APPRISE_MISSING_TARGETS_ERROR, + ); + expect(getAlertDestinationsAppriseValidationError('missingTargets')).toBe( + 'Add at least one Apprise target to test CLI delivery.', + ); + }); + + it('routes an unexpected variant through the default (missing-targets) arm', () => { + // The 'missingServerUrl' early-return is taken only on an exact match; + // any other value (here a non-union string cast through the param + // type) misses the early return and lands in + // getAlertDestinationsAppriseTestError, whose own 'disabled' equality + // also misses and defaults to the missing-targets copy. This exercises + // both default arms in sequence via a single malformed input. + const bogus = 'server-down' as unknown as Parameters< + typeof getAlertDestinationsAppriseValidationError + >[0]; + expect(getAlertDestinationsAppriseValidationError(bogus)).toBe( + ALERT_DESTINATIONS_APPRISE_MISSING_TARGETS_ERROR, + ); + }); + }); + + describe('getAlertDestinationsLoadErrorBanner — boundary message inputs', () => { + // The sibling test passes only a single non-empty webhook prefix. The + // template composes via one interpolation branch; probe the empty-string + // boundary and a second distinct prefix to confirm the join is a pure + // concatenation with no special-casing. + it('joins an empty leading message with the risk notice', () => { + expect(getAlertDestinationsLoadErrorBanner('')).toBe( + ` ${ALERT_DESTINATIONS_LOAD_ERROR_RISK_NOTICE}`, + ); + }); + + it('joins the canonical config-load prefix with the risk notice', () => { + const prefix = + 'Unable to load notification settings. Your existing configuration could not be retrieved.'; + expect(getAlertDestinationsLoadErrorBanner(prefix)).toBe( + `${prefix} ${ALERT_DESTINATIONS_LOAD_ERROR_RISK_NOTICE}`, + ); + }); + }); + + describe('residual canonical copy constants', () => { + it('exposes the enabled/disabled status vocabulary', () => { + expect(ALERT_DESTINATIONS_ENABLED_LABEL).toBe('Enabled'); + expect(ALERT_DESTINATIONS_DISABLED_LABEL).toBe('Disabled'); + }); + + it('exposes the email panel description', () => { + expect(ALERT_DESTINATIONS_EMAIL_PANEL_DESCRIPTION).toBe( + 'Configure SMTP delivery for alert emails.', + ); + }); + + it('exposes the apprise panel description', () => { + expect(ALERT_DESTINATIONS_APPRISE_PANEL_DESCRIPTION).toBe( + 'Relay grouped alerts through Apprise by using the CLI or a remote API.', + ); + }); + + it('exposes the apprise test-action label constants', () => { + expect(ALERT_DESTINATIONS_APPRISE_TEST_LABEL).toBe('Send test'); + expect(ALERT_DESTINATIONS_APPRISE_TESTING_LABEL).toBe('Testing…'); + }); + + it('exposes the apprise delivery-mode vocabulary', () => { + expect(ALERT_DESTINATIONS_APPRISE_MODE_LABEL).toBe('Delivery mode'); + expect(ALERT_DESTINATIONS_APPRISE_MODE_CLI_LABEL).toBe('Local Apprise CLI'); + expect(ALERT_DESTINATIONS_APPRISE_MODE_HTTP_LABEL).toBe('Remote Apprise API'); + }); + + it('exposes the apprise targets label and per-mode help copy', () => { + expect(ALERT_DESTINATIONS_APPRISE_TARGETS_LABEL).toBe('Delivery targets'); + expect(ALERT_DESTINATIONS_APPRISE_TARGETS_HELP_CLI).toBe( + 'Enter one Apprise URL per line. Commas are also supported.', + ); + expect(ALERT_DESTINATIONS_APPRISE_TARGETS_HELP_HTTP).toBe( + 'Optional: override the URLs defined on your Apprise API instance. Leave blank to use the server defaults.', + ); + }); + + it('exposes the apprise CLI-path vocabulary', () => { + expect(ALERT_DESTINATIONS_APPRISE_CLI_PATH_LABEL).toBe('CLI path'); + expect(ALERT_DESTINATIONS_APPRISE_CLI_PATH_PLACEHOLDER).toBe('apprise'); + expect(ALERT_DESTINATIONS_APPRISE_CLI_PATH_HELP).toBe( + 'Leave blank to use the default `apprise` executable.', + ); + }); + + it('exposes the apprise server-URL vocabulary', () => { + expect(ALERT_DESTINATIONS_APPRISE_SERVER_URL_LABEL).toBe('Server URL'); + expect(ALERT_DESTINATIONS_APPRISE_SERVER_URL_PLACEHOLDER).toBe( + 'https://apprise-api.internal:8000', + ); + expect(ALERT_DESTINATIONS_APPRISE_SERVER_URL_HELP).toBe( + 'Point to an Apprise API endpoint such as https://host:8000.', + ); + }); + + it('exposes the apprise config-key vocabulary', () => { + expect(ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_LABEL).toBe('Config key (optional)'); + expect(ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_PLACEHOLDER).toBe('default'); + expect(ALERT_DESTINATIONS_APPRISE_CONFIG_KEY_HELP).toBe( + 'Targets the /notify/ endpoint when provided.', + ); + }); + + it('exposes the apprise API-key vocabulary', () => { + expect(ALERT_DESTINATIONS_APPRISE_API_KEY_LABEL).toBe('API key'); + expect(ALERT_DESTINATIONS_APPRISE_API_KEY_PLACEHOLDER).toBe('Optional API key'); + expect(ALERT_DESTINATIONS_APPRISE_API_KEY_HELP).toBe( + 'Included with each request when your Apprise API requires authentication.', + ); + }); + + it('exposes the apprise API-key header vocabulary', () => { + expect(ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_LABEL).toBe('API key header'); + expect(ALERT_DESTINATIONS_APPRISE_API_KEY_HEADER_PLACEHOLDER).toBe('X-API-KEY'); + }); + + it('exposes the apprise TLS vocabulary', () => { + expect(ALERT_DESTINATIONS_APPRISE_TLS_LABEL).toBe('TLS verification'); + expect(ALERT_DESTINATIONS_APPRISE_TLS_CHECKBOX_LABEL).toBe( + 'Allow self-signed certificates', + ); + expect(ALERT_DESTINATIONS_APPRISE_TLS_HELP).toBe( + 'Enable only when the Apprise API uses a self-signed certificate.', + ); + }); + + it('exposes the apprise timeout vocabulary', () => { + expect(ALERT_DESTINATIONS_APPRISE_TIMEOUT_LABEL).toBe('Timeout (seconds)'); + expect(ALERT_DESTINATIONS_APPRISE_TIMEOUT_HELP).toBe( + 'Maximum time to wait for Apprise to respond.', + ); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/alertEmailPresentation.branchcov0718.test.ts b/frontend-modern/src/utils/__tests__/alertEmailPresentation.branchcov0718.test.ts new file mode 100644 index 000000000..e6925d68b --- /dev/null +++ b/frontend-modern/src/utils/__tests__/alertEmailPresentation.branchcov0718.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from 'vitest'; +import { + ALERT_EMAIL_FROM_ADDRESS_LABEL, + ALERT_EMAIL_FROM_ADDRESS_PLACEHOLDER, + ALERT_EMAIL_HIDE_ADVANCED_OPTIONS_LABEL, + ALERT_EMAIL_HIDE_SETUP_INSTRUCTIONS_LABEL, + ALERT_EMAIL_MAX_RETRIES_LABEL, + ALERT_EMAIL_PASSWORD_LABEL, + ALERT_EMAIL_PASSWORD_PLACEHOLDER, + ALERT_EMAIL_RATE_LIMIT_LABEL, + ALERT_EMAIL_RATE_LIMIT_SUFFIX, + ALERT_EMAIL_RECIPIENTS_FALLBACK_FROM, + ALERT_EMAIL_RECIPIENTS_LABEL, + ALERT_EMAIL_RECIPIENTS_PLACEHOLDER_SUFFIX, + ALERT_EMAIL_REPLY_TO_LABEL, + ALERT_EMAIL_REPLY_TO_PLACEHOLDER, + ALERT_EMAIL_RETRY_DELAY_LABEL, + ALERT_EMAIL_SECURITY_LABEL, + ALERT_EMAIL_SECURITY_NONE_LABEL, + ALERT_EMAIL_SECURITY_STARTTLS_LABEL, + ALERT_EMAIL_SECURITY_TLS_LABEL, + ALERT_EMAIL_SENDGRID_USERNAME_PLACEHOLDER, + ALERT_EMAIL_SHOW_ADVANCED_OPTIONS_LABEL, + ALERT_EMAIL_SHOW_SETUP_INSTRUCTIONS_LABEL, + ALERT_EMAIL_SMTP_PORT_LABEL, + ALERT_EMAIL_SMTP_PORT_PLACEHOLDER, + ALERT_EMAIL_SMTP_SERVER_LABEL, + ALERT_EMAIL_SMTP_SERVER_PLACEHOLDER, + ALERT_EMAIL_TEST_LABEL, + ALERT_EMAIL_TESTING_LABEL, + ALERT_EMAIL_USERNAME_LABEL, + ALERT_EMAIL_USERNAME_PLACEHOLDER, + getAlertEmailAdvancedToggleLabel, + getAlertEmailProviderOptionLabel, + getAlertEmailRecipientsPlaceholder, + getAlertEmailSetupInstructionsToggleLabel, + getAlertEmailTestButtonLabel, + getAlertEmailUsernamePlaceholder, +} from '@/utils/alertEmailPresentation'; + +// Branch-coverage companion to alertEmailPresentation.test.ts. +// +// The sibling suite already exercises: +// - getAlertEmailProviderOptionLabel on the SendGrid (587) sample, +// - both arms of getAlertEmailUsernamePlaceholder ('SendGrid' vs 'SMTP2GO'), +// - getAlertEmailRecipientsPlaceholder on the TRUTHY fromAddress arm only, +// - both arms of the two toggle helpers, +// - both arms of getAlertEmailTestButtonLabel, +// - a small slice of the exported label/placeholder constants. +// +// This file targets the RESIDUAL: +// 1. The falsy arm (`fromAddress || ALERT_EMAIL_RECIPIENTS_FALLBACK_FROM`) of +// getAlertEmailRecipientsPlaceholder for `undefined` and the empty string, +// including the exact rendered newline-joined string. +// 2. Additional provider variants for getAlertEmailUsernamePlaceholder and +// getAlertEmailProviderOptionLabel to confirm the non-SendGrid branch is +// stable across provider strings (and that integer port rendering is +// literal, no coercion). +// 3. The full canonical label/placeholder vocabulary that the sibling suite +// does not yet assert — every remaining exported string constant. +// +// Note on item-spec wording: the task brief mentions "factory functions +// returning objects of getters" and "email-config state arms (configured vs +// not)". This module has neither — it is 69 lines of pure value-in/value-out +// functions plus exported string constants. The residual coverage is therefore +// the falsy recipients arm + the unused-on-this-path vocabulary. There are no +// factory/getter functions to wrap in createRoot here, so we follow the +// sibling's plain direct-invocation pattern exactly. + +describe('alertEmailPresentation — branch coverage (batch 0718)', () => { + describe('getAlertEmailRecipientsPlaceholder — falsy fromAddress arm', () => { + it('falls back to "the from address" when fromAddress is undefined', () => { + expect(getAlertEmailRecipientsPlaceholder()).toBe( + `Leave empty to use ${ALERT_EMAIL_RECIPIENTS_FALLBACK_FROM}\n${ALERT_EMAIL_RECIPIENTS_PLACEHOLDER_SUFFIX}`, + ); + expect(getAlertEmailRecipientsPlaceholder()).toBe( + 'Leave empty to use the from address\nOr add one recipient per line', + ); + }); + + it('falls back to "the from address" when fromAddress is the empty string (|| short-circuits)', () => { + // The empty string is falsy in JS, so the `||` operator picks the + // FALLBACK_FROM constant even though an argument was supplied. + expect(getAlertEmailRecipientsPlaceholder('')).toBe( + 'Leave empty to use the from address\nOr add one recipient per line', + ); + }); + + it('uses the supplied fromAddress verbatim when truthy (whitespace survives)', () => { + // Sanity check that the truthy arm interpolates the raw string; the + // sibling test only checked one sample ('ops@example.com'). + expect(getAlertEmailRecipientsPlaceholder(' team@corp.io ')).toBe( + 'Leave empty to use team@corp.io \nOr add one recipient per line', + ); + }); + }); + + describe('getAlertEmailUsernamePlaceholder — non-SendGrid arm across provider variants', () => { + it.each([ + ['Mailgun' as const], + ['Postmark' as const], + ['Amazon SES' as const], + ['' as const], + ['sendgrid' as const], // case-sensitive: only exact 'SendGrid' matches + ])('returns the generic placeholder for provider %p (non-SendGrid arm)', (provider) => { + expect(getAlertEmailUsernamePlaceholder(provider)).toBe(ALERT_EMAIL_USERNAME_PLACEHOLDER); + expect(getAlertEmailUsernamePlaceholder(provider)).toBe('username@example.com'); + }); + + it('returns the SendGrid placeholder only for the exact string "SendGrid"', () => { + expect(getAlertEmailUsernamePlaceholder('SendGrid')).toBe( + ALERT_EMAIL_SENDGRID_USERNAME_PLACEHOLDER, + ); + expect(getAlertEmailUsernamePlaceholder('SendGrid')).toBe('apikey'); + }); + }); + + describe('getAlertEmailProviderOptionLabel — port & host rendering variants', () => { + it('renders TLS port 465 and arbitrary host verbatim (no protocol rewrite)', () => { + expect( + getAlertEmailProviderOptionLabel({ + name: 'Amazon SES', + smtpHost: 'email-smtp.us-east-1.amazonaws.com', + smtpPort: 465, + }), + ).toBe('Amazon SES (email-smtp.us-east-1.amazonaws.com:465)'); + }); + + it('renders unusual port numbers and unicode / spaced names without coercion', () => { + // Port 2525 is commonly used by Mailgun/Postmark; we assert the integer + // is interpolated directly (no zero-padding, no thousands separator). + expect( + getAlertEmailProviderOptionLabel({ + name: 'Mailgun EU', + smtpHost: 'smtp.eu.mailgun.org', + smtpPort: 2525, + }), + ).toBe('Mailgun EU (smtp.eu.mailgun.org:2525)'); + + // Port 1 (boundary: smallest truthy port) renders literally. + expect( + getAlertEmailProviderOptionLabel({ + name: 'X', + smtpHost: 'h', + smtpPort: 1, + }), + ).toBe('X (h:1)'); + }); + }); + + describe('getAlertEmailSetupInstructionsToggleLabel — exact constant wiring', () => { + it('returns the canonical HIDE constant for true', () => { + expect(getAlertEmailSetupInstructionsToggleLabel(true)).toBe( + ALERT_EMAIL_HIDE_SETUP_INSTRUCTIONS_LABEL, + ); + }); + + it('returns the canonical SHOW constant for false', () => { + expect(getAlertEmailSetupInstructionsToggleLabel(false)).toBe( + ALERT_EMAIL_SHOW_SETUP_INSTRUCTIONS_LABEL, + ); + }); + }); + + describe('getAlertEmailAdvancedToggleLabel — exact constant wiring', () => { + it('returns the canonical HIDE constant for true', () => { + expect(getAlertEmailAdvancedToggleLabel(true)).toBe( + ALERT_EMAIL_HIDE_ADVANCED_OPTIONS_LABEL, + ); + }); + + it('returns the canonical SHOW constant for false', () => { + expect(getAlertEmailAdvancedToggleLabel(false)).toBe( + ALERT_EMAIL_SHOW_ADVANCED_OPTIONS_LABEL, + ); + }); + }); + + describe('getAlertEmailTestButtonLabel — exact constant wiring', () => { + it('returns the canonical idle label for false', () => { + expect(getAlertEmailTestButtonLabel(false)).toBe(ALERT_EMAIL_TEST_LABEL); + }); + + it('returns the canonical testing label for true', () => { + expect(getAlertEmailTestButtonLabel(true)).toBe(ALERT_EMAIL_TESTING_LABEL); + }); + }); + + describe('residual exported vocabulary — exact canonical strings', () => { + // The sibling suite asserts a handful of label constants; this block pins + // the rest so a silent rename in the source will fail loudly here. + + it('exposes the SMTP server / port label + placeholder vocabulary', () => { + expect(ALERT_EMAIL_SMTP_SERVER_LABEL).toBe('SMTP server'); + expect(ALERT_EMAIL_SMTP_SERVER_PLACEHOLDER).toBe('smtp.example.com'); + expect(ALERT_EMAIL_SMTP_PORT_LABEL).toBe('SMTP port'); + expect(ALERT_EMAIL_SMTP_PORT_PLACEHOLDER).toBe('587'); + }); + + it('exposes the from / reply-to label + placeholder vocabulary', () => { + expect(ALERT_EMAIL_FROM_ADDRESS_LABEL).toBe('From address'); + expect(ALERT_EMAIL_FROM_ADDRESS_PLACEHOLDER).toBe('noreply@example.com'); + expect(ALERT_EMAIL_REPLY_TO_LABEL).toBe('Reply-to address'); + expect(ALERT_EMAIL_REPLY_TO_PLACEHOLDER).toBe('admin@example.com'); + }); + + it('exposes the username label + both placeholder constants', () => { + expect(ALERT_EMAIL_USERNAME_LABEL).toBe('Username'); + expect(ALERT_EMAIL_USERNAME_PLACEHOLDER).toBe('username@example.com'); + expect(ALERT_EMAIL_SENDGRID_USERNAME_PLACEHOLDER).toBe('apikey'); + }); + + it('exposes the password label + placeholder vocabulary', () => { + expect(ALERT_EMAIL_PASSWORD_LABEL).toBe('Password / API key'); + expect(ALERT_EMAIL_PASSWORD_PLACEHOLDER).toBe('••••••••'); + }); + + it('exposes the recipients label + fallback + suffix vocabulary', () => { + expect(ALERT_EMAIL_RECIPIENTS_LABEL).toBe('Recipients (one per line)'); + expect(ALERT_EMAIL_RECIPIENTS_FALLBACK_FROM).toBe('the from address'); + expect(ALERT_EMAIL_RECIPIENTS_PLACEHOLDER_SUFFIX).toBe('Or add one recipient per line'); + }); + + it('exposes the setup-instructions show/hide vocabulary', () => { + expect(ALERT_EMAIL_SHOW_SETUP_INSTRUCTIONS_LABEL).toBe('Show setup instructions'); + expect(ALERT_EMAIL_HIDE_SETUP_INSTRUCTIONS_LABEL).toBe('Hide setup instructions'); + }); + + it('exposes the advanced-options show/hide vocabulary', () => { + expect(ALERT_EMAIL_SHOW_ADVANCED_OPTIONS_LABEL).toBe('Show advanced options'); + expect(ALERT_EMAIL_HIDE_ADVANCED_OPTIONS_LABEL).toBe('Hide advanced options'); + }); + + it('exposes the security-option vocabulary (label + all three arms)', () => { + expect(ALERT_EMAIL_SECURITY_LABEL).toBe('Security'); + expect(ALERT_EMAIL_SECURITY_NONE_LABEL).toBe('None'); + expect(ALERT_EMAIL_SECURITY_STARTTLS_LABEL).toBe('STARTTLS (587)'); + expect(ALERT_EMAIL_SECURITY_TLS_LABEL).toBe('TLS/SSL (465)'); + }); + + it('exposes the rate-limit label + suffix vocabulary', () => { + expect(ALERT_EMAIL_RATE_LIMIT_LABEL).toBe('Rate limit'); + expect(ALERT_EMAIL_RATE_LIMIT_SUFFIX).toBe('/min'); + }); + + it('exposes the retry vocabulary (max retries + retry delay)', () => { + expect(ALERT_EMAIL_MAX_RETRIES_LABEL).toBe('Max retries'); + expect(ALERT_EMAIL_RETRY_DELAY_LABEL).toBe('Retry delay (seconds)'); + }); + + it('exposes the test-email idle vocabulary (testing arm covered above)', () => { + expect(ALERT_EMAIL_TEST_LABEL).toBe('Send test email'); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/alertResourceTablePresentation.branchcov0718.test.ts b/frontend-modern/src/utils/__tests__/alertResourceTablePresentation.branchcov0718.test.ts new file mode 100644 index 000000000..3ee70af4b --- /dev/null +++ b/frontend-modern/src/utils/__tests__/alertResourceTablePresentation.branchcov0718.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { + // Metric-input title constants — the sibling test only asserts these via + // the getAlertResourceTable*Title helpers, never pinning the constants + // themselves. Each `export const` is its own coverage statement, so + // importing them here guards against a silent rename. + ALERT_RESOURCE_TABLE_DISABLE_METRIC_TITLE, + ALERT_RESOURCE_TABLE_EDIT_METRIC_TITLE, + ALERT_RESOURCE_TABLE_ENABLE_METRIC_TITLE, + ALERT_RESOURCE_TABLE_EMPTY_STATE, + ALERT_RESOURCE_TABLE_OFFLINE_STATE_CRITICAL_LABEL, + ALERT_RESOURCE_TABLE_OFFLINE_STATE_CRITICAL_TITLE, + getAlertResourceTableEmptyState, + getAlertResourceTableNoResultsState, + getAlertResourceTableOfflineStateOrder, + getAlertResourceTableOfflineStatePresentation, + type AlertResourceTableOfflineState, +} from '@/utils/alertResourceTablePresentation'; + +// Residual branch-coverage probes for alertResourceTablePresentation. +// +// The sibling test (alertResourceTablePresentation.test.ts) already exercises +// the canonical happy arm of every exported helper and drives the module to +// 100% v8 line/branch coverage. This file targets the *residual behavioral +// arms* the sibling never trips — the falsy-empty-string corner of the +// `emptyMessage || DEFAULT` short-circuit, the defensive `default` arm of +// the offline-state switch (reached only by a non-union sentinel cast +// through the param type), the toLowerCase boundary inputs for the +// no-results formatter, and the three metric-title constants the sibling +// never imports. None of these assertions duplicate the sibling. + +describe('alertResourceTablePresentation.branchcov0718', () => { + describe('getAlertResourceTableEmptyState — falsy empty-string corner', () => { + // The sibling test covers the two obvious arms: undefined (-> default) + // and a non-empty custom message (-> custom). The `||` short-circuit + // also has a third corner: an explicitly empty string is falsy, so it + // must fall back to the canonical default rather than render as blank. + it('falls back to the canonical default when handed an empty string', () => { + expect(getAlertResourceTableEmptyState('')).toBe(ALERT_RESOURCE_TABLE_EMPTY_STATE); + expect(getAlertResourceTableEmptyState('')).toBe('No resources available.'); + }); + + it('still prefers a non-empty whitespace-only message (truthy, not trimmed)', () => { + // The guard is truthiness, not a trimmed check, so a string of only + // spaces is truthy and is returned verbatim. Locking this prevents a + // well-meaning refactor from silently introducing a `.trim()` that + // would change observable behavior. + expect(getAlertResourceTableEmptyState(' ')).toBe(' '); + }); + }); + + describe('getAlertResourceTableNoResultsState — toLowerCase boundary inputs', () => { + // The sibling test passes only the title-cased single word 'Guests'. + // Probe the lowercase-forcing interpolation across casing, word count, + // and the empty-string boundary. + it('lowercases an all-uppercase acronym title', () => { + expect(getAlertResourceTableNoResultsState('VMS')).toBe('No vms found'); + }); + + it('lowercases a multi-word title phrase', () => { + expect(getAlertResourceTableNoResultsState('Alert Rules')).toBe( + 'No alert rules found', + ); + }); + + it('passes an already-lowercase title through unchanged', () => { + expect(getAlertResourceTableNoResultsState('guests')).toBe('No guests found'); + }); + + it('composes around an empty title without special-casing', () => { + // No defensive guard on the title, so an empty string interpolates + // verbatim into the template rather than rendering a fallback. + expect(getAlertResourceTableNoResultsState('')).toBe('No found'); + }); + }); + + describe('getAlertResourceTableOfflineStatePresentation — defensive default arm', () => { + // The switch's `case 'critical':` falls through to `default:`, so the + // sibling's three named-state calls only ever exercise the three case + // labels. The `default` arm itself is reached solely by a value the + // union does not name; cast a sentinel string through the param type to + // trip it and confirm the defensive fallback renders the critical tone. + it('routes an unknown state through the default (critical-toned) arm', () => { + const bogus = 'unknown' as unknown as AlertResourceTableOfflineState; + expect(getAlertResourceTableOfflineStatePresentation(bogus)).toEqual({ + label: ALERT_RESOURCE_TABLE_OFFLINE_STATE_CRITICAL_LABEL, + className: + 'bg-red-50 text-red-700 hover:bg-red-100 dark:bg-red-900 dark:text-red-200 dark:hover:bg-red-800', + title: ALERT_RESOURCE_TABLE_OFFLINE_STATE_CRITICAL_TITLE, + }); + }); + + it('renders the critical label and title copy for the default arm', () => { + const bogus = 'info' as unknown as AlertResourceTableOfflineState; + const presentation = getAlertResourceTableOfflineStatePresentation(bogus); + expect(presentation.label).toBe('Crit'); + expect(presentation.title).toBe( + 'Offline alerts will raise critical-level notifications.', + ); + }); + }); + + describe('getAlertResourceTableOfflineStateOrder — freshness guarantee', () => { + // The sibling test asserts the literal contents once. The helper + // returns a fresh array literal on every call, so callers can mutate + // the result without side-effecting subsequent callers — pin that + // independence so a future memoization does not silently break it. + it('returns equal but independent array instances across calls', () => { + const first = getAlertResourceTableOfflineStateOrder(); + const second = getAlertResourceTableOfflineStateOrder(); + expect(second).toEqual(['off', 'warning', 'critical']); + expect(first).toEqual(second); + expect(first).not.toBe(second); + + // Mutating the first call must not bleed into the second. + first.push('critical'); + expect(second).toEqual(['off', 'warning', 'critical']); + }); + }); + + describe('metric-title constants — residual pins', () => { + // The sibling test reads these values only indirectly through the + // getAlertResourceTableMetricInputTitle / getAlertResourceTableEditMetricTitle + // helpers. Pin the underlying constants directly so a rename is caught + // even if the helper return values stay stable. + it('exposes the enable/disable metric-input title constants', () => { + expect(ALERT_RESOURCE_TABLE_ENABLE_METRIC_TITLE).toBe( + 'Click to enable this metric', + ); + expect(ALERT_RESOURCE_TABLE_DISABLE_METRIC_TITLE).toBe( + 'Set to -1 to disable alerts for this metric', + ); + }); + + it('exposes the edit-metric title constant', () => { + expect(ALERT_RESOURCE_TABLE_EDIT_METRIC_TITLE).toBe('Click to edit this metric'); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/auditWebhookPresentation.branchcov0718.test.ts b/frontend-modern/src/utils/__tests__/auditWebhookPresentation.branchcov0718.test.ts new file mode 100644 index 000000000..c24385ac0 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/auditWebhookPresentation.branchcov0718.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import { + AUDIT_WEBHOOK_SECURITY_NOTE_BODY, + AUDIT_WEBHOOK_SECURITY_NOTE_TITLE, + getAuditWebhookDuplicateUrlMessage, + getAuditWebhookFeatureGateCopy, + getAuditWebhookInvalidUrlMessage, + getAuditWebhookSaveErrorMessage, + getAuditWebhookSaveSuccessMessage, +} from '@/utils/auditWebhookPresentation'; + +// Branch-coverage companion to auditWebhookPresentation.test.ts. +// +// The sibling suite already pins: +// - getAuditWebhookFeatureGateCopy() with no args (commercial copy arm), +// - getAuditWebhookFeatureGateCopy({ showCommercialCopy: false }) (not-enabled arm), +// - getAuditWebhookFeatureGateCopy({ paidRuntimeRequired: true }) (paid-runtime arm), +// - getAuditWebhookEmptyStateCopy / getAuditWebhookLoadingState canonical strings, +// - a small slice of the exported shell-class constants. +// +// v8 reports the module at 100% branch coverage but only ~43% function coverage +// after the sibling suite: four pure message getters +// (getAuditWebhookInvalidUrlMessage, getAuditWebhookDuplicateUrlMessage, +// getAuditWebhookSaveSuccessMessage, getAuditWebhookSaveErrorMessage) are never +// invoked, and the exported SECURITY_NOTE_TITLE / SECURITY_NOTE_BODY strings are +// never asserted. This file targets that residual: +// +// 1. Each currently-unexercised combination of the feature-gate option arms — +// pinning precedence (paidRuntimeRequired wins even when +// showCommercialCopy is explicitly false) and the explicit-true / +// explicit-false / undefined matrix per option. +// 2. The four uncovered message getters, asserting each exact canonical +// string so a silent rename in the source fails loudly. +// 3. The two exported SECURITY_NOTE vocabulary strings, which no test +// currently pins. +// +// The module is pure value-in/value-out (no factory returning getter +// properties, no Solid signals) so we mirror the sibling's plain +// direct-invocation pattern — no createRoot required. + +describe('auditWebhookPresentation — branch coverage (batch 0718)', () => { + describe('getAuditWebhookFeatureGateCopy — residual option-matrix arms', () => { + it('honours paidRuntimeRequired even when showCommercialCopy is explicitly false (precedence: first guard wins)', () => { + // The `if (options.paidRuntimeRequired)` guard runs before the + // showCommercialCopy check, so the paid-runtime copy must win regardless + // of the commercial flag. Locks the precedence order. + const copy = getAuditWebhookFeatureGateCopy({ + paidRuntimeRequired: true, + showCommercialCopy: false, + }); + expect(copy).toEqual({ + title: 'Pulse Pro runtime required', + body: expect.stringContaining('private Pulse Pro runtime'), + }); + expect(copy.body).not.toContain('not enabled'); + }); + + it('honours paidRuntimeRequired when showCommercialCopy is explicitly true (both flags agree on commercial intent)', () => { + const copy = getAuditWebhookFeatureGateCopy({ + paidRuntimeRequired: true, + showCommercialCopy: true, + }); + expect(copy).toEqual({ + title: 'Pulse Pro runtime required', + body: expect.stringContaining('Install the private Pulse Pro runtime'), + }); + }); + + it('returns commercial copy for explicit paidRuntimeRequired: false + showCommercialCopy: true', () => { + // Explicit `false` for paidRuntimeRequired takes the falsy arm of the + // first guard; explicit `true` for showCommercialCopy means + // `!== false` is true → commercial copy. + expect( + getAuditWebhookFeatureGateCopy({ + paidRuntimeRequired: false, + showCommercialCopy: true, + }), + ).toEqual({ + title: 'Audit Webhooks', + body: 'Audit webhook delivery is available on paid self-hosted and hosted plans.', + }); + }); + + it('returns not-enabled copy for explicit paidRuntimeRequired: false + showCommercialCopy: false', () => { + expect( + getAuditWebhookFeatureGateCopy({ + paidRuntimeRequired: false, + showCommercialCopy: false, + }), + ).toEqual({ + title: 'Audit Webhooks', + body: 'Audit webhook delivery is not enabled for this instance.', + }); + }); + + it('returns commercial copy for explicit paidRuntimeRequired: false with showCommercialCopy omitted (undefined !== false)', () => { + // Documents that omitting showCommercialCopy is equivalent to + // showCommercialCopy: true — the `!== false` default-on behaviour. + expect( + getAuditWebhookFeatureGateCopy({ paidRuntimeRequired: false }), + ).toEqual({ + title: 'Audit Webhooks', + body: 'Audit webhook delivery is available on paid self-hosted and hosted plans.', + }); + }); + + it('returns commercial copy for explicit showCommercialCopy: true with paidRuntimeRequired omitted', () => { + // Sister case: only showCommercialCopy is supplied (as true). The first + // guard's falsy arm is taken (paidRuntimeRequired undefined), then the + // commercial body is selected. + expect(getAuditWebhookFeatureGateCopy({ showCommercialCopy: true })).toEqual({ + title: 'Audit Webhooks', + body: 'Audit webhook delivery is available on paid self-hosted and hosted plans.', + }); + }); + + it('returns commercial copy when called with an explicit empty options object (matches no-arg overload)', () => { + // The default `options = {}` parameter must behave identically to an + // explicit `{}` — locks the no-arg / empty-arg equivalence. + expect(getAuditWebhookFeatureGateCopy({})).toEqual({ + title: 'Audit Webhooks', + body: 'Audit webhook delivery is available on paid self-hosted and hosted plans.', + }); + }); + }); + + describe('getAuditWebhookInvalidUrlMessage — uncovered getter', () => { + it('returns the canonical invalid-URL validation message', () => { + expect(getAuditWebhookInvalidUrlMessage()).toBe('Please enter a valid URL'); + }); + }); + + describe('getAuditWebhookDuplicateUrlMessage — uncovered getter', () => { + it('returns the canonical duplicate-URL validation message', () => { + expect(getAuditWebhookDuplicateUrlMessage()).toBe('This URL is already configured'); + }); + }); + + describe('getAuditWebhookSaveSuccessMessage — uncovered getter', () => { + it('returns the canonical save-success toast message', () => { + expect(getAuditWebhookSaveSuccessMessage()).toBe('Audit webhooks updated'); + }); + }); + + describe('getAuditWebhookSaveErrorMessage — uncovered getter', () => { + it('returns the canonical save-error toast message', () => { + expect(getAuditWebhookSaveErrorMessage()).toBe('Failed to save webhook configuration'); + }); + }); + + describe('residual exported vocabulary — SECURITY_NOTE constants', () => { + // The sibling suite never asserts the SECURITY_NOTE strings; pin them so a + // future copy edit cannot pass silently. + it('exposes the canonical SECURITY_NOTE title', () => { + expect(AUDIT_WEBHOOK_SECURITY_NOTE_TITLE).toBe('Security Note'); + }); + + it('exposes the canonical SECURITY_NOTE body verbatim', () => { + expect(AUDIT_WEBHOOK_SECURITY_NOTE_BODY).toBe( + 'Audit webhooks are dispatched asynchronously to avoid blocking user operations. Endpoints should still verify source trust (for example via an ingest secret) before processing events.', + ); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/k8sNamespacePresentation.branchcov0718.test.ts b/frontend-modern/src/utils/__tests__/k8sNamespacePresentation.branchcov0718.test.ts new file mode 100644 index 000000000..84492a156 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/k8sNamespacePresentation.branchcov0718.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { + K8S_NAMESPACES_COLUMN_ACTIONS_LABEL, + K8S_NAMESPACES_COLUMN_DEPLOYMENTS_LABEL, + K8S_NAMESPACES_COLUMN_NAMESPACE_LABEL, + K8S_NAMESPACES_COLUMN_PODS_LABEL, + K8S_NAMESPACES_DRAWER_DESCRIPTION, + K8S_NAMESPACES_DRAWER_TITLE, + K8S_NAMESPACES_OPEN_ALL_PODS_LABEL, + K8S_NAMESPACES_OPEN_PODS_LABEL, + K8S_NAMESPACES_SEARCH_PLACEHOLDER, + K8S_NAMESPACES_VIEW_DEPLOYMENTS_LABEL, + getK8sNamespacesFailureState, +} from '../k8sNamespacePresentation'; + +// NOTE: k8sNamespacePresentation.ts is a small pure-presentation module +// (4 functions + 10 string constants). The existing sibling test +// (k8sNamespacePresentation.test.ts) already asserts the happy-path return +// value of every function: getK8sNamespacesDrawerPresentation(), +// getK8sNamespacesLoadingState(), getK8sNamespacesFailureState('boom'), +// getK8sNamespacesFailureState() (undefined), and both arms of +// getK8sNamespacesEmptyState(true|false). +// +// The module has NO namespace status/phase concept (the spec template's +// "9 uncovered getters across status/phase variants" does not map onto this +// file). The genuine residual coverage is: +// (a) the 10 exported K8S_NAMESPACES_* constants — exercised today only +// transitively through the function returns, never imported / asserted +// directly; +// (b) the falsy branch arm of `message || 'Unknown error'` in +// getK8sNamespacesFailureState for the inputs the existing test does +// NOT pass — namely `null` and the empty string `''` (the existing +// test only covers a truthy string and the implicit `undefined`). +// This file targets exactly that residual. + +describe('k8sNamespacePresentation.branchcov0718 — residual uncovered exports', () => { + describe('exported label / copy constants (direct assertion)', () => { + it('exposes the canonical drawer title and description', () => { + expect(K8S_NAMESPACES_DRAWER_TITLE).toBe('Namespaces'); + expect(K8S_NAMESPACES_DRAWER_DESCRIPTION).toBe( + 'Scope Pods and Deployments by namespace', + ); + }); + + it('exposes the canonical search placeholder', () => { + expect(K8S_NAMESPACES_SEARCH_PLACEHOLDER).toBe('Search namespaces...'); + }); + + it('exposes the canonical action button labels', () => { + expect(K8S_NAMESPACES_OPEN_ALL_PODS_LABEL).toBe('Open All Pods'); + expect(K8S_NAMESPACES_OPEN_PODS_LABEL).toBe('Open Pods'); + expect(K8S_NAMESPACES_VIEW_DEPLOYMENTS_LABEL).toBe('View Deployments'); + }); + + it('exposes the canonical table column labels', () => { + expect(K8S_NAMESPACES_COLUMN_NAMESPACE_LABEL).toBe('Namespace'); + expect(K8S_NAMESPACES_COLUMN_PODS_LABEL).toBe('Pods'); + expect(K8S_NAMESPACES_COLUMN_DEPLOYMENTS_LABEL).toBe('Deployments'); + expect(K8S_NAMESPACES_COLUMN_ACTIONS_LABEL).toBe('Actions'); + }); + }); + + describe('getK8sNamespacesFailureState — residual falsy branch arms', () => { + // Existing sibling test only passes `'boom'` (truthy) and `undefined` + // (argument omitted). The `message || 'Unknown error'` coercion has + // additional falsy arms that were never exercised: explicit `null` and + // the empty string `''`. Both must fall through to the canonical + // "Unknown error" copy. + + it('falls back to "Unknown error" when message is explicitly null', () => { + expect(getK8sNamespacesFailureState(null)).toEqual({ + title: 'Failed to load namespaces', + description: 'Unknown error', + }); + }); + + it('falls back to "Unknown error" when message is the empty string', () => { + expect(getK8sNamespacesFailureState('')).toEqual({ + title: 'Failed to load namespaces', + description: 'Unknown error', + }); + }); + + it('preserves a non-empty message and does not mutate the title', () => { + // Guards against a regression where the `||` could be widened to `??` + // (which would change behaviour for `''`). A non-empty message must + // survive unchanged and the title must remain the canonical failure + // copy regardless of the message value. + expect(getK8sNamespacesFailureState('connection refused')).toEqual({ + title: 'Failed to load namespaces', + description: 'connection refused', + }); + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/nodeModalPresentation.branchcov0718.test.ts b/frontend-modern/src/utils/__tests__/nodeModalPresentation.branchcov0718.test.ts new file mode 100644 index 000000000..973f5b3d2 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/nodeModalPresentation.branchcov0718.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it } from 'vitest'; +import { + buildNodeModalMonitoringPayload, + getNodeModalDefaultFormData, + getNodeModalTestResultPresentation, + getNodeMonitoringCoverageCopy, + getNodeTokenIdPlaceholder, + getTemperatureMonitoringLockedCopy, + type NodeModalFormData, +} from '@/utils/nodeModalPresentation'; + +// Branch-coverage companion to nodeModalPresentation.test.ts. +// +// The sibling suite already pins every switch arm of getNodeProductName, +// getNodeEndpointPlaceholder, getNodeEndpointHelp, getNodeGuestUrlPlaceholder, +// getNodeUsernamePlaceholder and getNodeUsernameHelp; pins both arms of each +// pmg predicate inside getNodeModalDefaultFormData (via toMatchObject on a +// subset of fields); covers the pve/pbs toggle-propagation arms of +// buildNodeModalMonitoringPayload; and exercises the success/warning/error +// outcomes of getNodeModalTestResultPresentation via partial matches. +// +// This file targets only the residual surface: +// - getTemperatureMonitoringLockedCopy (wholly uncovered export) +// - getNodeTokenIdPlaceholder('pmg') (untested `case 'pmg'` arm) +// - getNodeMonitoringCoverageCopy('pbs') (third input routing to the non-pmg arm) +// - getNodeModalTestResultPresentation with undefined/null/''/unrecognized status +// (the default arm of the switch on its full optional-input space) plus +// full-string pins of every panelClass/textClass so the dark-mode classes +// cannot silently regress +// - buildNodeModalMonitoringPayload pmg toggle propagation and the pve +// monitorPhysicalDisks/physicalDiskPollingMinutes assignment arms +// - getNodeModalDefaultFormData via toStrictEqual on every field so the +// fields the sibling ignores (tokenName, tokenValue, fingerprint, the +// pbs/pmg-only monitor flags when called for pve, etc.) are locked down + +// =========================================================================== +// getTemperatureMonitoringLockedCopy — previously uncovered export. +// =========================================================================== + +describe('getTemperatureMonitoringLockedCopy branch coverage', () => { + it('returns the canonical environment-override lock message verbatim', () => { + // Single-return literal; pin the exact wording (including the env-var + // token ENABLE_TEMPERATURE_MONITORING) so a copy edit surfaces loudly. + expect(getTemperatureMonitoringLockedCopy()).toBe( + 'Locked by environment variables. Remove the override (ENABLE_TEMPERATURE_MONITORING) and restart Pulse to manage it in the UI.', + ); + }); +}); + +// =========================================================================== +// getNodeTokenIdPlaceholder — pmg switch arm (sibling only tested pve + pbs). +// =========================================================================== + +describe('getNodeTokenIdPlaceholder branch coverage', () => { + it("returns the pmg-specific token id placeholder (untested `case 'pmg'` arm)", () => { + expect(getNodeTokenIdPlaceholder('pmg')).toBe('pulse-monitor@pmg!pulse-token'); + }); +}); + +// =========================================================================== +// getNodeMonitoringCoverageCopy — pbs input (sibling only asserted pve + pmg). +// =========================================================================== + +describe('getNodeMonitoringCoverageCopy branch coverage', () => { + it('returns the generic non-pmg coverage copy for pbs (false arm of the pmg predicate)', () => { + // The sibling test covers this return via 'pve'; pinning the pbs input + // too locks down the third node type that routes here. The exact string + // is asserted verbatim so the em-dash + 'PBS job activity' phrasing is + // protected against silent copy drift. + expect(getNodeMonitoringCoverageCopy('pbs')).toBe( + 'Pulse automatically tracks all supported resources for this node — virtual machines, containers, storage usage, backups, and PBS job activity — so you always get full visibility without extra configuration.', + ); + }); +}); + +// =========================================================================== +// getNodeModalTestResultPresentation — default arm with the full optional +// input space (undefined / null / '' / unrecognized) plus full-string pins +// of every panelClass and textClass. +// =========================================================================== + +describe('getNodeModalTestResultPresentation branch coverage', () => { + it('routes an undefined status to the default error presentation', () => { + // switch(undefined) misses both named cases and hits the default arm. + // The sibling test never calls the function without an argument, so the + // optional-parameter handling on the default arm is otherwise unexercised. + expect(getNodeModalTestResultPresentation()).toStrictEqual({ + panelClass: + 'mx-6 p-3 rounded-md text-sm bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200', + textClass: 'text-red-800 dark:text-red-200', + icon: 'error', + }); + }); + + it('routes a null status to the default error presentation', () => { + // switch(null) also hits the default arm; observable through the red + // icon and the dark-mode panelClass together. + expect(getNodeModalTestResultPresentation(null)).toStrictEqual({ + panelClass: + 'mx-6 p-3 rounded-md text-sm bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200', + textClass: 'text-red-800 dark:text-red-200', + icon: 'error', + }); + }); + + it('routes an empty-string status to the default error presentation', () => { + // '' is neither 'success' nor 'warning' — default arm. + const out = getNodeModalTestResultPresentation(''); + expect(out.icon).toBe('error'); + expect(out.panelClass).toBe( + 'mx-6 p-3 rounded-md text-sm bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200', + ); + expect(out.textClass).toBe('text-red-800 dark:text-red-200'); + }); + + it('routes an unrecognized status string to the default error presentation', () => { + // Any value other than 'success'/'warning' falls through to default; the + // returned icon is the 'error' sentinel even though the input string + // itself is preserved nowhere on the output. + const out = getNodeModalTestResultPresentation('connection-refused'); + expect(out.icon).toBe('error'); + expect(out.panelClass).toContain('bg-red-50'); + expect(out.panelClass).toContain('dark:bg-red-900'); + expect(out.textClass).toBe('text-red-800 dark:text-red-200'); + }); + + it('returns the exact success presentation with every dark-mode class pinned', () => { + // The sibling test only used stringContaining('bg-green-50'); this pins + // the whole panelClass string (including dark:green-900, the border + // classes, and the green-200 dark textClass) so a tailwind refactor + // cannot silently drop a class. + expect(getNodeModalTestResultPresentation('success')).toStrictEqual({ + panelClass: + 'mx-6 p-3 rounded-md text-sm bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200', + textClass: 'text-green-800 dark:text-green-200', + icon: 'success', + }); + }); + + it('returns the exact warning presentation with every dark-mode class pinned', () => { + expect(getNodeModalTestResultPresentation('warning')).toStrictEqual({ + panelClass: + 'mx-6 p-3 rounded-md text-sm bg-amber-50 dark:bg-amber-900 border border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200', + textClass: 'text-amber-800 dark:text-amber-200', + icon: 'warning', + }); + }); + + it('returns the exact error presentation with every dark-mode class pinned', () => { + // 'error' is not a named case — it falls through to default; the sibling + // test only asserts icon + stringContaining('bg-red-50'). This pins the + // full panelClass and the previously-unasserted textClass. + expect(getNodeModalTestResultPresentation('error')).toStrictEqual({ + panelClass: + 'mx-6 p-3 rounded-md text-sm bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200', + textClass: 'text-red-800 dark:text-red-200', + icon: 'error', + }); + }); +}); + +// =========================================================================== +// buildNodeModalMonitoringPayload — pmg toggle propagation (sibling only used +// default form data for pmg) plus the pve monitorPhysicalDisks=true arm with +// a custom physicalDiskPollingMinutes (sibling left both at their defaults). +// =========================================================================== + +describe('buildNodeModalMonitoringPayload branch coverage', () => { + it('propagates every toggled pmg scope boolean through the pmg case arm', () => { + // The sibling pmg test only reads back the default form data; this + // overrides all four pmg surfaces (two flipped to false, two flipped to + // true) so each assignment in the pmg case runs against a non-default + // value and the output is pinned via toStrictEqual (no extra keys leak). + const form: NodeModalFormData = { + ...getNodeModalDefaultFormData('pmg'), + monitorMailStats: false, + monitorQueues: true, + monitorQuarantine: false, + monitorDomainStats: true, + }; + expect(buildNodeModalMonitoringPayload('pmg', form)).toStrictEqual({ + monitorMailStats: false, + monitorQueues: true, + monitorQuarantine: false, + monitorDomainStats: true, + }); + }); + + it('propagates monitorPhysicalDisks=true and a custom physicalDiskPollingMinutes through the pve arm', () => { + // The sibling pve toggle test left monitorPhysicalDisks at its false + // default and physicalDiskPollingMinutes at 5; this flips the disk flag + // to true and overrides the polling cadence to 30 so both assignment + // arms run with non-default values in a single toStrictEqual payload. + const form: NodeModalFormData = { + ...getNodeModalDefaultFormData('pve'), + monitorPhysicalDisks: true, + physicalDiskPollingMinutes: 30, + }; + expect(buildNodeModalMonitoringPayload('pve', form)).toStrictEqual({ + monitorVMs: true, + monitorContainers: true, + monitorStorage: true, + monitorBackups: true, + monitorPhysicalDisks: true, + physicalDiskPollingMinutes: 30, + }); + }); +}); + +// =========================================================================== +// getNodeModalDefaultFormData — full toStrictEqual pin per node type. The +// sibling test only toMatchObject'd a subset of fields; this locks every +// field (including tokenName/tokenValue/fingerprint empty strings, the pve +// monitorVMs/Containers/Storage/Backups=true defaults, and the +// clusterEndpointOverrides empty record) so a default flip cannot hide. +// =========================================================================== + +describe('getNodeModalDefaultFormData branch coverage', () => { + it('returns the complete canonical pve form shape with every field pinned', () => { + expect(getNodeModalDefaultFormData('pve')).toStrictEqual({ + name: '', + host: '', + guestURL: '', + authType: 'token', + setupMode: 'auto', + user: '', + password: '', + tokenName: '', + tokenValue: '', + fingerprint: '', + verifySSL: true, + monitorVMs: true, + monitorContainers: true, + monitorStorage: true, + monitorBackups: true, + monitorPhysicalDisks: false, + physicalDiskPollingMinutes: 5, + monitorDatastores: true, + monitorSyncJobs: true, + monitorVerifyJobs: true, + monitorPruneJobs: true, + monitorGarbageJobs: true, + monitorMailStats: true, + monitorQueues: true, + monitorQuarantine: true, + monitorDomainStats: false, + clusterEndpointOverrides: {}, + }); + }); + + it('returns the complete canonical pbs form shape with every field pinned', () => { + // pbs takes the same non-pmg arms of both ternaries as pve (authType + // 'token', setupMode 'auto'); pinning it separately guards against a + // future per-nodeType default flip. + expect(getNodeModalDefaultFormData('pbs')).toStrictEqual({ + name: '', + host: '', + guestURL: '', + authType: 'token', + setupMode: 'auto', + user: '', + password: '', + tokenName: '', + tokenValue: '', + fingerprint: '', + verifySSL: true, + monitorVMs: true, + monitorContainers: true, + monitorStorage: true, + monitorBackups: true, + monitorPhysicalDisks: false, + physicalDiskPollingMinutes: 5, + monitorDatastores: true, + monitorSyncJobs: true, + monitorVerifyJobs: true, + monitorPruneJobs: true, + monitorGarbageJobs: true, + monitorMailStats: true, + monitorQueues: true, + monitorQuarantine: true, + monitorDomainStats: false, + clusterEndpointOverrides: {}, + }); + }); + + it('returns the complete canonical pmg form shape with both pmg-ternary arms pinned', () => { + // pmg is the only input that takes the true arm of both + // `nodeType === 'pmg'` ternaries (authType 'password', setupMode + // 'manual'); this full-shape assertion fires both arms in one go. + expect(getNodeModalDefaultFormData('pmg')).toStrictEqual({ + name: '', + host: '', + guestURL: '', + authType: 'password', + setupMode: 'manual', + user: '', + password: '', + tokenName: '', + tokenValue: '', + fingerprint: '', + verifySSL: true, + monitorVMs: true, + monitorContainers: true, + monitorStorage: true, + monitorBackups: true, + monitorPhysicalDisks: false, + physicalDiskPollingMinutes: 5, + monitorDatastores: true, + monitorSyncJobs: true, + monitorVerifyJobs: true, + monitorPruneJobs: true, + monitorGarbageJobs: true, + monitorMailStats: true, + monitorQueues: true, + monitorQuarantine: true, + monitorDomainStats: false, + clusterEndpointOverrides: {}, + }); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/swarmPresentation.branchcov0718.test.ts b/frontend-modern/src/utils/__tests__/swarmPresentation.branchcov0718.test.ts new file mode 100644 index 000000000..9f4961590 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/swarmPresentation.branchcov0718.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from 'vitest'; +import { + // Vocabulary constants — the sibling test never imports these directly; + // each `export const` is its own coverage statement, so pinning them + // guards against silent renames and exercises the per-symbol getter + // coverage reports flag as "get". + SWARM_DRAWER_TITLE, + SWARM_DRAWER_SEARCH_PLACEHOLDER, + SWARM_DRAWER_NO_CLUSTER_LABEL, + SWARM_DRAWER_CLUSTER_PREFIX, + SWARM_DRAWER_CLUSTER_ID_PREFIX, + SWARM_DRAWER_ROLE_PREFIX, + SWARM_DRAWER_STATE_PREFIX, + SWARM_DRAWER_CONTROL_PREFIX, + SWARM_DRAWER_CONTROL_AVAILABLE_LABEL, + SWARM_DRAWER_CONTROL_UNAVAILABLE_LABEL, + SWARM_DRAWER_COLUMN_SERVICE_LABEL, + SWARM_DRAWER_COLUMN_STACK_LABEL, + SWARM_DRAWER_COLUMN_IMAGE_LABEL, + SWARM_DRAWER_COLUMN_MODE_LABEL, + SWARM_DRAWER_COLUMN_DESIRED_LABEL, + SWARM_DRAWER_COLUMN_RUNNING_LABEL, + SWARM_DRAWER_COLUMN_UPDATE_LABEL, + SWARM_DRAWER_COLUMN_PORTS_LABEL, + formatSwarmClusterId, + formatSwarmClusterSummary, + formatSwarmControlLabel, + formatSwarmRoleLabel, + formatSwarmStateLabel, + getSwarmDrawerPresentation, +} from '../swarmPresentation'; + +// Residual branch-coverage probes for the swarm-presentation module. +// The sibling test (swarmPresentation.test.ts) already exercises: +// - getSwarmDrawerPresentation() via a single bulk toEqual, +// - the happy trim-truthy arm + the empty-string falsy arm of every +// formatSwarm* helper, +// - the boolean true/false arms and the null arm of +// formatSwarmControlLabel, +// - both arms of getSwarmServicesEmptyState and the fixed return of +// getSwarmServicesLoadingState. +// This file targets the residual: +// (a) every exported SWARM_DRAWER_* constant imported and pinned, so the +// per-symbol getter the coverage tool reports is exercised for each, +// (b) every field of getSwarmDrawerPresentation() read individually (the +// bulk toEqual in the sibling test reads the object shape but does +// not register a dedicated property-get coverage hit per field), +// (c) the null / undefined / whitespace-only / surrounding-whitespace +// input variants of every formatSwarm* helper — these all funnel +// through the `|| ''` coalesce + `.trim()` pipeline but are never +// exercised by the sibling test, +// (d) the non-boolean primitive arm of formatSwarmControlLabel's +// typeof guard (sibling test only passes null, which is one +// specific non-boolean; the typeof check also rejects undefined, +// strings, and numbers). + +describe('swarmPresentation.branchcov0718', () => { + describe('exported vocabulary constants', () => { + // Each constant below is its own coverage statement; importing and + // asserting the literal pins the canonical UI copy and protects + // against silent renames. + it('exposes the drawer title and search placeholder', () => { + expect(SWARM_DRAWER_TITLE).toBe('Swarm'); + expect(SWARM_DRAWER_SEARCH_PLACEHOLDER).toBe('Search services...'); + }); + + it('exposes the no-cluster and cluster-prefix vocabulary', () => { + expect(SWARM_DRAWER_NO_CLUSTER_LABEL).toBe('No Swarm cluster detected'); + expect(SWARM_DRAWER_CLUSTER_PREFIX).toBe('Cluster:'); + expect(SWARM_DRAWER_CLUSTER_ID_PREFIX).toBe('Cluster ID:'); + }); + + it('exposes the role / state / control prefix vocabulary', () => { + expect(SWARM_DRAWER_ROLE_PREFIX).toBe('Role:'); + expect(SWARM_DRAWER_STATE_PREFIX).toBe('State:'); + expect(SWARM_DRAWER_CONTROL_PREFIX).toBe('Control:'); + }); + + it('exposes the control availability status vocabulary', () => { + expect(SWARM_DRAWER_CONTROL_AVAILABLE_LABEL).toBe('available'); + expect(SWARM_DRAWER_CONTROL_UNAVAILABLE_LABEL).toBe('unavailable'); + }); + + it('exposes every service-table column label', () => { + expect(SWARM_DRAWER_COLUMN_SERVICE_LABEL).toBe('Service'); + expect(SWARM_DRAWER_COLUMN_STACK_LABEL).toBe('Stack'); + expect(SWARM_DRAWER_COLUMN_IMAGE_LABEL).toBe('Image'); + expect(SWARM_DRAWER_COLUMN_MODE_LABEL).toBe('Mode'); + expect(SWARM_DRAWER_COLUMN_DESIRED_LABEL).toBe('Desired'); + expect(SWARM_DRAWER_COLUMN_RUNNING_LABEL).toBe('Running'); + expect(SWARM_DRAWER_COLUMN_UPDATE_LABEL).toBe('Update'); + expect(SWARM_DRAWER_COLUMN_PORTS_LABEL).toBe('Ports'); + }); + }); + + describe('getSwarmDrawerPresentation — per-field property gets', () => { + // The sibling test asserts the whole shape via toEqual; reading each + // field individually registers a dedicated property-get coverage hit + // and guards against the wrapper drifting away from any single + // underlying constant. + const presentation = getSwarmDrawerPresentation(); + + it('wires title and searchPlaceholder fields', () => { + expect(presentation.title).toBe(SWARM_DRAWER_TITLE); + expect(presentation.title).toBe('Swarm'); + expect(presentation.searchPlaceholder).toBe(SWARM_DRAWER_SEARCH_PLACEHOLDER); + expect(presentation.searchPlaceholder).toBe('Search services...'); + }); + + it('wires noClusterLabel and the cluster/cluster-id prefixes', () => { + expect(presentation.noClusterLabel).toBe(SWARM_DRAWER_NO_CLUSTER_LABEL); + expect(presentation.noClusterLabel).toBe('No Swarm cluster detected'); + expect(presentation.clusterPrefix).toBe(SWARM_DRAWER_CLUSTER_PREFIX); + expect(presentation.clusterPrefix).toBe('Cluster:'); + expect(presentation.clusterIdPrefix).toBe(SWARM_DRAWER_CLUSTER_ID_PREFIX); + expect(presentation.clusterIdPrefix).toBe('Cluster ID:'); + }); + + it('wires the role / state / control prefixes', () => { + expect(presentation.rolePrefix).toBe(SWARM_DRAWER_ROLE_PREFIX); + expect(presentation.rolePrefix).toBe('Role:'); + expect(presentation.statePrefix).toBe(SWARM_DRAWER_STATE_PREFIX); + expect(presentation.statePrefix).toBe('State:'); + expect(presentation.controlPrefix).toBe(SWARM_DRAWER_CONTROL_PREFIX); + expect(presentation.controlPrefix).toBe('Control:'); + }); + + it('wires the control availability status labels', () => { + expect(presentation.controlAvailableLabel).toBe( + SWARM_DRAWER_CONTROL_AVAILABLE_LABEL, + ); + expect(presentation.controlAvailableLabel).toBe('available'); + expect(presentation.controlUnavailableLabel).toBe( + SWARM_DRAWER_CONTROL_UNAVAILABLE_LABEL, + ); + expect(presentation.controlUnavailableLabel).toBe('unavailable'); + }); + + it('wires every service-table column label field', () => { + expect(presentation.serviceColumnLabel).toBe(SWARM_DRAWER_COLUMN_SERVICE_LABEL); + expect(presentation.serviceColumnLabel).toBe('Service'); + expect(presentation.stackColumnLabel).toBe(SWARM_DRAWER_COLUMN_STACK_LABEL); + expect(presentation.stackColumnLabel).toBe('Stack'); + expect(presentation.imageColumnLabel).toBe(SWARM_DRAWER_COLUMN_IMAGE_LABEL); + expect(presentation.imageColumnLabel).toBe('Image'); + expect(presentation.modeColumnLabel).toBe(SWARM_DRAWER_COLUMN_MODE_LABEL); + expect(presentation.modeColumnLabel).toBe('Mode'); + expect(presentation.desiredColumnLabel).toBe(SWARM_DRAWER_COLUMN_DESIRED_LABEL); + expect(presentation.desiredColumnLabel).toBe('Desired'); + expect(presentation.runningColumnLabel).toBe(SWARM_DRAWER_COLUMN_RUNNING_LABEL); + expect(presentation.runningColumnLabel).toBe('Running'); + expect(presentation.updateColumnLabel).toBe(SWARM_DRAWER_COLUMN_UPDATE_LABEL); + expect(presentation.updateColumnLabel).toBe('Update'); + expect(presentation.portsColumnLabel).toBe(SWARM_DRAWER_COLUMN_PORTS_LABEL); + expect(presentation.portsColumnLabel).toBe('Ports'); + }); + }); + + describe('formatSwarmClusterSummary — coalesce + trim edge variants', () => { + // Sibling test passes 'Prod' (truthy post-trim) and '' (falsy post-trim). + // Residual: the `clusterName || ''` coalesce arm (null/undefined inputs), + // the whitespace-only post-trim falsy arm, and the surrounding-whitespace + // post-trim truthy arm. + it('returns the no-cluster label for null/undefined', () => { + expect(formatSwarmClusterSummary(null)).toBe(SWARM_DRAWER_NO_CLUSTER_LABEL); + expect(formatSwarmClusterSummary(undefined)).toBe(SWARM_DRAWER_NO_CLUSTER_LABEL); + }); + + it('returns the no-cluster label for whitespace-only input', () => { + expect(formatSwarmClusterSummary(' ')).toBe(SWARM_DRAWER_NO_CLUSTER_LABEL); + expect(formatSwarmClusterSummary('\t\n')).toBe(SWARM_DRAWER_NO_CLUSTER_LABEL); + }); + + it('trims surrounding whitespace before joining with the prefix', () => { + expect(formatSwarmClusterSummary(' Prod ')).toBe('Cluster: Prod'); + expect(formatSwarmClusterSummary('\tStaging\n')).toBe('Cluster: Staging'); + }); + }); + + describe('formatSwarmClusterId — coalesce + trim edge variants', () => { + it('returns empty string for null/undefined', () => { + expect(formatSwarmClusterId(null)).toBe(''); + expect(formatSwarmClusterId(undefined)).toBe(''); + }); + + it('returns empty string for whitespace-only input', () => { + expect(formatSwarmClusterId(' ')).toBe(''); + expect(formatSwarmClusterId('\t\n')).toBe(''); + }); + + it('trims surrounding whitespace before joining with the prefix', () => { + expect(formatSwarmClusterId(' abc123 ')).toBe('Cluster ID: abc123'); + expect(formatSwarmClusterId('\tdef456\n')).toBe('Cluster ID: def456'); + }); + }); + + describe('formatSwarmRoleLabel — coalesce + trim edge variants', () => { + it('returns empty string for null/undefined', () => { + expect(formatSwarmRoleLabel(null)).toBe(''); + expect(formatSwarmRoleLabel(undefined)).toBe(''); + }); + + it('returns empty string for whitespace-only input', () => { + expect(formatSwarmRoleLabel(' ')).toBe(''); + expect(formatSwarmRoleLabel('\t\n')).toBe(''); + }); + + it('trims surrounding whitespace before joining with the prefix', () => { + expect(formatSwarmRoleLabel(' manager ')).toBe('Role: manager'); + expect(formatSwarmRoleLabel('\tworker\n')).toBe('Role: worker'); + }); + }); + + describe('formatSwarmStateLabel — coalesce + trim edge variants', () => { + it('returns empty string for null/undefined', () => { + expect(formatSwarmStateLabel(null)).toBe(''); + expect(formatSwarmStateLabel(undefined)).toBe(''); + }); + + it('returns empty string for whitespace-only input', () => { + expect(formatSwarmStateLabel(' ')).toBe(''); + expect(formatSwarmStateLabel('\t\n')).toBe(''); + }); + + it('trims surrounding whitespace before joining with the prefix', () => { + expect(formatSwarmStateLabel(' active ')).toBe('State: active'); + expect(formatSwarmStateLabel('\tpaused\n')).toBe('State: paused'); + }); + }); + + describe('formatSwarmControlLabel — typeof guard residual arms', () => { + // Sibling test passes true/false (the boolean arms of the ternary) and + // null (one specific non-boolean). The typeof guard also rejects + // undefined, strings, and numbers — exercise each of those to take + // every arm the guard offers. + it('returns empty string for undefined', () => { + expect(formatSwarmControlLabel(undefined)).toBe(''); + }); + + it('returns empty string for non-boolean primitive inputs', () => { + const stringInput = 'true' as unknown as Parameters[0]; + const numberInput = 1 as unknown as Parameters[0]; + const objectInput = {} as unknown as Parameters[0]; + expect(formatSwarmControlLabel(stringInput)).toBe(''); + expect(formatSwarmControlLabel(numberInput)).toBe(''); + expect(formatSwarmControlLabel(objectInput)).toBe(''); + }); + + it('still returns the canonical copy for genuine boolean inputs', () => { + // Guard against an over-eager typeof change: the boolean path must + // still interpolate the canonical available/unavailable labels. + expect(formatSwarmControlLabel(true)).toBe( + `${SWARM_DRAWER_CONTROL_PREFIX} ${SWARM_DRAWER_CONTROL_AVAILABLE_LABEL}`, + ); + expect(formatSwarmControlLabel(false)).toBe( + `${SWARM_DRAWER_CONTROL_PREFIX} ${SWARM_DRAWER_CONTROL_UNAVAILABLE_LABEL}`, + ); + }); + }); +});