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
+12 -3
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));
+4 -9
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 ---
+1 -5
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);
+2 -277
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):
+2 -473
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