/** * Tests for ExposeSettings (M5-T12). * * Strategy: vi.mock the apiClient so GET/PUT/POST responses are controlled * without a real server or MQTT broker. * * Coverage: * 1. Loading state shows a loader. * 2. Error state shows an error alert. * 3. Empty catalog shows the empty-state message. * 4. Catalog with entities: renders entity names, device groups, toggle switches. * 5. MQTT status badges render correctly (configured/connected/discovery). * 6. Toggling a switch calls PUT /api/expose with the correct key/bool. * 7. Re-publish button calls POST /api/expose/republish. * 8. Republish ok=true shows success alert; ok=false shows warning. * 9. value_getter is never rendered (no crash from non-serialisable callable). * 10. binary_sensor entities are shown in the catalog alongside sensors. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { screen, waitFor, fireEvent } from '@testing-library/react' import { renderWithProviders } from '../test-utils' import { ExposeSettings } from './ExposeSettings' // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const MOCK_MQTT_STATUS_OFF = { mqtt_configured: false, mqtt_connected: false, discovery_enabled: false, } const MOCK_MQTT_STATUS_ON = { mqtt_configured: true, mqtt_connected: true, discovery_enabled: true, } const MOCK_CATALOG_EMPTY = { catalog: [], mqtt_status: MOCK_MQTT_STATUS_OFF, } const MOCK_CATALOG_ONE_DEVICE = { catalog: [ { entity: { key: 'modbus.abc.voltage', component: 'sensor', device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' }, device_class: 'voltage', unit: 'V', name: 'Test Meter Voltage', state_class: 'measurement', }, enabled: false, }, { entity: { key: 'modbus.abc.current', component: 'sensor', device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' }, device_class: 'current', unit: 'A', name: 'Test Meter Current', state_class: 'measurement', }, enabled: true, }, { entity: { key: 'modbus.abc.online', component: 'binary_sensor', device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' }, device_class: 'connectivity', unit: '', name: 'Test Meter Online', state_class: null, }, enabled: false, }, ], mqtt_status: MOCK_MQTT_STATUS_ON, } // --------------------------------------------------------------------------- // Mock apiClient // --------------------------------------------------------------------------- const mockGet = vi.fn() const mockPut = vi.fn() const mockPost = vi.fn() vi.mock('../api/client', () => ({ default: { GET: (...args: unknown[]) => mockGet(...args), PUT: (...args: unknown[]) => mockPut(...args), POST: (...args: unknown[]) => mockPost(...args), }, ApiError: class ApiError extends Error { status: number body: unknown constructor(status: number, body: unknown) { super(`API error ${status}`) this.name = 'ApiError' this.status = status this.body = body } }, registerLoginRedirect: vi.fn(), })) // --------------------------------------------------------------------------- // Helper // --------------------------------------------------------------------------- function renderExpose() { return renderWithProviders(, { initialPath: '/config' }) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('ExposeSettings', () => { beforeEach(() => { vi.clearAllMocks() }) // ------------------------------------------------------------------------- // 1. Loading state // ------------------------------------------------------------------------- it('shows a loader while the catalog is loading', () => { // Never resolves mockGet.mockReturnValue(new Promise(() => {})) renderExpose() expect(screen.getByTestId('expose-loading')).toBeInTheDocument() }) // ------------------------------------------------------------------------- // 2. Error state // ------------------------------------------------------------------------- it('shows an error alert when the catalog request fails', async () => { mockGet.mockRejectedValue(new Error('Network error')) renderExpose() await waitFor(() => { expect(screen.getByTestId('expose-load-error')).toBeInTheDocument() }) }) // ------------------------------------------------------------------------- // 3. Empty catalog // ------------------------------------------------------------------------- it('shows empty-state message when catalog is empty', async () => { mockGet.mockResolvedValue({ data: MOCK_CATALOG_EMPTY }) renderExpose() await waitFor(() => { expect(screen.getByTestId('expose-empty')).toBeInTheDocument() }) }) // ------------------------------------------------------------------------- // 4. Catalog with entities // ------------------------------------------------------------------------- it('renders entity names and device groups', async () => { mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE }) renderExpose() await waitFor(() => { expect(screen.getByTestId('expose-settings')).toBeInTheDocument() }) // Device group heading expect(screen.getByTestId('device-group-Test Meter')).toBeInTheDocument() // Entity names expect(screen.getByText('Test Meter Voltage')).toBeInTheDocument() expect(screen.getByText('Test Meter Current')).toBeInTheDocument() expect(screen.getByText('Test Meter Online')).toBeInTheDocument() }) it('renders toggle switches for each entity', async () => { mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE }) renderExpose() await waitFor(() => { expect(screen.getByTestId('entity-toggle-modbus.abc.voltage')).toBeInTheDocument() }) // voltage is disabled (enabled=false in fixture) const voltageSwitch = screen.getByTestId( 'entity-toggle-modbus.abc.voltage', ) as HTMLInputElement expect(voltageSwitch.checked).toBe(false) // current is enabled (enabled=true in fixture) const currentSwitch = screen.getByTestId( 'entity-toggle-modbus.abc.current', ) as HTMLInputElement expect(currentSwitch.checked).toBe(true) }) it('shows binary_sensor entities alongside sensor entities', async () => { mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE }) renderExpose() await waitFor(() => { expect(screen.getByTestId('entity-row-modbus.abc.online')).toBeInTheDocument() }) // binary_sensor row contains "binary_sensor" text const onlineRow = screen.getByTestId('entity-row-modbus.abc.online') expect(onlineRow).toHaveTextContent('binary_sensor') }) // ------------------------------------------------------------------------- // 5. MQTT status badges // ------------------------------------------------------------------------- it('renders MQTT status badges', async () => { mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE }) renderExpose() await waitFor(() => { expect(screen.getByTestId('mqtt-status-badges')).toBeInTheDocument() }) expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument() expect(screen.getByTestId('badge-mqtt-connected')).toBeInTheDocument() expect(screen.getByTestId('badge-discovery-enabled')).toBeInTheDocument() }) it('shows "Configured" when mqtt_configured is true', async () => { mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE }) renderExpose() await waitFor(() => { expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument() }) expect(screen.getByTestId('badge-mqtt-configured')).toHaveTextContent('MQTT Configured') }) it('shows "Not Configured" when mqtt_configured is false', async () => { mockGet.mockResolvedValue({ data: MOCK_CATALOG_EMPTY }) renderExpose() await waitFor(() => { expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument() }) expect(screen.getByTestId('badge-mqtt-configured')).toHaveTextContent('MQTT Not Configured') }) // ------------------------------------------------------------------------- // 6. Toggle a switch → PUT /api/expose // ------------------------------------------------------------------------- it('calls PUT /api/expose when a toggle is switched', async () => { mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE }) mockPut.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE }) renderExpose() await waitFor(() => { expect(screen.getByTestId('entity-toggle-modbus.abc.voltage')).toBeInTheDocument() }) // Click the voltage toggle (currently off → turning on) const voltageSwitch = screen.getByTestId('entity-toggle-modbus.abc.voltage') fireEvent.click(voltageSwitch) await waitFor(() => { expect(mockPut).toHaveBeenCalled() }) const putCall = mockPut.mock.calls[0] // path arg expect(putCall[0]).toBe('/api/expose') // body expect(putCall[1]?.body?.toggles).toMatchObject({ 'modbus.abc.voltage': true }) }) // ------------------------------------------------------------------------- // 7. Republish button → POST /api/expose/republish // ------------------------------------------------------------------------- it('calls POST /api/expose/republish when republish button is clicked', async () => { mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE }) mockPost.mockResolvedValue({ data: { ok: true, message: 'HA Discovery re-published.' } }) renderExpose() await waitFor(() => { expect(screen.getByTestId('republish-button')).toBeInTheDocument() }) fireEvent.click(screen.getByTestId('republish-button')) await waitFor(() => { expect(mockPost).toHaveBeenCalled() }) const postCall = mockPost.mock.calls[0] expect(postCall[0]).toBe('/api/expose/republish') }) // ------------------------------------------------------------------------- // 8. Republish ok=true → success alert; ok=false → warning // ------------------------------------------------------------------------- it('shows success alert when republish returns ok=true', async () => { mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE }) mockPost.mockResolvedValue({ data: { ok: true, message: 'HA Discovery re-published successfully.' }, }) renderExpose() await waitFor(() => { expect(screen.getByTestId('republish-button')).toBeInTheDocument() }) fireEvent.click(screen.getByTestId('republish-button')) await waitFor(() => { expect(screen.getByTestId('republish-success')).toBeInTheDocument() }) expect(screen.queryByTestId('republish-warning')).not.toBeInTheDocument() }) it('shows warning alert when republish returns ok=false', async () => { mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE }) mockPost.mockResolvedValue({ data: { ok: false, message: 'MQTT not connected.' }, }) renderExpose() await waitFor(() => { expect(screen.getByTestId('republish-button')).toBeInTheDocument() }) fireEvent.click(screen.getByTestId('republish-button')) await waitFor(() => { expect(screen.getByTestId('republish-warning')).toBeInTheDocument() }) expect(screen.queryByTestId('republish-success')).not.toBeInTheDocument() }) // ------------------------------------------------------------------------- // 9. No crash from non-serialisable value_getter (value_getter NOT rendered) // ------------------------------------------------------------------------- it('renders without crashing even if value_getter were present (it is excluded by API)', async () => { // The API never sends value_getter; this tests resilience of the schema const catalogWithExtraField = { ...MOCK_CATALOG_ONE_DEVICE, catalog: MOCK_CATALOG_ONE_DEVICE.catalog.map((entry) => ({ ...entry, entity: { ...entry.entity }, // No value_getter — confirm the component handles normal data })), } mockGet.mockResolvedValue({ data: catalogWithExtraField }) renderExpose() await waitFor(() => { expect(screen.getByTestId('expose-settings')).toBeInTheDocument() }) // No errors thrown, entities visible expect(screen.getByText('Test Meter Voltage')).toBeInTheDocument() }) })