import { describe, it, expect, beforeEach, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; const useConfigurationStatusMock = vi.fn(); vi.mock('../useConfigurationStatus', () => ({ useConfigurationStatus: () => useConfigurationStatusMock(), })); import { ConfigurationStatus } from '../ConfigurationStatus'; import type { ConfigurationStatus as ConfigurationStatusPayload } from '../useConfigurationStatus'; function makePayload(overrides: Partial = {}): ConfigurationStatusPayload { return { tier: 'community', notifications: { agents: { discord: { configured: false, enabled: false }, slack: { configured: false, enabled: false }, webhook: { configured: false, enabled: false }, apprise: { configured: false, enabled: false }, ntfy: { configured: false, enabled: false }, }, alertRules: 0, routingRules: { count: 0, enabledCount: 0, locked: true }, suppressionRules: { total: 0, enabledCount: 0 }, }, automation: { autoHeal: { total: 0, enabled: 0 }, autoUpdate: { enabled: 0, total: 0 }, scheduledTasks: { total: 0, enabled: 0, locked: true }, webhooks: { total: 0, enabled: 0, locked: true }, }, security: { mfaEnabled: null, ssoEnabled: false, ssoProvider: null, trivyInstalled: false, scanPolicies: { total: 0, enabled: 0, locked: false }, }, thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false, hostAlertsEnabled: true }, backup: { provider: 'disabled', autoUpload: false, locked: false }, ...overrides, }; } beforeEach(() => { useConfigurationStatusMock.mockReset(); }); describe('ConfigurationStatus row visibility', () => { it('renders a skeleton while loading', () => { useConfigurationStatusMock.mockReturnValue({ status: null, loading: true }); render(); expect(screen.getByText('Configuration Status')).toBeDefined(); // Skeleton renders placeholder rows; assert the load-error message is NOT shown. expect(screen.queryByText(/Unable to load configuration/i)).toBeNull(); }); it('renders an error state when the payload is null and not loading', () => { useConfigurationStatusMock.mockReturnValue({ status: null, loading: false }); render(); expect(screen.getByText(/Unable to load configuration/i)).toBeDefined(); }); it('always shows the Automation section and its free rows, hiding only the per-row locked entries', () => { useConfigurationStatusMock.mockReturnValue({ status: makePayload(), loading: false }); render(); // Automation moved to free: the section and its auto-heal / auto-update // rows render for every tier. expect(screen.getByText('Automation')).toBeDefined(); expect(screen.getByText('Auto-heal policies')).toBeDefined(); expect(screen.getByText('Auto-update schedules')).toBeDefined(); // Rows whose payload reports locked stay hidden. expect(screen.queryByText('Routing')).toBeNull(); expect(screen.queryByText('Webhooks')).toBeNull(); expect(screen.queryByText('Scheduled tasks')).toBeNull(); // Scan policies are free, so the row renders. expect(screen.getByText('Scan policies')).toBeDefined(); // Recovery Vault row is universal because Custom S3 is open to every tier. expect(screen.getByText('Recovery Vault')).toBeDefined(); }); it('shows every row when the payload reports nothing locked', () => { useConfigurationStatusMock.mockReturnValue({ status: makePayload({ tier: 'paid', notifications: { agents: { discord: { configured: false, enabled: false }, slack: { configured: false, enabled: false }, webhook: { configured: false, enabled: false }, apprise: { configured: false, enabled: false }, ntfy: { configured: false, enabled: false }, }, alertRules: 2, routingRules: { count: 1, enabledCount: 1, locked: false }, suppressionRules: { total: 0, enabledCount: 0 }, }, automation: { autoHeal: { total: 3, enabled: 2 }, autoUpdate: { enabled: 4, total: 5 }, scheduledTasks: { total: 1, enabled: 1, locked: false }, webhooks: { total: 1, enabled: 1, locked: false }, }, security: { mfaEnabled: true, ssoEnabled: true, ssoProvider: 'oidc_google', trivyInstalled: true, scanPolicies: { total: 2, enabled: 2, locked: false }, }, }), loading: false, }); render(); expect(screen.getByText('Automation')).toBeDefined(); expect(screen.getByText('Auto-heal policies')).toBeDefined(); expect(screen.getByText('Auto-update schedules')).toBeDefined(); expect(screen.getByText('Routing')).toBeDefined(); expect(screen.getByText('Webhooks')).toBeDefined(); expect(screen.getByText('Scheduled tasks')).toBeDefined(); expect(screen.getByText('Scan policies')).toBeDefined(); expect(screen.getByText('Recovery Vault')).toBeDefined(); // SSO label maps the provider to a friendly name. expect(screen.getByText('Google')).toBeDefined(); }); }); describe('ConfigurationStatus threshold display', () => { it('renders threshold values when hostAlertsEnabled is true', () => { useConfigurationStatusMock.mockReturnValue({ status: makePayload({ thresholds: { cpuLimit: 80, ramLimit: 85, diskLimit: 90, dockerJanitorGb: 5, globalCrash: true, hostAlertsEnabled: true } }), loading: false, }); render(); expect(screen.getByText('CPU 80% · RAM 85% · Disk 90%')).toBeDefined(); }); it('renders OFF badge when hostAlertsEnabled is false', () => { useConfigurationStatusMock.mockReturnValue({ status: makePayload({ thresholds: { cpuLimit: 80, ramLimit: 85, diskLimit: 90, dockerJanitorGb: 5, globalCrash: true, hostAlertsEnabled: false }, }), loading: false, }); render(); // StatusBadge uppercases 'Off' to 'OFF'. Since backup.provider is // 'disabled' (also rendered as OFF), there are two OFF badges. // Verify the Alert thresholds row specifically shows OFF. const thresholdRow = screen.getByText('Alert thresholds').closest('button'); expect(thresholdRow).toBeDefined(); // The OFF badge is the span inside the row that has font-mono + uppercase. const badge = thresholdRow!.querySelector('.font-mono'); expect(badge?.textContent?.trim()).toBe('OFF'); }); }); describe('ConfigurationStatus click targets', () => { it('routes Crash detection to container-alerts', () => { const onOpenSection = vi.fn(); useConfigurationStatusMock.mockReturnValue({ status: makePayload(), loading: false, }); render(); const crashRow = screen.getByText('Crash detection').closest('button'); expect(crashRow).toBeDefined(); fireEvent.click(crashRow!); expect(onOpenSection).toHaveBeenCalledWith('container-alerts'); }); }); describe('ConfigurationStatus legacy remote agents', () => { it('renders a three-channel payload that omits apprise without throwing', () => { const legacy = makePayload(); // Simulate older remote contract: no apprise key on the wire. delete (legacy.notifications.agents as { apprise?: unknown }).apprise; useConfigurationStatusMock.mockReturnValue({ status: legacy, loading: false }); expect(() => render()).not.toThrow(); const channelsRow = screen.getByText('Channels').closest('button'); expect(channelsRow?.textContent).toContain('None'); }); it('summarizes enabled legacy channels without requiring apprise', () => { const legacy = makePayload({ notifications: { agents: { discord: { configured: true, enabled: true }, slack: { configured: false, enabled: false }, webhook: { configured: false, enabled: false }, } as ConfigurationStatusPayload['notifications']['agents'], alertRules: 0, routingRules: { count: 0, enabledCount: 0, locked: true }, suppressionRules: { total: 0, enabledCount: 0 }, }, }); useConfigurationStatusMock.mockReturnValue({ status: legacy, loading: false }); render(); expect(screen.getByText('Discord')).toBeDefined(); }); it('renders a payload that omits ntfy without throwing', () => { const legacy = makePayload(); delete (legacy.notifications.agents as { ntfy?: unknown }).ntfy; useConfigurationStatusMock.mockReturnValue({ status: legacy, loading: false }); expect(() => render()).not.toThrow(); const channelsRow = screen.getByText('Channels').closest('button'); expect(channelsRow?.textContent).toContain('None'); }); });