From 875f1978126ecc4ff6fbb2be3b758a4910cff6a7 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 10 Feb 2026 16:47:46 -0800 Subject: [PATCH] Testing blitz! --- frontend/src/components/MessageList.tsx | 15 +- frontend/src/components/SettingsModal.tsx | 4 +- frontend/src/test/appFavorites.test.tsx | 246 +++++++++++++++++++ frontend/src/test/settingsModal.test.tsx | 123 ++++++++++ frontend/vitest.config.ts | 6 + tests/test_radio_router.py | 217 ++++++++++++++++ tests/test_repeater_routes.py | 286 ++++++++++++++++++++++ tests/test_repository.py | 97 ++++++++ tests/test_settings_router.py | 194 +++++++++++++++ 9 files changed, 1181 insertions(+), 7 deletions(-) create mode 100644 frontend/src/test/appFavorites.test.tsx create mode 100644 frontend/src/test/settingsModal.test.tsx create mode 100644 tests/test_radio_router.py create mode 100644 tests/test_repeater_routes.py create mode 100644 tests/test_settings_router.py diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 111009e..ffc4360 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -462,9 +462,10 @@ export function MessageList({ )} )} - {msg.outgoing && (msg.acked > 0 - ? msg.paths && msg.paths.length > 0 - ? 0 ? ( + msg.paths && msg.paths.length > 0 ? ( + { e.stopPropagation(); @@ -480,8 +481,12 @@ export function MessageList({ }} title="View echo paths" >{` ✓${msg.acked > 1 ? msg.acked : ''}`} - : ` ✓${msg.acked > 1 ? msg.acked : ''}` - : ' ?')} + ) : ( + ` ✓${msg.acked > 1 ? msg.acked : ''}` + ) + ) : ( + ' ?' + ))} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 67fb743..ee879b9 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -751,8 +751,8 @@ export function SettingsModal({ onChange={(e) => setMaxRadioContacts(e.target.value)} />

- Favorite contacts load first, then recent non-repeater contacts until this - limit is reached (1-1000) + Favorite contacts load first, then recent non-repeater contacts until this limit + is reached (1-1000)

diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx new file mode 100644 index 0000000..64cfc35 --- /dev/null +++ b/frontend/src/test/appFavorites.test.tsx @@ -0,0 +1,246 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + api: { + getRadioConfig: vi.fn(), + getSettings: vi.fn(), + getUndecryptedPacketCount: vi.fn(), + getChannels: vi.fn(), + getContacts: vi.fn(), + toggleFavorite: vi.fn(), + updateSettings: vi.fn(), + getHealth: vi.fn(), + sendAdvertisement: vi.fn(), + rebootRadio: vi.fn(), + createChannel: vi.fn(), + decryptHistoricalPackets: vi.fn(), + createContact: vi.fn(), + deleteChannel: vi.fn(), + deleteContact: vi.fn(), + sendChannelMessage: vi.fn(), + sendDirectMessage: vi.fn(), + requestTrace: vi.fn(), + updateRadioConfig: vi.fn(), + setPrivateKey: vi.fn(), + migratePreferences: vi.fn(), + }, + toast: { + success: vi.fn(), + error: vi.fn(), + }, + hookFns: { + setMessages: vi.fn(), + fetchMessages: vi.fn(async () => {}), + fetchOlderMessages: vi.fn(async () => {}), + addMessageIfNew: vi.fn(), + updateMessageAck: vi.fn(), + incrementUnread: vi.fn(), + markAllRead: vi.fn(), + trackNewMessage: vi.fn(), + handleTelemetryRequest: vi.fn(), + handleRepeaterCommand: vi.fn(), + }, +})); + +vi.mock('../api', () => ({ + api: mocks.api, +})); + +vi.mock('../useWebSocket', () => ({ + useWebSocket: vi.fn(), +})); + +vi.mock('../hooks', () => ({ + useConversationMessages: () => ({ + messages: [], + messagesLoading: false, + loadingOlder: false, + hasOlderMessages: false, + setMessages: mocks.hookFns.setMessages, + fetchMessages: mocks.hookFns.fetchMessages, + fetchOlderMessages: mocks.hookFns.fetchOlderMessages, + addMessageIfNew: mocks.hookFns.addMessageIfNew, + updateMessageAck: mocks.hookFns.updateMessageAck, + }), + useUnreadCounts: () => ({ + unreadCounts: {}, + mentions: {}, + lastMessageTimes: {}, + incrementUnread: mocks.hookFns.incrementUnread, + markAllRead: mocks.hookFns.markAllRead, + trackNewMessage: mocks.hookFns.trackNewMessage, + }), + useRepeaterMode: () => ({ + repeaterLoggedIn: false, + activeContactIsRepeater: false, + handleTelemetryRequest: mocks.hookFns.handleTelemetryRequest, + handleRepeaterCommand: mocks.hookFns.handleRepeaterCommand, + }), + getMessageContentKey: () => 'content-key', +})); + +vi.mock('../messageCache', () => ({ + addMessage: vi.fn(), + updateAck: vi.fn(), + remove: vi.fn(), +})); + +vi.mock('../components/StatusBar', () => ({ + StatusBar: () =>
, +})); + +vi.mock('../components/Sidebar', () => ({ + Sidebar: () =>
, +})); + +vi.mock('../components/MessageList', () => ({ + MessageList: () =>
, +})); + +vi.mock('../components/MessageInput', () => ({ + MessageInput: React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ appendText: vi.fn() })); + return
; + }), +})); + +vi.mock('../components/NewMessageModal', () => ({ + NewMessageModal: () => null, +})); + +vi.mock('../components/SettingsModal', () => ({ + SettingsModal: () => null, +})); + +vi.mock('../components/RawPacketList', () => ({ + RawPacketList: () => null, +})); + +vi.mock('../components/MapView', () => ({ + MapView: () => null, +})); + +vi.mock('../components/VisualizerView', () => ({ + VisualizerView: () => null, +})); + +vi.mock('../components/CrackerPanel', () => ({ + CrackerPanel: () => null, +})); + +vi.mock('../components/ui/sheet', () => ({ + Sheet: ({ children }: { children: React.ReactNode }) =>
{children}
, + SheetContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SheetHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + SheetTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock('../components/ui/sonner', () => ({ + Toaster: () => null, + toast: mocks.toast, +})); + +vi.mock('../utils/urlHash', () => ({ + parseHashConversation: () => null, + updateUrlHash: vi.fn(), + getMapFocusHash: () => '#map', +})); + +import { App } from '../App'; + +const baseConfig = { + public_key: 'aa'.repeat(32), + name: 'TestNode', + lat: 0, + lon: 0, + tx_power: 17, + max_tx_power: 22, + radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 }, +}; + +const baseSettings = { + max_radio_contacts: 200, + favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>, + auto_decrypt_dm_on_advert: false, + sidebar_sort_order: 'recent' as const, + last_message_times: {}, + preferences_migrated: false, + advert_interval: 0, + last_advert_time: 0, + bots: [], +}; + +const publicChannel = { + key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72', + name: 'Public', + is_hashtag: false, + on_radio: false, + last_read_at: null, +}; + +describe('App favorite toggle flow', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mocks.api.getRadioConfig.mockResolvedValue(baseConfig); + mocks.api.getSettings.mockResolvedValue({ ...baseSettings }); + mocks.api.getUndecryptedPacketCount.mockResolvedValue({ count: 0 }); + mocks.api.getChannels.mockResolvedValue([publicChannel]); + mocks.api.getContacts.mockResolvedValue([]); + mocks.api.toggleFavorite.mockResolvedValue({ + ...baseSettings, + favorites: [{ type: 'channel', id: publicChannel.key }], + }); + }); + + it('optimistically toggles favorite and persists on success', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTitle('Add to favorites')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle('Add to favorites')); + + await waitFor(() => { + expect(mocks.api.toggleFavorite).toHaveBeenCalledWith('channel', publicChannel.key); + }); + + await waitFor(() => { + expect(screen.getByTitle('Remove from favorites')).toBeInTheDocument(); + }); + }); + + it('rolls back favorite state by refetching settings on toggle failure', async () => { + mocks.api.toggleFavorite.mockRejectedValue(new Error('toggle failed')); + mocks.api.getSettings + .mockResolvedValueOnce({ ...baseSettings }) // initial load + .mockResolvedValueOnce({ ...baseSettings }); // rollback refetch + + render(); + + await waitFor(() => { + expect(screen.getByTitle('Add to favorites')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTitle('Add to favorites')); + + await waitFor(() => { + expect(mocks.api.toggleFavorite).toHaveBeenCalledWith('channel', publicChannel.key); + }); + + await waitFor(() => { + expect(mocks.api.getSettings).toHaveBeenCalledTimes(2); + }); + + await waitFor(() => { + expect(mocks.toast.error).toHaveBeenCalledWith('Failed to update favorite'); + }); + + await waitFor(() => { + expect(screen.getByTitle('Add to favorites')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx new file mode 100644 index 0000000..6ac024f --- /dev/null +++ b/frontend/src/test/settingsModal.test.tsx @@ -0,0 +1,123 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { SettingsModal } from '../components/SettingsModal'; +import type { AppSettings, HealthStatus, RadioConfig } from '../types'; + +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, +}; + +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, + bots: [], +}; + +function renderModal(overrides?: { + appSettings?: AppSettings; + onSaveAppSettings?: (update: { max_radio_contacts?: number }) => Promise; + onRefreshAppSettings?: () => Promise; +}) { + const onSaveAppSettings = overrides?.onSaveAppSettings ?? vi.fn(async () => {}); + const onRefreshAppSettings = overrides?.onRefreshAppSettings ?? vi.fn(async () => {}); + + render( + {})} + onSaveAppSettings={onSaveAppSettings} + onSetPrivateKey={vi.fn(async () => {})} + onReboot={vi.fn(async () => {})} + onAdvertise={vi.fn(async () => {})} + onHealthRefresh={vi.fn(async () => {})} + onRefreshAppSettings={onRefreshAppSettings} + /> + ); + + return { onSaveAppSettings, onRefreshAppSettings }; +} + +function openConnectivityTab() { + const connectivityTab = screen.getByRole('tab', { name: 'Connectivity' }); + fireEvent.mouseDown(connectivityTab); + fireEvent.click(connectivityTab); +} + +describe('SettingsModal', () => { + it('refreshes app settings when opened', async () => { + const { onRefreshAppSettings } = renderModal(); + + await waitFor(() => { + expect(onRefreshAppSettings).toHaveBeenCalledTimes(1); + }); + }); + + it('shows favorite-first contact sync helper text in connectivity tab', async () => { + renderModal(); + + openConnectivityTab(); + + 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(); + + openConnectivityTab(); + + 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 }, + }); + + openConnectivityTab(); + fireEvent.click(screen.getByRole('button', { name: 'Save Settings' })); + + await waitFor(() => { + expect(onSaveAppSettings).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 1252d24..42b8673 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,8 +1,14 @@ import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; +import path from 'path'; export default defineConfig({ plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, test: { environment: 'jsdom', globals: true, diff --git a/tests/test_radio_router.py b/tests/test_radio_router.py new file mode 100644 index 0000000..aad9f97 --- /dev/null +++ b/tests/test_radio_router.py @@ -0,0 +1,217 @@ +"""Tests for radio router endpoint logic.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException +from meshcore import EventType + +from app.routers.radio import ( + PrivateKeyUpdate, + RadioConfigResponse, + RadioConfigUpdate, + RadioSettings, + get_radio_config, + reboot_radio, + reconnect_radio, + send_advertisement, + set_private_key, + update_radio_config, +) + + +def _radio_result(event_type=EventType.OK, payload=None): + result = MagicMock() + result.type = event_type + result.payload = payload or {} + return result + + +def _mock_meshcore_with_info(): + mc = MagicMock() + mc.self_info = { + "public_key": "aa" * 32, + "name": "NodeA", + "adv_lat": 10.0, + "adv_lon": 20.0, + "tx_power": 17, + "max_tx_power": 22, + "radio_freq": 910.525, + "radio_bw": 62.5, + "radio_sf": 7, + "radio_cr": 5, + } + mc.commands = MagicMock() + mc.commands.set_name = AsyncMock() + mc.commands.set_coords = AsyncMock() + mc.commands.set_tx_power = AsyncMock() + mc.commands.set_radio = AsyncMock() + mc.commands.send_appstart = AsyncMock() + mc.commands.import_private_key = AsyncMock(return_value=_radio_result()) + return mc + + +class TestGetRadioConfig: + @pytest.mark.asyncio + async def test_maps_self_info_to_response(self): + mc = _mock_meshcore_with_info() + with patch("app.routers.radio.require_connected", return_value=mc): + response = await get_radio_config() + + assert response.public_key == "aa" * 32 + assert response.name == "NodeA" + assert response.lat == 10.0 + assert response.lon == 20.0 + assert response.radio.freq == 910.525 + assert response.radio.cr == 5 + + @pytest.mark.asyncio + async def test_returns_503_when_self_info_missing(self): + mc = MagicMock() + mc.self_info = None + with patch("app.routers.radio.require_connected", return_value=mc): + with pytest.raises(HTTPException) as exc: + await get_radio_config() + + assert exc.value.status_code == 503 + + +class TestUpdateRadioConfig: + @pytest.mark.asyncio + async def test_updates_only_requested_fields_and_refreshes_info(self): + mc = _mock_meshcore_with_info() + expected = RadioConfigResponse( + public_key="aa" * 32, + name="NodeUpdated", + lat=1.23, + lon=20.0, + tx_power=17, + max_tx_power=22, + radio=RadioSettings(freq=910.525, bw=62.5, sf=7, cr=5), + ) + + with ( + patch("app.routers.radio.require_connected", return_value=mc), + patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock) as mock_sync_time, + patch( + "app.routers.radio.get_radio_config", new_callable=AsyncMock, return_value=expected + ), + ): + result = await update_radio_config(RadioConfigUpdate(name="NodeUpdated", lat=1.23)) + + mc.commands.set_name.assert_awaited_once_with("NodeUpdated") + mc.commands.set_coords.assert_awaited_once_with(lat=1.23, lon=20.0) + mc.commands.set_tx_power.assert_not_awaited() + mc.commands.set_radio.assert_not_awaited() + mc.commands.send_appstart.assert_awaited_once() + mock_sync_time.assert_awaited_once() + assert result == expected + + +class TestPrivateKeyImport: + @pytest.mark.asyncio + async def test_rejects_invalid_hex(self): + mc = _mock_meshcore_with_info() + with patch("app.routers.radio.require_connected", return_value=mc): + with pytest.raises(HTTPException) as exc: + await set_private_key(PrivateKeyUpdate(private_key="not-hex")) + + assert exc.value.status_code == 400 + + @pytest.mark.asyncio + async def test_returns_500_on_radio_error(self): + mc = _mock_meshcore_with_info() + mc.commands.import_private_key = AsyncMock( + return_value=_radio_result(EventType.ERROR, {"error": "failed"}) + ) + with patch("app.routers.radio.require_connected", return_value=mc): + with pytest.raises(HTTPException) as exc: + await set_private_key(PrivateKeyUpdate(private_key="aa" * 64)) + + assert exc.value.status_code == 500 + + +class TestAdvertise: + @pytest.mark.asyncio + async def test_raises_when_send_fails(self): + with ( + patch("app.routers.radio.require_connected"), + patch( + "app.routers.radio.do_send_advertisement", + new_callable=AsyncMock, + return_value=False, + ), + ): + with pytest.raises(HTTPException) as exc: + await send_advertisement() + + assert exc.value.status_code == 500 + + +class TestRebootAndReconnect: + @pytest.mark.asyncio + async def test_reboot_connected_sends_reboot_command(self): + mock_rm = MagicMock() + mock_rm.is_connected = True + mock_rm.meshcore = MagicMock() + mock_rm.meshcore.commands.reboot = AsyncMock() + + with patch("app.radio.radio_manager", mock_rm): + result = await reboot_radio() + + assert result["status"] == "ok" + mock_rm.meshcore.commands.reboot.assert_awaited_once() + + @pytest.mark.asyncio + async def test_reboot_returns_pending_when_reconnect_in_progress(self): + mock_rm = MagicMock() + mock_rm.is_connected = False + mock_rm.meshcore = None + mock_rm.is_reconnecting = True + + with patch("app.radio.radio_manager", mock_rm): + result = await reboot_radio() + + assert result["status"] == "pending" + assert result["connected"] is False + + @pytest.mark.asyncio + async def test_reboot_attempts_reconnect_when_disconnected(self): + mock_rm = MagicMock() + mock_rm.is_connected = False + mock_rm.meshcore = None + mock_rm.is_reconnecting = False + mock_rm.reconnect = AsyncMock(return_value=True) + mock_rm.post_connect_setup = AsyncMock() + + with patch("app.radio.radio_manager", mock_rm): + result = await reboot_radio() + + assert result["status"] == "ok" + assert result["connected"] is True + mock_rm.reconnect.assert_awaited_once() + mock_rm.post_connect_setup.assert_awaited_once() + + @pytest.mark.asyncio + async def test_reconnect_returns_already_connected(self): + mock_rm = MagicMock() + mock_rm.is_connected = True + + with patch("app.radio.radio_manager", mock_rm): + result = await reconnect_radio() + + assert result["status"] == "ok" + assert result["connected"] is True + + @pytest.mark.asyncio + async def test_reconnect_raises_503_on_failure(self): + mock_rm = MagicMock() + mock_rm.is_connected = False + mock_rm.is_reconnecting = False + mock_rm.reconnect = AsyncMock(return_value=False) + + with patch("app.radio.radio_manager", mock_rm): + with pytest.raises(HTTPException) as exc: + await reconnect_radio() + + assert exc.value.status_code == 503 diff --git a/tests/test_repeater_routes.py b/tests/test_repeater_routes.py new file mode 100644 index 0000000..90701f4 --- /dev/null +++ b/tests/test_repeater_routes.py @@ -0,0 +1,286 @@ +"""Tests for repeater-specific contacts routes (telemetry, command, trace).""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException +from meshcore import EventType + +from app.models import CommandRequest, Contact, TelemetryRequest +from app.routers.contacts import request_telemetry, request_trace, send_repeater_command + +KEY_A = "aa" * 32 + + +def _radio_result(event_type=EventType.OK, payload=None): + result = MagicMock() + result.type = event_type + result.payload = payload or {} + return result + + +def _make_contact(public_key: str, contact_type: int, name: str = "Node") -> Contact: + return Contact(public_key=public_key, name=name, type=contact_type) + + +def _mock_mc(): + mc = MagicMock() + mc.commands = MagicMock() + mc.commands.req_status_sync = AsyncMock() + mc.commands.fetch_all_neighbours = AsyncMock() + mc.commands.req_acl_sync = AsyncMock() + mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK)) + mc.commands.get_msg = AsyncMock() + mc.commands.add_contact = AsyncMock(return_value=_radio_result(EventType.OK)) + mc.commands.send_trace = AsyncMock(return_value=_radio_result(EventType.OK)) + mc.wait_for_event = AsyncMock() + mc.stop_auto_message_fetching = AsyncMock() + mc.start_auto_message_fetching = AsyncMock() + return mc + + +class TestTelemetryRoute: + @pytest.mark.asyncio + async def test_returns_404_when_contact_missing(self): + mc = _mock_mc() + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch( + "app.routers.contacts.ContactRepository.get_by_key_or_prefix", + new_callable=AsyncMock, + return_value=None, + ), + ): + with pytest.raises(HTTPException) as exc: + await request_telemetry(KEY_A, TelemetryRequest(password="pw")) + + assert exc.value.status_code == 404 + + @pytest.mark.asyncio + async def test_returns_400_for_non_repeater_contact(self): + mc = _mock_mc() + contact = _make_contact(KEY_A, contact_type=1, name="Client") + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch( + "app.routers.contacts.ContactRepository.get_by_key_or_prefix", + new_callable=AsyncMock, + return_value=contact, + ), + ): + with pytest.raises(HTTPException) as exc: + await request_telemetry(KEY_A, TelemetryRequest(password="pw")) + + assert exc.value.status_code == 400 + assert "not a repeater" in exc.value.detail.lower() + + @pytest.mark.asyncio + async def test_status_retry_timeout_returns_504(self): + mc = _mock_mc() + contact = _make_contact(KEY_A, contact_type=2, name="Repeater") + mc.commands.req_status_sync = AsyncMock(side_effect=[None, None, None]) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch( + "app.routers.contacts.ContactRepository.get_by_key_or_prefix", + new_callable=AsyncMock, + return_value=contact, + ), + patch( + "app.routers.contacts.prepare_repeater_connection", + new_callable=AsyncMock, + ) as mock_prepare, + ): + with pytest.raises(HTTPException) as exc: + await request_telemetry(KEY_A, TelemetryRequest(password="pw")) + + assert exc.value.status_code == 504 + assert mc.commands.req_status_sync.await_count == 3 + mock_prepare.assert_awaited_once() + + @pytest.mark.asyncio + async def test_clock_timeout_uses_fallback_message_and_restores_auto_fetch(self): + mc = _mock_mc() + contact = _make_contact(KEY_A, contact_type=2, name="Repeater") + mc.commands.req_status_sync = AsyncMock( + return_value={ + "pubkey_pre": "aaaaaaaaaaaa", + "bat": 3775, + "uptime": 1234, + } + ) + mc.commands.fetch_all_neighbours = AsyncMock( + return_value={"neighbours": [{"pubkey": "abc123def456", "snr": 9.0, "secs_ago": 5}]} + ) + mc.commands.req_acl_sync = AsyncMock(return_value=[{"key": "def456abc123", "perm": 2}]) + mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK)) + mc.wait_for_event = AsyncMock(side_effect=[None, None]) # two clock attempts, no response + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch( + "app.routers.contacts.ContactRepository.get_by_key_or_prefix", + new_callable=AsyncMock, + return_value=contact, + ), + patch( + "app.routers.contacts.ContactRepository.get_by_key_prefix", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "app.routers.contacts.prepare_repeater_connection", + new_callable=AsyncMock, + ) as mock_prepare, + ): + response = await request_telemetry(KEY_A, TelemetryRequest(password="pw")) + + assert response.pubkey_prefix == "aaaaaaaaaaaa" + assert response.battery_volts == 3.775 + assert response.clock_output is not None + assert "unable to fetch `clock` output" in response.clock_output.lower() + mock_prepare.assert_awaited_once() + mc.stop_auto_message_fetching.assert_awaited_once() + mc.start_auto_message_fetching.assert_awaited_once() + + +class TestRepeaterCommandRoute: + @pytest.mark.asyncio + async def test_send_cmd_error_raises_and_restores_auto_fetch(self): + mc = _mock_mc() + contact = _make_contact(KEY_A, contact_type=2, name="Repeater") + mc.commands.send_cmd = AsyncMock( + return_value=_radio_result(EventType.ERROR, {"err": "bad"}) + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch( + "app.routers.contacts.ContactRepository.get_by_key_or_prefix", + new_callable=AsyncMock, + return_value=contact, + ), + ): + with pytest.raises(HTTPException) as exc: + await send_repeater_command(KEY_A, CommandRequest(command="ver")) + + assert exc.value.status_code == 500 + mc.start_auto_message_fetching.assert_awaited_once() + + @pytest.mark.asyncio + async def test_timeout_returns_no_response_message(self): + mc = _mock_mc() + contact = _make_contact(KEY_A, contact_type=2, name="Repeater") + mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK)) + mc.wait_for_event = AsyncMock(return_value=None) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch( + "app.routers.contacts.ContactRepository.get_by_key_or_prefix", + new_callable=AsyncMock, + return_value=contact, + ), + ): + response = await send_repeater_command(KEY_A, CommandRequest(command="ver")) + + assert response.command == "ver" + assert "no response" in response.response.lower() + mc.start_auto_message_fetching.assert_awaited_once() + + @pytest.mark.asyncio + async def test_success_returns_command_response_text_and_timestamp(self): + mc = _mock_mc() + contact = _make_contact(KEY_A, contact_type=2, name="Repeater") + mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK)) + mc.wait_for_event = AsyncMock(return_value=MagicMock()) + mc.commands.get_msg = AsyncMock( + return_value=_radio_result( + EventType.CONTACT_MSG_RECV, + {"text": "firmware: v1.2.3", "timestamp": 1700000000}, + ) + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch( + "app.routers.contacts.ContactRepository.get_by_key_or_prefix", + new_callable=AsyncMock, + return_value=contact, + ), + ): + response = await send_repeater_command(KEY_A, CommandRequest(command="ver")) + + assert response.command == "ver" + assert response.response == "firmware: v1.2.3" + assert response.sender_timestamp == 1700000000 + + +class TestTraceRoute: + @pytest.mark.asyncio + async def test_send_trace_error_returns_500(self): + mc = _mock_mc() + contact = _make_contact(KEY_A, contact_type=1, name="Client") + mc.commands.send_trace = AsyncMock( + return_value=_radio_result(EventType.ERROR, {"err": "x"}) + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch( + "app.routers.contacts.ContactRepository.get_by_key_or_prefix", + new_callable=AsyncMock, + return_value=contact, + ), + patch("app.routers.contacts.random.randint", return_value=1234), + ): + with pytest.raises(HTTPException) as exc: + await request_trace(KEY_A) + + assert exc.value.status_code == 500 + + @pytest.mark.asyncio + async def test_wait_timeout_returns_504(self): + mc = _mock_mc() + contact = _make_contact(KEY_A, contact_type=1, name="Client") + mc.commands.send_trace = AsyncMock(return_value=_radio_result(EventType.OK)) + mc.wait_for_event = AsyncMock(return_value=None) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch( + "app.routers.contacts.ContactRepository.get_by_key_or_prefix", + new_callable=AsyncMock, + return_value=contact, + ), + patch("app.routers.contacts.random.randint", return_value=1234), + ): + with pytest.raises(HTTPException) as exc: + await request_trace(KEY_A) + + assert exc.value.status_code == 504 + + @pytest.mark.asyncio + async def test_success_returns_remote_and_local_snr(self): + mc = _mock_mc() + contact = _make_contact(KEY_A, contact_type=1, name="Client") + mc.commands.send_trace = AsyncMock(return_value=_radio_result(EventType.OK)) + mc.wait_for_event = AsyncMock( + return_value=MagicMock(payload={"path": [{"snr": 5.5}, {"snr": 3.2}], "path_len": 2}) + ) + + with ( + patch("app.routers.contacts.require_connected", return_value=mc), + patch( + "app.routers.contacts.ContactRepository.get_by_key_or_prefix", + new_callable=AsyncMock, + return_value=contact, + ), + patch("app.routers.contacts.random.randint", return_value=1234), + ): + response = await request_trace(KEY_A) + + assert response.remote_snr == 5.5 + assert response.local_snr == 3.2 + assert response.path_len == 2 diff --git a/tests/test_repository.py b/tests/test_repository.py index 56f4f2b..3f41d52 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -415,3 +415,100 @@ class TestMessageRepositoryGetAckCount: result = await MessageRepository.get_ack_count(message_id=42) assert result == 0 + + +class TestAppSettingsRepository: + """Test AppSettingsRepository parsing and migration edge cases.""" + + @pytest.mark.asyncio + async def test_get_handles_corrupted_json_and_invalid_sort_order(self): + """Corrupted JSON fields are recovered with safe defaults.""" + mock_conn = AsyncMock() + mock_cursor = AsyncMock() + mock_cursor.fetchone = AsyncMock( + return_value={ + "max_radio_contacts": 250, + "favorites": "{not-json", + "auto_decrypt_dm_on_advert": 1, + "sidebar_sort_order": "invalid", + "last_message_times": "{also-not-json", + "preferences_migrated": 0, + "advert_interval": None, + "last_advert_time": None, + "bots": "{bad-bots-json", + } + ) + mock_conn.execute = AsyncMock(return_value=mock_cursor) + mock_db = MagicMock() + mock_db.conn = mock_conn + + with patch("app.repository.db", mock_db): + from app.repository import AppSettingsRepository + + settings = await AppSettingsRepository.get() + + assert settings.max_radio_contacts == 250 + assert settings.favorites == [] + assert settings.last_message_times == {} + assert settings.sidebar_sort_order == "recent" + assert settings.bots == [] + assert settings.advert_interval == 0 + assert settings.last_advert_time == 0 + + @pytest.mark.asyncio + async def test_add_favorite_is_idempotent(self): + """Adding an existing favorite does not write duplicate entries.""" + from app.models import AppSettings, Favorite + + existing = AppSettings(favorites=[Favorite(type="contact", id="aa" * 32)]) + + with ( + patch( + "app.repository.AppSettingsRepository.get", + new_callable=AsyncMock, + return_value=existing, + ), + patch( + "app.repository.AppSettingsRepository.update", + new_callable=AsyncMock, + ) as mock_update, + ): + from app.repository import AppSettingsRepository + + result = await AppSettingsRepository.add_favorite("contact", "aa" * 32) + + assert result == existing + mock_update.assert_not_awaited() + + @pytest.mark.asyncio + async def test_migrate_preferences_uses_recent_for_invalid_sort_order(self): + """Migration normalizes invalid sort order to 'recent'.""" + from app.models import AppSettings + + current = AppSettings(preferences_migrated=False) + migrated = AppSettings(preferences_migrated=True, sidebar_sort_order="recent") + + with ( + patch( + "app.repository.AppSettingsRepository.get", + new_callable=AsyncMock, + return_value=current, + ), + patch( + "app.repository.AppSettingsRepository.update", + new_callable=AsyncMock, + return_value=migrated, + ) as mock_update, + ): + from app.repository import AppSettingsRepository + + result, did_migrate = await AppSettingsRepository.migrate_preferences_from_frontend( + favorites=[{"type": "contact", "id": "bb" * 32}], + sort_order="weird-order", + last_message_times={"contact-bbbbbbbbbbbb": 123}, + ) + + assert did_migrate is True + assert result.preferences_migrated is True + assert mock_update.call_args.kwargs["sidebar_sort_order"] == "recent" + assert mock_update.call_args.kwargs["preferences_migrated"] is True diff --git a/tests/test_settings_router.py b/tests/test_settings_router.py new file mode 100644 index 0000000..8bf9ea7 --- /dev/null +++ b/tests/test_settings_router.py @@ -0,0 +1,194 @@ +"""Tests for settings router endpoints and validation behavior.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException + +from app.models import AppSettings, BotConfig, Favorite +from app.routers.settings import ( + AppSettingsUpdate, + FavoriteRequest, + MigratePreferencesRequest, + migrate_preferences, + toggle_favorite, + update_settings, +) + + +def _settings( + *, + favorites: list[Favorite] | None = None, + migrated: bool = False, + max_radio_contacts: int = 200, +) -> AppSettings: + return AppSettings( + max_radio_contacts=max_radio_contacts, + favorites=favorites or [], + auto_decrypt_dm_on_advert=False, + sidebar_sort_order="recent", + last_message_times={}, + preferences_migrated=migrated, + advert_interval=0, + last_advert_time=0, + bots=[], + ) + + +class TestUpdateSettings: + @pytest.mark.asyncio + async def test_forwards_only_provided_fields(self): + updated = _settings(max_radio_contacts=321) + with patch( + "app.routers.settings.AppSettingsRepository.update", + new_callable=AsyncMock, + return_value=updated, + ) as mock_update: + result = await update_settings( + AppSettingsUpdate(max_radio_contacts=321, advert_interval=3600) + ) + + assert result.max_radio_contacts == 321 + assert mock_update.call_count == 1 + assert mock_update.call_args.kwargs == { + "max_radio_contacts": 321, + "advert_interval": 3600, + } + + @pytest.mark.asyncio + async def test_empty_patch_returns_current_settings(self): + current = _settings() + with ( + patch( + "app.routers.settings.AppSettingsRepository.get", + new_callable=AsyncMock, + return_value=current, + ) as mock_get, + patch( + "app.routers.settings.AppSettingsRepository.update", + new_callable=AsyncMock, + ) as mock_update, + ): + result = await update_settings(AppSettingsUpdate()) + + assert result == current + mock_get.assert_awaited_once() + mock_update.assert_not_awaited() + + @pytest.mark.asyncio + async def test_invalid_bot_syntax_returns_400(self): + bad_bot = BotConfig( + id="bot-1", + name="BadBot", + enabled=True, + code="def bot(:\n return 'x'\n", + ) + + with pytest.raises(HTTPException) as exc: + await update_settings(AppSettingsUpdate(bots=[bad_bot])) + + assert exc.value.status_code == 400 + assert "syntax error" in exc.value.detail.lower() + + +class TestToggleFavorite: + @pytest.mark.asyncio + async def test_adds_when_not_favorited(self): + initial = _settings(favorites=[]) + updated = _settings(favorites=[Favorite(type="contact", id="aa" * 32)]) + request = FavoriteRequest(type="contact", id="aa" * 32) + + with ( + patch( + "app.routers.settings.AppSettingsRepository.get", + new_callable=AsyncMock, + return_value=initial, + ), + patch( + "app.routers.settings.AppSettingsRepository.add_favorite", + new_callable=AsyncMock, + return_value=updated, + ) as mock_add, + patch( + "app.routers.settings.AppSettingsRepository.remove_favorite", + new_callable=AsyncMock, + ) as mock_remove, + ): + result = await toggle_favorite(request) + + assert result.favorites == updated.favorites + mock_add.assert_awaited_once_with("contact", "aa" * 32) + mock_remove.assert_not_awaited() + + @pytest.mark.asyncio + async def test_removes_when_already_favorited(self): + initial = _settings(favorites=[Favorite(type="channel", id="ABCD")]) + updated = _settings(favorites=[]) + request = FavoriteRequest(type="channel", id="ABCD") + + with ( + patch( + "app.routers.settings.AppSettingsRepository.get", + new_callable=AsyncMock, + return_value=initial, + ), + patch( + "app.routers.settings.AppSettingsRepository.remove_favorite", + new_callable=AsyncMock, + return_value=updated, + ) as mock_remove, + patch( + "app.routers.settings.AppSettingsRepository.add_favorite", + new_callable=AsyncMock, + ) as mock_add, + ): + result = await toggle_favorite(request) + + assert result.favorites == [] + mock_remove.assert_awaited_once_with("channel", "ABCD") + mock_add.assert_not_awaited() + + +class TestMigratePreferences: + @pytest.mark.asyncio + async def test_maps_frontend_payload_and_returns_migrated_true(self): + request = MigratePreferencesRequest( + favorites=[FavoriteRequest(type="contact", id="aa" * 32)], + sort_order="alpha", + last_message_times={"contact-aaaaaaaaaaaa": 123}, + ) + settings = _settings(favorites=[Favorite(type="contact", id="aa" * 32)], migrated=True) + + with patch( + "app.routers.settings.AppSettingsRepository.migrate_preferences_from_frontend", + new_callable=AsyncMock, + return_value=(settings, True), + ) as mock_migrate: + response = await migrate_preferences(request) + + assert response.migrated is True + assert response.settings == settings + assert mock_migrate.call_args.kwargs == { + "favorites": [{"type": "contact", "id": "aa" * 32}], + "sort_order": "alpha", + "last_message_times": {"contact-aaaaaaaaaaaa": 123}, + } + + @pytest.mark.asyncio + async def test_returns_migrated_false_when_already_done(self): + request = MigratePreferencesRequest( + favorites=[], + sort_order="recent", + last_message_times={}, + ) + settings = _settings(migrated=True) + + with patch( + "app.routers.settings.AppSettingsRepository.migrate_preferences_from_frontend", + new_callable=AsyncMock, + return_value=(settings, False), + ): + response = await migrate_preferences(request) + + assert response.migrated is False + assert response.settings.preferences_migrated is True