diff --git a/AGENTS.md b/AGENTS.md index bea6153..9c0b0b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -300,15 +300,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | GET | `/api/contacts` | List contacts | | GET | `/api/contacts/analytics` | Unified keyed-or-name contact analytics payload | | GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts | -| GET | `/api/contacts/name-detail` | Channel activity summary for a sender name without a resolved key | -| GET | `/api/contacts/{public_key}` | Get contact by public key or prefix | -| GET | `/api/contacts/{public_key}/detail` | Comprehensive contact profile (stats, name history, paths) | -| GET | `/api/contacts/{public_key}/advert-paths` | List recent unique advert paths for a contact | | POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) | | DELETE | `/api/contacts/{public_key}` | Delete contact | -| POST | `/api/contacts/sync` | Pull from radio | -| POST | `/api/contacts/{public_key}/add-to-radio` | Push contact to radio | -| POST | `/api/contacts/{public_key}/remove-from-radio` | Remove contact from radio | | POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read | | POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater | | POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override | @@ -325,10 +318,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | GET | `/api/channels` | List channels | | GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) | -| GET | `/api/channels/{key}` | Get channel by key | | POST | `/api/channels` | Create channel | | DELETE | `/api/channels/{key}` | Delete channel | -| POST | `/api/channels/sync` | Pull from radio | | POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override | | POST | `/api/channels/{key}/mark-read` | Mark channel as read | | GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) | diff --git a/app/AGENTS.md b/app/AGENTS.md index 4ed5c20..2672334 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -158,15 +158,8 @@ app/ - `GET /contacts` - `GET /contacts/analytics` — unified keyed-or-name analytics payload - `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts -- `GET /contacts/name-detail` — name-only activity summary for unresolved channel senders -- `GET /contacts/{public_key}` -- `GET /contacts/{public_key}/detail` — comprehensive contact profile (stats, name history, paths, nearest repeaters) -- `GET /contacts/{public_key}/advert-paths` — recent advert paths for one contact - `POST /contacts` - `DELETE /contacts/{public_key}` -- `POST /contacts/sync` -- `POST /contacts/{public_key}/add-to-radio` -- `POST /contacts/{public_key}/remove-from-radio` - `POST /contacts/{public_key}/mark-read` - `POST /contacts/{public_key}/command` - `POST /contacts/{public_key}/routing-override` @@ -184,10 +177,8 @@ app/ ### Channels - `GET /channels` - `GET /channels/{key}/detail` -- `GET /channels/{key}` - `POST /channels` - `DELETE /channels/{key}` -- `POST /channels/sync` - `POST /channels/{key}/flood-scope-override` - `POST /channels/{key}/mark-read` diff --git a/app/routers/channels.py b/app/routers/channels.py index 7403137..0537bfb 100644 --- a/app/routers/channels.py +++ b/app/routers/channels.py @@ -1,16 +1,12 @@ import logging from hashlib import sha256 -from fastapi import APIRouter, HTTPException, Query -from meshcore import EventType +from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field -from app.dependencies import require_connected from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender -from app.radio_sync import upsert_channel_from_radio_slot from app.region_scope import normalize_region_scope from app.repository import ChannelRepository, MessageRepository -from app.services.radio_runtime import radio_runtime as radio_manager from app.websocket import broadcast_event logger = logging.getLogger(__name__) @@ -59,15 +55,6 @@ async def get_channel_detail(key: str) -> ChannelDetail: ) -@router.get("/{key}", response_model=Channel) -async def get_channel(key: str) -> Channel: - """Get a specific channel by key (32-char hex string).""" - channel = await ChannelRepository.get_by_key(key) - if not channel: - raise HTTPException(status_code=404, detail="Channel not found") - return channel - - @router.post("", response_model=Channel) async def create_channel(request: CreateChannelRequest) -> Channel: """Create a channel in the database. @@ -110,33 +97,6 @@ async def create_channel(request: CreateChannelRequest) -> Channel: return stored -@router.post("/sync") -async def sync_channels_from_radio(max_channels: int = Query(default=40, ge=1, le=40)) -> dict: - """Sync channels from the radio to the database.""" - require_connected() - - logger.info("Syncing channels from radio (checking %d slots)", max_channels) - count = 0 - - async with radio_manager.radio_operation("sync_channels_from_radio") as mc: - for idx in range(max_channels): - result = await mc.commands.get_channel(idx) - - if result.type == EventType.CHANNEL_INFO: - key_hex = await upsert_channel_from_radio_slot(result.payload, on_radio=True) - if key_hex is not None: - count += 1 - stored = await ChannelRepository.get_by_key(key_hex) - if stored is not None: - _broadcast_channel_update(stored) - logger.debug( - "Synced channel %s: %s", key_hex, result.payload.get("channel_name") - ) - - logger.info("Synced %d channels from radio", count) - return {"synced": count} - - @router.post("/{key}/mark-read") async def mark_channel_read(key: str) -> dict: """Mark a channel as read (update last_read_at timestamp).""" diff --git a/app/routers/contacts.py b/app/routers/contacts.py index 0569fbb..5da0a0c 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -8,14 +8,11 @@ from app.dependencies import require_connected from app.models import ( Contact, ContactActiveRoom, - ContactAdvertPath, ContactAdvertPathSummary, ContactAnalytics, - ContactDetail, ContactRoutingOverrideRequest, ContactUpsert, CreateContactRequest, - NameOnlyContactDetail, NearestRepeater, TraceResponse, ) @@ -325,158 +322,6 @@ async def create_contact( return stored -@router.get("/{public_key}/detail", response_model=ContactDetail) -async def get_contact_detail(public_key: str) -> ContactDetail: - """Get comprehensive contact profile data. - - Returns contact info, name history, message counts, most active rooms, - advertisement paths, advert frequency, and nearest repeaters. - """ - contact = await _resolve_contact_or_404(public_key) - analytics = await _build_keyed_contact_analytics(contact) - assert analytics.contact is not None - return ContactDetail( - contact=analytics.contact, - name_history=analytics.name_history, - dm_message_count=analytics.dm_message_count, - channel_message_count=analytics.channel_message_count, - most_active_rooms=analytics.most_active_rooms, - advert_paths=analytics.advert_paths, - advert_frequency=analytics.advert_frequency, - nearest_repeaters=analytics.nearest_repeaters, - ) - - -@router.get("/name-detail", response_model=NameOnlyContactDetail) -async def get_name_only_contact_detail( - name: str = Query(min_length=1, max_length=200), -) -> NameOnlyContactDetail: - """Get channel activity summary for a sender name without a resolved key.""" - normalized_name = name.strip() - if not normalized_name: - raise HTTPException(status_code=400, detail="name is required") - analytics = await _build_name_only_contact_analytics(normalized_name) - return NameOnlyContactDetail( - name=analytics.name, - channel_message_count=analytics.channel_message_count, - most_active_rooms=analytics.most_active_rooms, - ) - - -@router.get("/{public_key}", response_model=Contact) -async def get_contact(public_key: str) -> Contact: - """Get a specific contact by public key or prefix.""" - return await _resolve_contact_or_404(public_key) - - -@router.get("/{public_key}/advert-paths", response_model=list[ContactAdvertPath]) -async def get_contact_advert_paths( - public_key: str, - limit: int = Query(default=10, ge=1, le=50), -) -> list[ContactAdvertPath]: - """List recent unique advert paths for a contact.""" - contact = await _resolve_contact_or_404(public_key) - return await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key, limit) - - -@router.post("/sync") -async def sync_contacts_from_radio() -> dict: - """Sync contacts from the radio to the database.""" - require_connected() - - logger.info("Syncing contacts from radio") - - async with radio_manager.radio_operation("sync_contacts_from_radio") as mc: - result = await mc.commands.get_contacts() - - if result.type == EventType.ERROR: - raise HTTPException(status_code=500, detail=f"Failed to get contacts: {result.payload}") - - contacts = result.payload - count = 0 - - synced_keys: list[str] = [] - for public_key, contact_data in contacts.items(): - lower_key = public_key.lower() - await ContactRepository.upsert( - ContactUpsert.from_radio_dict(lower_key, contact_data, on_radio=True) - ) - promoted_keys = await promote_prefix_contacts_for_contact( - public_key=lower_key, - log=logger, - ) - synced_keys.append(lower_key) - await reconcile_contact_messages( - public_key=lower_key, - contact_name=contact_data.get("adv_name"), - log=logger, - ) - stored = await ContactRepository.get_by_key(lower_key) - if stored is not None: - await _broadcast_contact_update(stored) - await _broadcast_contact_resolution(promoted_keys, stored) - count += 1 - - # Clear on_radio for contacts not found on the radio - await ContactRepository.clear_on_radio_except(synced_keys) - - logger.info("Synced %d contacts from radio", count) - return {"synced": count} - - -@router.post("/{public_key}/remove-from-radio") -async def remove_contact_from_radio(public_key: str) -> dict: - """Remove a contact from the radio (keeps it in database).""" - require_connected() - - contact = await _resolve_contact_or_404(public_key) - - async with radio_manager.radio_operation("remove_contact_from_radio") as mc: - # Get the contact from radio - radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12]) - if not radio_contact: - # Already not on radio - await ContactRepository.set_on_radio(contact.public_key, False) - return {"status": "ok", "message": "Contact was not on radio"} - - logger.info("Removing contact %s from radio", contact.public_key[:12]) - - result = await mc.commands.remove_contact(radio_contact) - - if result.type == EventType.ERROR: - raise HTTPException( - status_code=500, detail=f"Failed to remove contact: {result.payload}" - ) - - await ContactRepository.set_on_radio(contact.public_key, False) - return {"status": "ok"} - - -@router.post("/{public_key}/add-to-radio") -async def add_contact_to_radio(public_key: str) -> dict: - """Add a contact from the database to the radio.""" - require_connected() - - contact = await _resolve_contact_or_404(public_key, "Contact not found in database") - - async with radio_manager.radio_operation("add_contact_to_radio") as mc: - # Check if already on radio - radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12]) - if radio_contact: - await ContactRepository.set_on_radio(contact.public_key, True) - return {"status": "ok", "message": "Contact already on radio"} - - logger.info("Adding contact %s to radio", contact.public_key[:12]) - - result = await mc.commands.add_contact(contact.to_radio_dict()) - - if result.type == EventType.ERROR: - raise HTTPException(status_code=500, detail=f"Failed to add contact: {result.payload}") - - await ContactRepository.set_on_radio(contact.public_key, True) - return {"status": "ok"} - - @router.post("/{public_key}/mark-read") async def mark_contact_read(public_key: str) -> dict: """Mark a contact conversation as read (update last_read_at timestamp).""" diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 13caa93..39ff59f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -6,9 +6,7 @@ import type { CommandResponse, Contact, ContactAnalytics, - ContactAdvertPath, ContactAdvertPathSummary, - ContactDetail, FanoutConfig, Favorite, HealthStatus, @@ -17,7 +15,6 @@ import type { MessagesAroundResponse, MigratePreferencesRequest, MigratePreferencesResponse, - NameOnlyContactDetail, RadioConfig, RadioConfigUpdate, RepeaterAclResponse, @@ -121,18 +118,12 @@ export const api = { fetchJson( `/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}` ), - getContactAdvertPaths: (publicKey: string, limit = 10) => - fetchJson(`/contacts/${publicKey}/advert-paths?limit=${limit}`), getContactAnalytics: (params: { publicKey?: string; name?: string }) => { const searchParams = new URLSearchParams(); if (params.publicKey) searchParams.set('public_key', params.publicKey); if (params.name) searchParams.set('name', params.name); return fetchJson(`/contacts/analytics?${searchParams.toString()}`); }, - getContactDetail: (publicKey: string) => - fetchJson(`/contacts/${publicKey}/detail`), - getNameOnlyContactDetail: (name: string) => - fetchJson(`/contacts/name-detail?name=${encodeURIComponent(name)}`), deleteContact: (publicKey: string) => fetchJson<{ status: string }>(`/contacts/${publicKey}`, { method: 'DELETE', diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 2d001b7..e207882 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -4,8 +4,14 @@ const BASE_URL = 'http://localhost:8001'; const MAX_RETRIES = 10; const RETRY_DELAY_MS = 2000; +interface HealthStatus { + radio_connected: boolean; + radio_initializing: boolean; + connection_info: string | null; +} + export default async function globalSetup(_config: FullConfig) { - // Wait for the backend to be fully ready and radio connected + // Wait for the backend to be fully ready and radio setup complete let lastError: Error | null = null; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { @@ -14,7 +20,7 @@ export default async function globalSetup(_config: FullConfig) { if (!res.ok) { throw new Error(`Health check returned ${res.status}`); } - const health = (await res.json()) as { radio_connected: boolean; connection_info: string | null }; + const health = (await res.json()) as HealthStatus; if (!health.radio_connected) { throw new Error( @@ -22,8 +28,11 @@ export default async function globalSetup(_config: FullConfig) { 'Set MESHCORE_SERIAL_PORT if auto-detection fails.' ); } + if (health.radio_initializing) { + throw new Error('Radio connected but still initializing'); + } - console.log(`Radio connected on ${health.connection_info}`); + console.log(`Radio ready on ${health.connection_info}`); return; } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)); diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts index 2e42c7c..1c41506 100644 --- a/tests/e2e/helpers/api.ts +++ b/tests/e2e/helpers/api.ts @@ -21,6 +21,7 @@ async function fetchJson(path: string, init?: RequestInit): Promise { export interface HealthStatus { radio_connected: boolean; + radio_initializing: boolean; connection_info: string | null; } @@ -267,7 +268,7 @@ export async function ensureFlightlessChannel(): Promise { } /** - * Wait for health to show radio_connected, polling with retries. + * Wait for health to show a fully ready radio, polling with retries. */ export async function waitForRadioConnected( timeoutMs: number = 30_000, @@ -277,19 +278,13 @@ export async function waitForRadioConnected( while (Date.now() < deadline) { try { const health = await getHealth(); - if (health.radio_connected) return; + if (health.radio_connected && !health.radio_initializing) return; } catch { // Backend might be restarting } await new Promise((r) => setTimeout(r, intervalMs)); } - throw new Error(`Radio did not reconnect within ${timeoutMs}ms`); -} - -// --- Contacts sync --- - -export function syncContacts(): Promise<{ synced: number }> { - return fetchJson('/contacts/sync', { method: 'POST' }); + throw new Error(`Radio did not finish reconnect/setup within ${timeoutMs}ms`); } // --- Packets / Historical decryption --- diff --git a/tests/e2e/specs/contacts.spec.ts b/tests/e2e/specs/contacts.spec.ts index ac25942..7d726e1 100644 --- a/tests/e2e/specs/contacts.spec.ts +++ b/tests/e2e/specs/contacts.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { syncContacts, getContacts, type Contact } from '../helpers/api'; +import { getContacts, type Contact } from '../helpers/api'; /** Escape special regex characters in a string. */ function escapeRegex(s: string): string { @@ -12,10 +12,6 @@ function findChatContact(contacts: Contact[]): Contact | undefined { } test.describe('Contacts sidebar & info pane', () => { - test.beforeAll(async () => { - await syncContacts(); - }); - test('contacts appear in sidebar and clicking opens conversation', async ({ page }) => { const contacts = await getContacts(); const named = findChatContact(contacts); diff --git a/tests/test_channels_router.py b/tests/test_channels_router.py index 407594a..3f4a658 100644 --- a/tests/test_channels_router.py +++ b/tests/test_channels_router.py @@ -1,288 +1,13 @@ -"""Tests for the channels router endpoints. - -Covers POST /api/channels/sync (radio sync) and GET /api/channels/{key}/detail -(channel stats). -""" +"""Tests for the channels router endpoints.""" import time -from contextlib import asynccontextmanager -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import patch import pytest -from fastapi import HTTPException -from meshcore import EventType -from app.radio import radio_manager from app.repository import ChannelRepository, MessageRepository -@pytest.fixture(autouse=True) -def _reset_radio_state(): - """Save/restore radio_manager state so tests don't leak.""" - prev = radio_manager._meshcore - prev_lock = radio_manager._operation_lock - yield - radio_manager._meshcore = prev - radio_manager._operation_lock = prev_lock - - -def _make_channel_info(name: str, secret: bytes): - """Create a mock channel info response.""" - result = MagicMock() - result.type = EventType.CHANNEL_INFO - result.payload = { - "channel_name": name, - "channel_secret": secret, - } - return result - - -def _make_empty_channel(): - """Create a mock empty channel response.""" - result = MagicMock() - result.type = EventType.CHANNEL_INFO - result.payload = { - "channel_name": "\x00\x00\x00\x00", - "channel_secret": b"", - } - return result - - -def _make_error_response(): - """Create a mock error response (channel slot unused).""" - result = MagicMock() - result.type = EventType.ERROR - result.payload = {} - return result - - -def _patch_require_connected(mc=None, *, detail="Radio not connected"): - if mc is None: - return patch( - "app.dependencies.radio_manager.require_connected", - side_effect=HTTPException(status_code=503, detail=detail), - ) - return patch("app.dependencies.radio_manager.require_connected", return_value=mc) - - -@asynccontextmanager -async def _noop_radio_operation(mc): - """No-op radio_operation context manager that yields mc.""" - yield mc - - -class TestSyncChannelsFromRadio: - """Test POST /api/channels/sync.""" - - @pytest.mark.asyncio - async def test_sync_channels_basic(self, test_db, client): - """Sync creates channels from radio slots.""" - secret_a = bytes.fromhex("0123456789abcdef0123456789abcdef") - secret_b = bytes.fromhex("fedcba9876543210fedcba9876543210") - - mock_mc = MagicMock() - - async def mock_get_channel(idx): - if idx == 0: - return _make_channel_info("#general", secret_a) - if idx == 1: - return _make_channel_info("Private", secret_b) - return _make_empty_channel() - - mock_mc.commands.get_channel = AsyncMock(side_effect=mock_get_channel) - radio_manager._meshcore = mock_mc - - with ( - _patch_require_connected(mock_mc), - patch("app.routers.channels.radio_manager") as mock_ch_rm, - ): - mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc) - - response = await client.post("/api/channels/sync?max_channels=5") - - assert response.status_code == 200 - data = response.json() - assert data["synced"] == 2 - - # Verify channels in DB (2 synced + #remoteterm seed) - channels = await ChannelRepository.get_all() - assert len(channels) == 3 - - keys = {ch.key for ch in channels} - assert secret_a.hex().upper() in keys - assert secret_b.hex().upper() in keys - - @pytest.mark.asyncio - async def test_sync_broadcasts_channel_updates(self, test_db, client): - secret = bytes.fromhex("0123456789abcdef0123456789abcdef") - mock_mc = MagicMock() - - async def mock_get_channel(idx): - if idx == 0: - return _make_channel_info("#general", secret) - return _make_empty_channel() - - mock_mc.commands.get_channel = AsyncMock(side_effect=mock_get_channel) - radio_manager._meshcore = mock_mc - - with ( - _patch_require_connected(mock_mc), - patch("app.routers.channels.radio_manager") as mock_ch_rm, - patch("app.routers.channels.broadcast_event") as mock_broadcast, - ): - mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc) - - response = await client.post("/api/channels/sync?max_channels=3") - - assert response.status_code == 200 - mock_broadcast.assert_called_once() - assert mock_broadcast.call_args.args[0] == "channel" - assert mock_broadcast.call_args.args[1]["key"] == secret.hex().upper() - - @pytest.mark.asyncio - async def test_sync_skips_empty_channels(self, test_db, client): - """Empty channel slots are skipped during sync.""" - secret = bytes.fromhex("aabbccddaabbccddaabbccddaabbccdd") - mock_mc = MagicMock() - - async def mock_get_channel(idx): - if idx == 0: - return _make_channel_info("#test", secret) - return _make_empty_channel() - - mock_mc.commands.get_channel = AsyncMock(side_effect=mock_get_channel) - radio_manager._meshcore = mock_mc - - with ( - _patch_require_connected(mock_mc), - patch("app.routers.channels.radio_manager") as mock_ch_rm, - ): - mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc) - - response = await client.post("/api/channels/sync?max_channels=5") - - assert response.status_code == 200 - assert response.json()["synced"] == 1 - - @pytest.mark.asyncio - async def test_sync_hashtag_flag(self, test_db, client): - """Channels starting with # are marked as hashtag channels.""" - secret = bytes.fromhex("1122334455667788aabbccddeeff0011") - mock_mc = MagicMock() - - async def mock_get_channel(idx): - if idx == 0: - return _make_channel_info("#hashtag-room", secret) - return _make_empty_channel() - - mock_mc.commands.get_channel = AsyncMock(side_effect=mock_get_channel) - radio_manager._meshcore = mock_mc - - with ( - _patch_require_connected(mock_mc), - patch("app.routers.channels.radio_manager") as mock_ch_rm, - ): - mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc) - - response = await client.post("/api/channels/sync?max_channels=3") - - assert response.status_code == 200 - - channel = await ChannelRepository.get_by_key(secret.hex().upper()) - assert channel is not None - assert channel.is_hashtag is True - assert channel.name == "#hashtag-room" - assert channel.on_radio is True - - @pytest.mark.asyncio - async def test_sync_marks_channels_on_radio(self, test_db, client): - """Synced channels have on_radio=True.""" - secret = bytes.fromhex("aabbccddaabbccddaabbccddaabbccdd") - mock_mc = MagicMock() - - async def mock_get_channel(idx): - if idx == 0: - return _make_channel_info("MyChannel", secret) - return _make_empty_channel() - - mock_mc.commands.get_channel = AsyncMock(side_effect=mock_get_channel) - radio_manager._meshcore = mock_mc - - with ( - _patch_require_connected(mock_mc), - patch("app.routers.channels.radio_manager") as mock_ch_rm, - ): - mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc) - - await client.post("/api/channels/sync?max_channels=3") - - channel = await ChannelRepository.get_by_key(secret.hex().upper()) - assert channel.on_radio is True - - @pytest.mark.asyncio - async def test_sync_requires_connection(self, test_db, client): - """Sync returns 503 when radio is not connected.""" - with _patch_require_connected(): - response = await client.post("/api/channels/sync") - - assert response.status_code == 503 - - @pytest.mark.asyncio - async def test_sync_key_normalized_uppercase(self, test_db, client): - """Channel keys are normalized to uppercase hex.""" - secret = bytes.fromhex("aabbccddaabbccddaabbccddaabbccdd") - mock_mc = MagicMock() - - async def mock_get_channel(idx): - if idx == 0: - return _make_channel_info("Test", secret) - return _make_empty_channel() - - mock_mc.commands.get_channel = AsyncMock(side_effect=mock_get_channel) - radio_manager._meshcore = mock_mc - - with ( - _patch_require_connected(mock_mc), - patch("app.routers.channels.radio_manager") as mock_ch_rm, - ): - mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc) - - await client.post("/api/channels/sync?max_channels=3") - - channel = await ChannelRepository.get_by_key("AABBCCDDAABBCCDDAABBCCDDAABBCCDD") - assert channel is not None - - @pytest.mark.asyncio - async def test_sync_preserves_existing_flood_scope_override(self, test_db, client): - secret = bytes.fromhex("cafebabecafebabecafebabecafebabe") - key = secret.hex().upper() - await ChannelRepository.upsert(key=key, name="#flightless", is_hashtag=True, on_radio=False) - await ChannelRepository.update_flood_scope_override(key, "#Esperance") - - mock_mc = MagicMock() - - async def mock_get_channel(idx): - if idx == 0: - return _make_channel_info("#flightless", secret) - return _make_empty_channel() - - mock_mc.commands.get_channel = AsyncMock(side_effect=mock_get_channel) - radio_manager._meshcore = mock_mc - - with ( - _patch_require_connected(mock_mc), - patch("app.routers.channels.radio_manager") as mock_ch_rm, - ): - mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc) - - response = await client.post("/api/channels/sync?max_channels=3") - - assert response.status_code == 200 - channel = await ChannelRepository.get_by_key(key) - assert channel is not None - assert channel.flood_scope_override == "#Esperance" - - class TestChannelFloodScopeOverride: @pytest.mark.asyncio async def test_sets_channel_flood_scope_override(self, test_db, client): diff --git a/tests/test_contacts_router.py b/tests/test_contacts_router.py index 4206f43..8440df0 100644 --- a/tests/test_contacts_router.py +++ b/tests/test_contacts_router.py @@ -1,7 +1,7 @@ """Tests for the contacts router. -Verifies the contact CRUD endpoints, sync, mark-read, delete, -and add/remove from radio operations. +Verifies the live contact CRUD, analytics, mark-read, delete, +historical decrypt, and routing override endpoints. Uses httpx.AsyncClient with real in-memory SQLite database. """ @@ -10,10 +10,8 @@ from contextlib import asynccontextmanager from unittest.mock import AsyncMock, MagicMock, patch import pytest -from fastapi import HTTPException from meshcore import EventType -from app.radio import radio_manager from app.repository import ContactAdvertPathRepository, ContactRepository, MessageRepository # Sample 64-char hex public keys for testing @@ -32,25 +30,6 @@ def _noop_radio_operation(mc=None): return _ctx -def _patch_require_connected(mc=None, *, detail="Radio not connected"): - if mc is None: - return patch( - "app.dependencies.radio_manager.require_connected", - side_effect=HTTPException(status_code=503, detail=detail), - ) - return patch("app.dependencies.radio_manager.require_connected", return_value=mc) - - -@pytest.fixture(autouse=True) -def _reset_radio_state(): - """Save/restore radio_manager state so tests don't leak.""" - prev = radio_manager._meshcore - prev_lock = radio_manager._operation_lock - yield - radio_manager._meshcore = prev - radio_manager._operation_lock = prev_lock - - async def _insert_contact(public_key=KEY_A, name="Alice", on_radio=False, **overrides): """Insert a contact into the test database.""" data = { @@ -164,36 +143,6 @@ class TestCreateContact: assert contact.name == "NewName" -class TestGetContact: - """Test GET /api/contacts/{public_key}.""" - - @pytest.mark.asyncio - async def test_get_existing(self, test_db, client): - await _insert_contact(KEY_A, "Alice") - - response = await client.get(f"/api/contacts/{KEY_A}") - - assert response.status_code == 200 - assert response.json()["name"] == "Alice" - - @pytest.mark.asyncio - async def test_get_not_found(self, test_db, client): - response = await client.get(f"/api/contacts/{KEY_A}") - - assert response.status_code == 404 - - @pytest.mark.asyncio - async def test_get_ambiguous_prefix_returns_409(self, test_db, client): - # Insert two contacts that share a prefix - await _insert_contact("abcd12" + "00" * 29, "ContactA") - await _insert_contact("abcd12" + "ff" * 29, "ContactB") - - response = await client.get("/api/contacts/abcd12") - - assert response.status_code == 409 - assert "ambiguous" in response.json()["detail"].lower() - - class TestAdvertPaths: """Test repeater advert path endpoints.""" @@ -214,248 +163,6 @@ class TestAdvertPaths: assert data[0]["paths"][0]["path"] == "3344" assert data[0]["paths"][0]["next_hop"] == "33" - @pytest.mark.asyncio - async def test_get_contact_advert_paths_for_repeater(self, test_db, client): - repeater_key = KEY_A - await _insert_contact(repeater_key, "R1", type=2) - await ContactAdvertPathRepository.record_observation(repeater_key, "", 1000) - - response = await client.get(f"/api/contacts/{repeater_key}/advert-paths") - - assert response.status_code == 200 - data = response.json() - assert len(data) == 1 - assert data[0]["path"] == "" - assert data[0]["next_hop"] is None - - @pytest.mark.asyncio - async def test_get_contact_advert_paths_distinguishes_same_bytes_by_hop_count( - self, test_db, client - ): - repeater_key = KEY_A - await _insert_contact(repeater_key, "R1", type=2) - await ContactAdvertPathRepository.record_observation( - repeater_key, "aa00", 1000, hop_count=1 - ) - await ContactAdvertPathRepository.record_observation( - repeater_key, "aa00", 1010, hop_count=2 - ) - - response = await client.get(f"/api/contacts/{repeater_key}/advert-paths") - - assert response.status_code == 200 - data = response.json() - assert [(item["path"], item["path_len"], item["next_hop"]) for item in data] == [ - ("aa00", 2, "aa"), - ("aa00", 1, "aa00"), - ] - - @pytest.mark.asyncio - async def test_get_contact_advert_paths_works_for_non_repeater(self, test_db, client): - await _insert_contact(KEY_A, "Alice", type=1) - - response = await client.get(f"/api/contacts/{KEY_A}/advert-paths") - - assert response.status_code == 200 - assert response.json() == [] - - -class TestContactDetail: - """Test GET /api/contacts/{public_key}/detail.""" - - @pytest.mark.asyncio - async def test_detail_returns_full_profile(self, test_db, client): - """Happy path: contact with DMs, channel messages, name history, advert paths.""" - await _insert_contact(KEY_A, "Alice", type=1) - - # Add some DMs - await MessageRepository.create( - msg_type="PRIV", - text="hi", - conversation_key=KEY_A, - sender_timestamp=1000, - received_at=1000, - sender_key=KEY_A, - ) - await MessageRepository.create( - msg_type="PRIV", - text="hello", - conversation_key=KEY_A, - sender_timestamp=1001, - received_at=1001, - outgoing=True, - ) - - # Add a channel message attributed to this contact - from app.repository import ContactNameHistoryRepository - - await MessageRepository.create( - msg_type="CHAN", - text="Alice: yo", - conversation_key="CHAN_KEY_0" * 2, - sender_timestamp=1002, - received_at=1002, - sender_name="Alice", - sender_key=KEY_A, - ) - - # Record name history - await ContactNameHistoryRepository.record_name(KEY_A, "Alice", 1000) - await ContactNameHistoryRepository.record_name(KEY_A, "AliceOld", 500) - - # Record advert paths - await ContactAdvertPathRepository.record_observation(KEY_A, "1122", 1000) - await ContactAdvertPathRepository.record_observation(KEY_A, "", 900) - - response = await client.get(f"/api/contacts/{KEY_A}/detail") - - assert response.status_code == 200 - data = response.json() - assert data["contact"]["public_key"] == KEY_A - assert data["dm_message_count"] == 2 - assert data["channel_message_count"] == 1 - assert len(data["name_history"]) == 2 - assert data["name_history"][0]["name"] == "Alice" # most recent first - assert len(data["advert_paths"]) == 2 - assert len(data["most_active_rooms"]) == 1 - - @pytest.mark.asyncio - async def test_detail_contact_not_found(self, test_db, client): - response = await client.get(f"/api/contacts/{KEY_A}/detail") - - assert response.status_code == 404 - - @pytest.mark.asyncio - async def test_detail_with_no_activity(self, test_db, client): - """Contact with no messages or paths returns zero counts and empty lists.""" - await _insert_contact(KEY_A, "Alice") - - response = await client.get(f"/api/contacts/{KEY_A}/detail") - - assert response.status_code == 200 - data = response.json() - assert data["dm_message_count"] == 0 - assert data["channel_message_count"] == 0 - assert data["most_active_rooms"] == [] - assert data["advert_paths"] == [] - assert data["advert_frequency"] is None - assert data["nearest_repeaters"] == [] - - @pytest.mark.asyncio - async def test_detail_nearest_repeaters_resolved(self, test_db, client): - """Nearest repeaters are resolved from first-hop prefixes in advert paths.""" - await _insert_contact(KEY_A, "Alice", type=1) - # Create a repeater whose key starts with "bb" - await _insert_contact(KEY_B, "Relay1", type=2) - - # Record advert paths that go through KEY_B's prefix - await ContactAdvertPathRepository.record_observation(KEY_A, "bb1122", 1000) - await ContactAdvertPathRepository.record_observation(KEY_A, "bb3344", 1010) - - response = await client.get(f"/api/contacts/{KEY_A}/detail") - - assert response.status_code == 200 - data = response.json() - assert len(data["nearest_repeaters"]) == 1 - repeater = data["nearest_repeaters"][0] - assert repeater["public_key"] == KEY_B - assert repeater["name"] == "Relay1" - assert repeater["heard_count"] == 2 - - -class TestNameOnlyContactDetail: - """Test GET /api/contacts/name-detail.""" - - @pytest.mark.asyncio - async def test_name_detail_returns_channel_stats(self, test_db, client): - chan_a = "11" * 16 - chan_b = "22" * 16 - - await MessageRepository.create( - msg_type="CHAN", - text="Mystery: hi", - conversation_key=chan_a, - sender_timestamp=1000, - received_at=1000, - sender_name="Mystery", - ) - await MessageRepository.create( - msg_type="CHAN", - text="Mystery: hello", - conversation_key=chan_a, - sender_timestamp=1001, - received_at=1001, - sender_name="Mystery", - ) - await MessageRepository.create( - msg_type="CHAN", - text="Mystery: ping", - conversation_key=chan_b, - sender_timestamp=1002, - received_at=1002, - sender_name="Mystery", - ) - - response = await client.get("/api/contacts/name-detail", params={"name": "Mystery"}) - - assert response.status_code == 200 - data = response.json() - assert data["name"] == "Mystery" - assert data["channel_message_count"] == 3 - assert len(data["most_active_rooms"]) == 2 - assert data["most_active_rooms"][0]["channel_key"] == chan_a - assert data["most_active_rooms"][0]["message_count"] == 2 - - @pytest.mark.asyncio - async def test_name_detail_with_no_activity_returns_empty(self, test_db, client): - response = await client.get("/api/contacts/name-detail", params={"name": "Mystery"}) - - assert response.status_code == 200 - data = response.json() - assert data["name"] == "Mystery" - assert data["channel_message_count"] == 0 - assert data["most_active_rooms"] == [] - - @pytest.mark.asyncio - async def test_detail_nearest_repeaters_use_full_multibyte_next_hop(self, test_db, client): - """Nearest repeater resolution should distinguish multi-byte hops with the same first byte.""" - await _insert_contact(KEY_A, "Alice", type=1) - repeater_1 = "bb11" + "aa" * 30 - repeater_2 = "bb22" + "cc" * 30 - await _insert_contact(repeater_1, "Relay11", type=2) - await _insert_contact(repeater_2, "Relay22", type=2) - - await ContactAdvertPathRepository.record_observation(KEY_A, "bb221122", 1000, hop_count=2) - await ContactAdvertPathRepository.record_observation(KEY_A, "bb223344", 1010, hop_count=2) - - response = await client.get(f"/api/contacts/{KEY_A}/detail") - - assert response.status_code == 200 - data = response.json() - assert len(data["nearest_repeaters"]) == 1 - repeater = data["nearest_repeaters"][0] - assert repeater["public_key"] == repeater_2 - assert repeater["name"] == "Relay22" - assert repeater["heard_count"] == 2 - - @pytest.mark.asyncio - async def test_detail_advert_frequency_computed(self, test_db, client): - """Advert frequency is computed from path observations over time span.""" - await _insert_contact(KEY_A, "Alice") - - # 10 observations over 1 hour (3600s) - for i in range(10): - path_hex = f"{i:02x}" * 2 # unique paths to avoid upsert - await ContactAdvertPathRepository.record_observation(KEY_A, path_hex, 1000 + i * 360) - - response = await client.get(f"/api/contacts/{KEY_A}/detail") - - assert response.status_code == 200 - data = response.json() - # 10 observations / (3240s / 3600) ≈ 11.11/hr - assert data["advert_frequency"] is not None - assert data["advert_frequency"] > 0 - class TestContactAnalytics: """Test GET /api/contacts/analytics.""" @@ -649,77 +356,6 @@ class TestDeleteContact: mock_mc.commands.remove_contact.assert_called_once_with(mock_radio_contact) -class TestSyncContacts: - """Test POST /api/contacts/sync.""" - - @pytest.mark.asyncio - async def test_sync_from_radio(self, test_db, client): - mock_mc = MagicMock() - mock_result = MagicMock() - mock_result.type = EventType.OK - mock_result.payload = { - KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0}, - KEY_B: {"adv_name": "Bob", "type": 1, "flags": 0}, - } - mock_mc.commands.get_contacts = AsyncMock(return_value=mock_result) - - radio_manager._meshcore = mock_mc - with ( - _patch_require_connected(mock_mc), - patch("app.websocket.broadcast_event") as mock_broadcast, - ): - response = await client.post("/api/contacts/sync") - - assert response.status_code == 200 - assert response.json()["synced"] == 2 - - # Verify contacts are in real DB - alice = await ContactRepository.get_by_key(KEY_A) - assert alice is not None - assert alice.name == "Alice" - assert mock_broadcast.call_count == 2 - assert [call.args[0] for call in mock_broadcast.call_args_list] == ["contact", "contact"] - assert {call.args[1]["public_key"] for call in mock_broadcast.call_args_list} == { - KEY_A, - KEY_B, - } - - @pytest.mark.asyncio - async def test_sync_requires_connection(self, test_db, client): - with _patch_require_connected(): - response = await client.post("/api/contacts/sync") - - assert response.status_code == 503 - - @pytest.mark.asyncio - async def test_sync_claims_prefix_messages(self, test_db, client): - """Syncing contacts promotes prefix-stored DM messages to the full key.""" - await MessageRepository.create( - msg_type="PRIV", - text="hello from prefix", - received_at=1700000000, - conversation_key=KEY_A[:12], - sender_timestamp=1700000000, - ) - - mock_mc = MagicMock() - mock_result = MagicMock() - mock_result.type = EventType.OK - mock_result.payload = {KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0}} - mock_mc.commands.get_contacts = AsyncMock(return_value=mock_result) - - radio_manager._meshcore = mock_mc - with _patch_require_connected(mock_mc): - response = await client.post("/api/contacts/sync") - - assert response.status_code == 200 - assert response.json()["synced"] == 1 - - messages = await MessageRepository.get_all(conversation_key=KEY_A) - assert len(messages) == 1 - assert messages[0].conversation_key == KEY_A.lower() - - class TestCreateContactWithHistorical: """Test POST /api/contacts with try_historical=true.""" @@ -914,110 +550,3 @@ class TestRoutingOverride: assert response.status_code == 400 assert "same width" in response.json()["detail"].lower() - - -class TestAddRemoveRadio: - """Test add-to-radio and remove-from-radio endpoints.""" - - @pytest.mark.asyncio - async def test_add_to_radio(self, test_db, client): - await _insert_contact(KEY_A) - - mock_mc = MagicMock() - mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None) # Not on radio - mock_result = MagicMock() - mock_result.type = EventType.OK - mock_mc.commands.add_contact = AsyncMock(return_value=mock_result) - - radio_manager._meshcore = mock_mc - with _patch_require_connected(mock_mc): - response = await client.post(f"/api/contacts/{KEY_A}/add-to-radio") - - assert response.status_code == 200 - mock_mc.commands.add_contact.assert_called_once() - - # Verify on_radio flag updated in DB - contact = await ContactRepository.get_by_key(KEY_A) - assert contact.on_radio is True - - @pytest.mark.asyncio - async def test_add_to_radio_preserves_stored_out_path_hash_mode(self, test_db, client): - await _insert_contact( - KEY_A, - last_path="aa00bb00", - last_path_len=2, - out_path_hash_mode=1, - ) - - mock_mc = MagicMock() - mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None) - mock_result = MagicMock() - mock_result.type = EventType.OK - mock_mc.commands.add_contact = AsyncMock(return_value=mock_result) - - radio_manager._meshcore = mock_mc - with _patch_require_connected(mock_mc): - response = await client.post(f"/api/contacts/{KEY_A}/add-to-radio") - - assert response.status_code == 200 - payload = mock_mc.commands.add_contact.call_args.args[0] - assert payload["out_path"] == "aa00bb00" - assert payload["out_path_len"] == 2 - assert payload["out_path_hash_mode"] == 1 - - @pytest.mark.asyncio - async def test_add_already_on_radio(self, test_db, client): - """Adding a contact already on radio repairs the DB flag and skips add_contact.""" - await _insert_contact(KEY_A, on_radio=False) - - mock_mc = MagicMock() - mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # On radio - - radio_manager._meshcore = mock_mc - with _patch_require_connected(mock_mc): - response = await client.post(f"/api/contacts/{KEY_A}/add-to-radio") - - assert response.status_code == 200 - assert "already" in response.json()["message"].lower() - contact = await ContactRepository.get_by_key(KEY_A) - assert contact is not None - assert contact.on_radio is True - mock_mc.commands.add_contact.assert_not_called() - - @pytest.mark.asyncio - async def test_remove_from_radio(self, test_db, client): - await _insert_contact(KEY_A, on_radio=True) - - mock_radio_contact = MagicMock() - mock_mc = MagicMock() - mock_mc.get_contact_by_key_prefix = MagicMock(return_value=mock_radio_contact) - mock_result = MagicMock() - mock_result.type = EventType.OK - mock_mc.commands.remove_contact = AsyncMock(return_value=mock_result) - - radio_manager._meshcore = mock_mc - with _patch_require_connected(mock_mc): - response = await client.post(f"/api/contacts/{KEY_A}/remove-from-radio") - - assert response.status_code == 200 - mock_mc.commands.remove_contact.assert_called_once_with(mock_radio_contact) - - # Verify on_radio flag updated in DB - contact = await ContactRepository.get_by_key(KEY_A) - assert contact.on_radio is False - - @pytest.mark.asyncio - async def test_add_requires_connection(self, test_db, client): - with _patch_require_connected(): - response = await client.post(f"/api/contacts/{KEY_A}/add-to-radio") - - assert response.status_code == 503 - - @pytest.mark.asyncio - async def test_remove_not_found(self, test_db, client): - mock_mc = MagicMock() - - with _patch_require_connected(mock_mc): - response = await client.post(f"/api/contacts/{KEY_A}/remove-from-radio") - - assert response.status_code == 404