import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { SettingsModal } from '../components/SettingsModal'; import type { AppSettings, AppSettingsUpdate, HealthStatus, RadioConfig, RadioConfigUpdate, StatisticsResponse, } from '../types'; import type { SettingsSection } from '../components/settingsConstants'; import { LAST_VIEWED_CONVERSATION_KEY, REOPEN_LAST_CONVERSATION_KEY, } from '../utils/lastViewedConversation'; import { api } from '../api'; const baseConfig: RadioConfig = { public_key: 'aa'.repeat(32), name: 'TestNode', lat: 1, lon: 2, tx_power: 17, max_tx_power: 22, radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5, }, }; const baseHealth: HealthStatus = { status: 'connected', radio_connected: true, connection_info: 'Serial: /dev/ttyUSB0', database_size_mb: 1.2, oldest_undecrypted_timestamp: null, mqtt_status: null, }; const baseSettings: AppSettings = { max_radio_contacts: 200, favorites: [], auto_decrypt_dm_on_advert: false, sidebar_sort_order: 'recent', last_message_times: {}, preferences_migrated: false, advert_interval: 0, last_advert_time: 0, bots: [], mqtt_broker_host: '', mqtt_broker_port: 1883, mqtt_username: '', mqtt_password: '', mqtt_use_tls: false, mqtt_tls_insecure: false, mqtt_topic_prefix: 'meshcore', mqtt_publish_messages: false, mqtt_publish_raw_packets: false, }; function renderModal(overrides?: { appSettings?: AppSettings; health?: HealthStatus; onSaveAppSettings?: (update: AppSettingsUpdate) => Promise; onRefreshAppSettings?: () => Promise; onSave?: (update: RadioConfigUpdate) => Promise; onClose?: () => void; onSetPrivateKey?: (key: string) => Promise; onReboot?: () => Promise; open?: boolean; pageMode?: boolean; externalSidebarNav?: boolean; desktopSection?: SettingsSection; mobile?: boolean; }) { setMatchMedia(overrides?.mobile ?? false); const onSaveAppSettings = overrides?.onSaveAppSettings ?? vi.fn(async () => {}); const onRefreshAppSettings = overrides?.onRefreshAppSettings ?? vi.fn(async () => {}); const onSave = overrides?.onSave ?? vi.fn(async (_update: RadioConfigUpdate) => {}); const onClose = overrides?.onClose ?? vi.fn(); const onSetPrivateKey = overrides?.onSetPrivateKey ?? vi.fn(async () => {}); const onReboot = overrides?.onReboot ?? vi.fn(async () => {}); const commonProps = { open: overrides?.open ?? true, pageMode: overrides?.pageMode, config: baseConfig, health: overrides?.health ?? baseHealth, appSettings: overrides?.appSettings ?? baseSettings, onClose, onSave, onSaveAppSettings, onSetPrivateKey, onReboot, onAdvertise: vi.fn(async () => {}), onHealthRefresh: vi.fn(async () => {}), onRefreshAppSettings, }; const view = overrides?.externalSidebarNav ? render( ) : render(); return { onSaveAppSettings, onRefreshAppSettings, onSave, onClose, onSetPrivateKey, onReboot, view, }; } function setMatchMedia(matches: boolean) { Object.defineProperty(window, 'matchMedia', { writable: true, value: vi.fn().mockImplementation(() => ({ matches, media: '(max-width: 767px)', onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), })), }); } function openConnectivitySection() { const connectivityToggle = screen.getByRole('button', { name: /Connectivity/i }); fireEvent.click(connectivityToggle); } function openMqttSection() { const mqttToggle = screen.getByRole('button', { name: /MQTT/i }); fireEvent.click(mqttToggle); } function openDatabaseSection() { const databaseToggle = screen.getByRole('button', { name: /Database/i }); fireEvent.click(databaseToggle); } describe('SettingsModal', () => { afterEach(() => { vi.restoreAllMocks(); localStorage.clear(); window.location.hash = ''; }); it('refreshes app settings when opened', async () => { const { onRefreshAppSettings } = renderModal(); await waitFor(() => { expect(onRefreshAppSettings).toHaveBeenCalledTimes(1); }); }); it('refreshes app settings in page mode even when open is false', async () => { const { onRefreshAppSettings } = renderModal({ open: false, pageMode: true }); await waitFor(() => { expect(onRefreshAppSettings).toHaveBeenCalledTimes(1); }); }); it('does not render when closed outside page mode', () => { renderModal({ open: false }); expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument(); }); it('shows favorite-first contact sync helper text in connectivity tab', async () => { renderModal(); openConnectivitySection(); expect( screen.getByText( /Favorite contacts load first, then recent non-repeater contacts until this\s+limit is reached/i ) ).toBeInTheDocument(); }); it('saves changed max contacts value through onSaveAppSettings', async () => { const { onSaveAppSettings } = renderModal(); openConnectivitySection(); const maxContactsInput = screen.getByLabelText('Max Contacts on Radio'); fireEvent.change(maxContactsInput, { target: { value: '250' } }); fireEvent.click(screen.getByRole('button', { name: 'Save Settings' })); await waitFor(() => { expect(onSaveAppSettings).toHaveBeenCalledWith({ max_radio_contacts: 250 }); }); }); it('does not save max contacts when unchanged', async () => { const { onSaveAppSettings } = renderModal({ appSettings: { ...baseSettings, max_radio_contacts: 200 }, }); openConnectivitySection(); fireEvent.click(screen.getByRole('button', { name: 'Save Settings' })); await waitFor(() => { expect(onSaveAppSettings).not.toHaveBeenCalled(); }); }); it('renders selected section from external sidebar nav on desktop mode', async () => { renderModal({ externalSidebarNav: true, desktopSection: 'bot', }); expect(screen.getByText('No bots configured')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Connectivity/i })).not.toBeInTheDocument(); expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument(); }); it('toggles sections in mobile accordion mode', () => { renderModal({ mobile: true }); const identityToggle = screen.getAllByRole('button', { name: /Identity/i })[0]; expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Public Key')).not.toBeInTheDocument(); fireEvent.click(identityToggle); expect(screen.getByLabelText('Public Key')).toBeInTheDocument(); fireEvent.click(identityToggle); expect(screen.queryByLabelText('Public Key')).not.toBeInTheDocument(); }); it('clears stale errors when switching external desktop sections', async () => { const onSaveAppSettings = vi.fn(async () => { throw new Error('Save failed'); }); const { view } = renderModal({ externalSidebarNav: true, desktopSection: 'database', onSaveAppSettings, }); fireEvent.click(screen.getByRole('button', { name: 'Save Settings' })); await waitFor(() => { expect(screen.getByText('Save failed')).toBeInTheDocument(); }); view.rerender( {})} onSaveAppSettings={onSaveAppSettings} onSetPrivateKey={vi.fn(async () => {})} onReboot={vi.fn(async () => {})} onAdvertise={vi.fn(async () => {})} onHealthRefresh={vi.fn(async () => {})} onRefreshAppSettings={vi.fn(async () => {})} /> ); expect(screen.queryByText('Save failed')).not.toBeInTheDocument(); }); it('does not call onClose after save/reboot flows in page mode', async () => { const onClose = vi.fn(); const onSave = vi.fn(async () => {}); const onSetPrivateKey = vi.fn(async () => {}); const onReboot = vi.fn(async () => {}); renderModal({ pageMode: true, onClose, onSave, onSetPrivateKey, onReboot, }); fireEvent.click(screen.getByRole('button', { name: 'Save Radio Config & Reboot' })); await waitFor(() => { expect(onSave).toHaveBeenCalledTimes(1); expect(onReboot).toHaveBeenCalledTimes(1); }); expect(onClose).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: /Identity/i })); fireEvent.change(screen.getByLabelText('Set Private Key (write-only)'), { target: { value: 'a'.repeat(64) }, }); fireEvent.click(screen.getByRole('button', { name: 'Set Private Key & Reboot' })); await waitFor(() => { expect(onSetPrivateKey).toHaveBeenCalledWith('a'.repeat(64)); expect(onReboot).toHaveBeenCalledTimes(2); }); expect(onClose).not.toHaveBeenCalled(); }); it('stores and clears reopen-last-conversation preference locally', () => { window.location.hash = '#raw'; renderModal(); openDatabaseSection(); const checkbox = screen.getByLabelText('Reopen to last viewed channel/conversation'); expect(checkbox).not.toBeChecked(); fireEvent.click(checkbox); expect(localStorage.getItem(REOPEN_LAST_CONVERSATION_KEY)).toBe('1'); expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toContain('"type":"raw"'); fireEvent.click(checkbox); expect(localStorage.getItem(REOPEN_LAST_CONVERSATION_KEY)).toBeNull(); expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toBeNull(); }); it('purges decrypted raw packets via maintenance endpoint action', async () => { const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({ packets_deleted: 12, vacuumed: true, }); renderModal(); openDatabaseSection(); fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' })); await waitFor(() => { expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true }); }); }); it('renders statistics section with fetched data', async () => { const mockStats: StatisticsResponse = { busiest_channels_24h: [ { channel_key: 'AA'.repeat(16), channel_name: 'general', message_count: 42 }, ], contact_count: 10, repeater_count: 3, channel_count: 5, total_packets: 200, decrypted_packets: 150, undecrypted_packets: 50, total_dms: 25, total_channel_messages: 80, total_outgoing: 30, contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 }, repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 }, }; vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(JSON.stringify(mockStats), { status: 200, headers: { 'Content-Type': 'application/json' }, }) ); renderModal({ externalSidebarNav: true, desktopSection: 'statistics', }); await waitFor(() => { expect(screen.getByText('Network')).toBeInTheDocument(); }); // Verify key labels are present expect(screen.getByText('Contacts')).toBeInTheDocument(); expect(screen.getByText('Repeaters')).toBeInTheDocument(); expect(screen.getByText('Direct Messages')).toBeInTheDocument(); expect(screen.getByText('Channel Messages')).toBeInTheDocument(); expect(screen.getByText('Sent (Outgoing)')).toBeInTheDocument(); expect(screen.getByText('Total stored')).toBeInTheDocument(); expect(screen.getByText('Decrypted')).toBeInTheDocument(); expect(screen.getByText('Undecrypted')).toBeInTheDocument(); expect(screen.getByText('Contacts heard')).toBeInTheDocument(); expect(screen.getByText('Repeaters heard')).toBeInTheDocument(); // Busiest channels expect(screen.getByText('general')).toBeInTheDocument(); expect(screen.getByText('42 msgs')).toBeInTheDocument(); }); it('renders MQTT section with form inputs', () => { renderModal(); openMqttSection(); expect(screen.getByLabelText('Broker Host')).toBeInTheDocument(); expect(screen.getByLabelText('Broker Port')).toBeInTheDocument(); expect(screen.getByLabelText('Username')).toBeInTheDocument(); expect(screen.getByLabelText('Password')).toBeInTheDocument(); expect(screen.getByLabelText('Topic Prefix')).toBeInTheDocument(); expect(screen.getByText('Publish Messages')).toBeInTheDocument(); expect(screen.getByText('Publish Raw Packets')).toBeInTheDocument(); }); it('saves MQTT settings through onSaveAppSettings', async () => { const { onSaveAppSettings } = renderModal(); openMqttSection(); const hostInput = screen.getByLabelText('Broker Host'); fireEvent.change(hostInput, { target: { value: 'mqtt.example.com' } }); fireEvent.click(screen.getByRole('button', { name: 'Save MQTT Settings' })); await waitFor(() => { expect(onSaveAppSettings).toHaveBeenCalledWith( expect.objectContaining({ mqtt_broker_host: 'mqtt.example.com', mqtt_broker_port: 1883, }) ); }); }); it('shows MQTT disabled status when mqtt_status is null', () => { renderModal({ appSettings: { ...baseSettings, mqtt_broker_host: 'broker.local', }, }); openMqttSection(); expect(screen.getByText('Disabled')).toBeInTheDocument(); }); it('shows MQTT connected status badge', () => { renderModal({ appSettings: { ...baseSettings, mqtt_broker_host: 'broker.local', }, health: { ...baseHealth, mqtt_status: 'connected', }, }); openMqttSection(); expect(screen.getByText('Connected')).toBeInTheDocument(); }); it('fetches statistics when expanded in mobile external-nav mode', async () => { const mockStats: StatisticsResponse = { busiest_channels_24h: [], contact_count: 10, repeater_count: 3, channel_count: 5, total_packets: 200, decrypted_packets: 150, undecrypted_packets: 50, total_dms: 25, total_channel_messages: 80, total_outgoing: 30, contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 }, repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 }, }; const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(JSON.stringify(mockStats), { status: 200, headers: { 'Content-Type': 'application/json' }, }) ); renderModal({ mobile: true, externalSidebarNav: true, desktopSection: 'radio', }); expect(fetchSpy).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: /Statistics/i })); await waitFor(() => { expect(fetchSpy).toHaveBeenCalledWith('/api/statistics', expect.any(Object)); }); await waitFor(() => { expect(screen.getByText('Network')).toBeInTheDocument(); }); }); });