Cull a bunch of unused functions

This commit is contained in:
Jack Kingsman
2026-03-12 18:12:27 -07:00
parent 74c13d194c
commit 358589bd66
10 changed files with 22 additions and 990 deletions

View File

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

View File

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

View File

@@ -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)."""

View File

@@ -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)."""

View File

@@ -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<ContactAdvertPathSummary[]>(
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
),
getContactAdvertPaths: (publicKey: string, limit = 10) =>
fetchJson<ContactAdvertPath[]>(`/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<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`);
},
getContactDetail: (publicKey: string) =>
fetchJson<ContactDetail>(`/contacts/${publicKey}/detail`),
getNameOnlyContactDetail: (name: string) =>
fetchJson<NameOnlyContactDetail>(`/contacts/name-detail?name=${encodeURIComponent(name)}`),
deleteContact: (publicKey: string) =>
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE',

View File

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

View File

@@ -21,6 +21,7 @@ async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
export interface HealthStatus {
radio_connected: boolean;
radio_initializing: boolean;
connection_info: string | null;
}
@@ -267,7 +268,7 @@ export async function ensureFlightlessChannel(): Promise<Channel> {
}
/**
* 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 ---

View File

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

View File

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

View File

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