mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Add branch-coverage tests for presentation and API-client helpers
Twelve pure modules had a hand-written test but left functions and conditional arms unexercised. These new branchcov tests drive the previously-uncovered inputs and assert real returned values and request shaping, taking each module's uncovered functions to zero. Presentation helpers alertDestinations, alertEmail, alertResourceTable, aiSettings, auditWebhook, nodeModal, record, swarm and k8sNamespace, plus API clients charts, discovery and truenas.
This commit is contained in:
@@ -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=<id> 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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+702
@@ -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> = {}): 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<string, unknown>,
|
||||
});
|
||||
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<string, unknown>,
|
||||
});
|
||||
expect(getStorageRecordZfsPool(record)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when details.zfsPool is a primitive (`typeof !== "object"` arm)', () => {
|
||||
const record = makeRecord({
|
||||
details: { zfsPool: 'DEGRADED' } as Record<string, unknown>,
|
||||
});
|
||||
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<string, unknown>,
|
||||
});
|
||||
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<string, unknown>,
|
||||
});
|
||||
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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+217
@@ -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/<key> 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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+139
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof formatSwarmControlLabel>[0];
|
||||
const numberInput = 1 as unknown as Parameters<typeof formatSwarmControlLabel>[0];
|
||||
const objectInput = {} as unknown as Parameters<typeof formatSwarmControlLabel>[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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user