mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Cull a bunch of unused functions
This commit is contained in:
@@ -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) |
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user