Testing blitz!

This commit is contained in:
Jack Kingsman
2026-02-10 16:47:46 -08:00
parent cf6df506d1
commit 875f197812
9 changed files with 1181 additions and 7 deletions

View File

@@ -462,9 +462,10 @@ export function MessageList({
)}
</>
)}
{msg.outgoing && (msg.acked > 0
? msg.paths && msg.paths.length > 0
? <span
{msg.outgoing &&
(msg.acked > 0 ? (
msg.paths && msg.paths.length > 0 ? (
<span
className="cursor-pointer hover:text-primary"
onClick={(e) => {
e.stopPropagation();
@@ -480,8 +481,12 @@ export function MessageList({
}}
title="View echo paths"
>{`${msg.acked > 1 ? msg.acked : ''}`}</span>
: `${msg.acked > 1 ? msg.acked : ''}`
: ' ?')}
) : (
`${msg.acked > 1 ? msg.acked : ''}`
)
) : (
' ?'
))}
</div>
</div>
</div>

View File

@@ -751,8 +751,8 @@ export function SettingsModal({
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
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)
</p>
</div>

View File

@@ -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: () => <div data-testid="status-bar" />,
}));
vi.mock('../components/Sidebar', () => ({
Sidebar: () => <div data-testid="sidebar" />,
}));
vi.mock('../components/MessageList', () => ({
MessageList: () => <div data-testid="message-list" />,
}));
vi.mock('../components/MessageInput', () => ({
MessageInput: React.forwardRef((_props, ref) => {
React.useImperativeHandle(ref, () => ({ appendText: vi.fn() }));
return <div data-testid="message-input" />;
}),
}));
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 }) => <div>{children}</div>,
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
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(<App />);
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(<App />);
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();
});
});
});

View File

@@ -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<void>;
onRefreshAppSettings?: () => Promise<void>;
}) {
const onSaveAppSettings = overrides?.onSaveAppSettings ?? vi.fn(async () => {});
const onRefreshAppSettings = overrides?.onRefreshAppSettings ?? vi.fn(async () => {});
render(
<SettingsModal
open
config={baseConfig}
health={baseHealth}
appSettings={overrides?.appSettings ?? baseSettings}
onClose={vi.fn()}
onSave={vi.fn(async () => {})}
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();
});
});
});

View File

@@ -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,

217
tests/test_radio_router.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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