mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 01:03:34 +02:00
More stable MC object reference and proper radio disconnection detection
This commit is contained in:
@@ -12,6 +12,7 @@ import httpx
|
||||
import pytest
|
||||
|
||||
from app.database import Database
|
||||
from app.radio import radio_manager
|
||||
from app.repository import (
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
@@ -20,6 +21,16 @@ from app.repository import (
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_db():
|
||||
"""Create an in-memory test database with schema + migrations."""
|
||||
@@ -109,6 +120,29 @@ class TestHealthEndpoint:
|
||||
assert data["connection_info"] is None
|
||||
|
||||
|
||||
class TestRadioDisconnectedHandler:
|
||||
"""Test that RadioDisconnectedError maps to 503."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_race_returns_503(self, test_db, client):
|
||||
"""If radio disconnects between require_connected() and lock acquisition, return 503."""
|
||||
pub_key = "ab" * 32
|
||||
await _insert_contact(pub_key, "Alice")
|
||||
|
||||
# require_connected() passes, but _meshcore is None when radio_operation() checks
|
||||
radio_manager._meshcore = None
|
||||
with patch("app.dependencies.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = MagicMock()
|
||||
|
||||
response = await client.post(
|
||||
"/api/messages/direct", json={"destination": pub_key, "text": "Hi"}
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert "not connected" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
class TestMessagesEndpoint:
|
||||
"""Test message-related endpoints."""
|
||||
|
||||
@@ -161,6 +195,7 @@ class TestMessagesEndpoint:
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
with (
|
||||
patch("app.dependencies.radio_manager") as mock_rm,
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()),
|
||||
@@ -204,6 +239,7 @@ class TestMessagesEndpoint:
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
with (
|
||||
patch("app.dependencies.radio_manager") as mock_rm,
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
@@ -260,6 +296,7 @@ class TestMessagesEndpoint:
|
||||
return_value=MagicMock(type=MagicMock(name="OK"), payload={"expected_ack": b"\x00\x01"})
|
||||
)
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
with (
|
||||
patch("app.dependencies.radio_manager") as mock_rm,
|
||||
patch("app.routers.messages.MessageRepository") as mock_msg_repo,
|
||||
@@ -296,6 +333,7 @@ class TestMessagesEndpoint:
|
||||
return_value=MagicMock(type=MagicMock(name="OK"), payload={})
|
||||
)
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
with (
|
||||
patch("app.dependencies.radio_manager") as mock_rm,
|
||||
patch("app.routers.messages.MessageRepository") as mock_msg_repo,
|
||||
@@ -355,6 +393,7 @@ class TestMessagesEndpoint:
|
||||
return_value=MagicMock(type=EventType.MSG_SENT, payload={})
|
||||
)
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
with patch("app.dependencies.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
|
||||
@@ -14,6 +14,7 @@ import pytest
|
||||
from meshcore import EventType
|
||||
|
||||
from app.database import Database
|
||||
from app.radio import radio_manager
|
||||
from app.repository import ContactRepository, MessageRepository
|
||||
|
||||
# Sample 64-char hex public keys for testing
|
||||
@@ -22,9 +23,24 @@ KEY_B = "bb" * 32 # bbbb...bb
|
||||
KEY_C = "cc" * 32 # cccc...cc
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _noop_radio_operation(*_args, **_kwargs):
|
||||
def _noop_radio_operation(mc=None):
|
||||
"""Factory for a no-op radio_operation context manager that yields mc."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def _ctx(*_args, **_kwargs):
|
||||
yield mc
|
||||
|
||||
return _ctx
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -225,7 +241,7 @@ class TestDeleteContact:
|
||||
with patch("app.routers.contacts.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = False
|
||||
mock_rm.meshcore = None
|
||||
mock_rm.radio_operation = _noop_radio_operation
|
||||
mock_rm.radio_operation = _noop_radio_operation()
|
||||
|
||||
response = await client.delete(f"/api/contacts/{KEY_A}")
|
||||
|
||||
@@ -255,7 +271,7 @@ class TestDeleteContact:
|
||||
with patch("app.routers.contacts.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
mock_rm.radio_operation = _noop_radio_operation
|
||||
mock_rm.radio_operation = _noop_radio_operation(mock_mc)
|
||||
|
||||
response = await client.delete(f"/api/contacts/{KEY_A}")
|
||||
|
||||
@@ -277,6 +293,7 @@ class TestSyncContacts:
|
||||
}
|
||||
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_result)
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
with patch("app.dependencies.radio_manager") as mock_dep_rm:
|
||||
mock_dep_rm.is_connected = True
|
||||
mock_dep_rm.meshcore = mock_mc
|
||||
@@ -318,6 +335,7 @@ class TestSyncContacts:
|
||||
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("app.dependencies.radio_manager") as mock_dep_rm:
|
||||
mock_dep_rm.is_connected = True
|
||||
mock_dep_rm.meshcore = mock_mc
|
||||
@@ -345,6 +363,7 @@ class TestAddRemoveRadio:
|
||||
mock_result.type = EventType.OK
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=mock_result)
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
with patch("app.dependencies.radio_manager") as mock_dep_rm:
|
||||
mock_dep_rm.is_connected = True
|
||||
mock_dep_rm.meshcore = mock_mc
|
||||
@@ -366,6 +385,7 @@ class TestAddRemoveRadio:
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # On radio
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
with patch("app.dependencies.radio_manager") as mock_dep_rm:
|
||||
mock_dep_rm.is_connected = True
|
||||
mock_dep_rm.meshcore = mock_mc
|
||||
@@ -386,6 +406,7 @@ class TestAddRemoveRadio:
|
||||
mock_result.type = EventType.OK
|
||||
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_result)
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
with patch("app.dependencies.radio_manager") as mock_dep_rm:
|
||||
mock_dep_rm.is_connected = True
|
||||
mock_dep_rm.meshcore = mock_mc
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.radio import RadioOperationBusyError, radio_manager
|
||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager
|
||||
from app.radio_sync import is_polling_paused
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ def reset_radio_operation_state():
|
||||
"""Reset shared radio operation lock state before/after each test."""
|
||||
prev_meshcore = radio_manager._meshcore
|
||||
radio_manager._operation_lock = None
|
||||
radio_manager._meshcore = None
|
||||
# Default to a non-None MagicMock so radio_operation() doesn't raise
|
||||
# RadioDisconnectedError for tests that only exercise locking.
|
||||
radio_manager._meshcore = MagicMock()
|
||||
|
||||
import app.radio_sync as radio_sync
|
||||
|
||||
@@ -123,3 +125,47 @@ class TestRadioOperationLock:
|
||||
assert is_polling_paused()
|
||||
|
||||
assert not is_polling_paused()
|
||||
|
||||
|
||||
class TestRadioOperationYield:
|
||||
"""Validate that radio_operation() yields the current meshcore instance."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_radio_operation_yields_current_meshcore(self):
|
||||
"""The yielded value is the current _meshcore at lock-acquisition time."""
|
||||
mc = MagicMock()
|
||||
radio_manager._meshcore = mc
|
||||
|
||||
async with radio_manager.radio_operation("test_yield") as yielded:
|
||||
assert yielded is mc
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_radio_operation_raises_when_disconnected_after_lock(self):
|
||||
"""RadioDisconnectedError is raised when _meshcore is None after acquiring the lock."""
|
||||
radio_manager._meshcore = None
|
||||
|
||||
with pytest.raises(RadioDisconnectedError):
|
||||
async with radio_manager.radio_operation("test_disconnected"):
|
||||
pass # pragma: no cover
|
||||
|
||||
# Lock must be released even after the error
|
||||
radio_manager._meshcore = MagicMock()
|
||||
async with radio_manager.radio_operation("after_error", blocking=False):
|
||||
pass
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_radio_operation_yields_fresh_reference_after_swap(self):
|
||||
"""If _meshcore is swapped between pre-check and lock acquisition,
|
||||
the yielded value is the new (current) instance, not the old one."""
|
||||
old_mc = MagicMock(name="old")
|
||||
new_mc = MagicMock(name="new")
|
||||
|
||||
# Start with old_mc
|
||||
radio_manager._meshcore = old_mc
|
||||
|
||||
# Simulate a reconnect swapping _meshcore before the caller enters the block
|
||||
radio_manager._meshcore = new_mc
|
||||
|
||||
async with radio_manager.radio_operation("test_swap") as yielded:
|
||||
assert yielded is new_mc
|
||||
assert yielded is not old_mc
|
||||
|
||||
+35
-12
@@ -8,7 +8,7 @@ import pytest
|
||||
from fastapi import HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.radio import RadioManager
|
||||
from app.radio import RadioManager, radio_manager
|
||||
from app.routers.radio import (
|
||||
PrivateKeyUpdate,
|
||||
RadioConfigResponse,
|
||||
@@ -30,9 +30,24 @@ def _radio_result(event_type=EventType.OK, payload=None):
|
||||
return result
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _noop_radio_operation(*_args, **_kwargs):
|
||||
def _noop_radio_operation(mc=None):
|
||||
"""Factory for a no-op radio_operation context manager that yields mc."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def _ctx(*_args, **_kwargs):
|
||||
yield mc
|
||||
|
||||
return _ctx
|
||||
|
||||
|
||||
@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 _mock_meshcore_with_info():
|
||||
@@ -100,6 +115,7 @@ class TestUpdateRadioConfig:
|
||||
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.radio.sync_radio_time", new_callable=AsyncMock) as mock_sync_time,
|
||||
patch(
|
||||
"app.routers.radio.get_radio_config", new_callable=AsyncMock, return_value=expected
|
||||
@@ -132,7 +148,10 @@ class TestPrivateKeyImport:
|
||||
mc.commands.import_private_key = AsyncMock(
|
||||
return_value=_radio_result(EventType.ERROR, {"error": "failed"})
|
||||
)
|
||||
with patch("app.routers.radio.require_connected", return_value=mc):
|
||||
with (
|
||||
patch("app.routers.radio.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await set_private_key(PrivateKeyUpdate(private_key="aa" * 64))
|
||||
|
||||
@@ -142,6 +161,7 @@ class TestPrivateKeyImport:
|
||||
class TestAdvertise:
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_when_send_fails(self):
|
||||
radio_manager._meshcore = MagicMock()
|
||||
with (
|
||||
patch("app.routers.radio.require_connected"),
|
||||
patch(
|
||||
@@ -170,6 +190,7 @@ class TestAdvertise:
|
||||
return True
|
||||
|
||||
isolated_manager = RadioManager()
|
||||
isolated_manager._meshcore = MagicMock()
|
||||
with (
|
||||
patch("app.routers.radio.require_connected"),
|
||||
patch("app.routers.radio.radio_manager", isolated_manager),
|
||||
@@ -187,17 +208,19 @@ class TestAdvertise:
|
||||
class TestRebootAndReconnect:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reboot_connected_sends_reboot_command(self):
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.commands.reboot = AsyncMock()
|
||||
|
||||
mock_rm = MagicMock()
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = MagicMock()
|
||||
mock_rm.meshcore.commands.reboot = AsyncMock()
|
||||
mock_rm.radio_operation = _noop_radio_operation
|
||||
mock_rm.meshcore = mock_mc
|
||||
mock_rm.radio_operation = _noop_radio_operation(mock_mc)
|
||||
|
||||
with patch("app.routers.radio.radio_manager", mock_rm):
|
||||
result = await reboot_radio()
|
||||
|
||||
assert result["status"] == "ok"
|
||||
mock_rm.meshcore.commands.reboot.assert_awaited_once()
|
||||
mock_mc.commands.reboot.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reboot_returns_pending_when_reconnect_in_progress(self):
|
||||
@@ -205,7 +228,7 @@ class TestRebootAndReconnect:
|
||||
mock_rm.is_connected = False
|
||||
mock_rm.meshcore = None
|
||||
mock_rm.is_reconnecting = True
|
||||
mock_rm.radio_operation = _noop_radio_operation
|
||||
mock_rm.radio_operation = _noop_radio_operation()
|
||||
|
||||
with patch("app.routers.radio.radio_manager", mock_rm):
|
||||
result = await reboot_radio()
|
||||
@@ -221,7 +244,7 @@ class TestRebootAndReconnect:
|
||||
mock_rm.is_reconnecting = False
|
||||
mock_rm.reconnect = AsyncMock(return_value=True)
|
||||
mock_rm.post_connect_setup = AsyncMock()
|
||||
mock_rm.radio_operation = _noop_radio_operation
|
||||
mock_rm.radio_operation = _noop_radio_operation()
|
||||
|
||||
with patch("app.routers.radio.radio_manager", mock_rm):
|
||||
result = await reboot_radio()
|
||||
@@ -235,7 +258,7 @@ class TestRebootAndReconnect:
|
||||
async def test_reconnect_returns_already_connected(self):
|
||||
mock_rm = MagicMock()
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.radio_operation = _noop_radio_operation
|
||||
mock_rm.radio_operation = _noop_radio_operation()
|
||||
|
||||
with patch("app.routers.radio.radio_manager", mock_rm):
|
||||
result = await reconnect_radio()
|
||||
@@ -249,7 +272,7 @@ class TestRebootAndReconnect:
|
||||
mock_rm.is_connected = False
|
||||
mock_rm.is_reconnecting = False
|
||||
mock_rm.reconnect = AsyncMock(return_value=False)
|
||||
mock_rm.radio_operation = _noop_radio_operation
|
||||
mock_rm.radio_operation = _noop_radio_operation()
|
||||
|
||||
with patch("app.routers.radio.radio_manager", mock_rm):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
|
||||
+58
-49
@@ -11,6 +11,7 @@ from meshcore import EventType
|
||||
|
||||
from app.database import Database
|
||||
from app.models import Favorite
|
||||
from app.radio import radio_manager
|
||||
from app.radio_sync import (
|
||||
is_polling_paused,
|
||||
pause_polling,
|
||||
@@ -45,14 +46,19 @@ async def test_db():
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_sync_state():
|
||||
"""Reset polling pause state and sync timestamp before and after each test."""
|
||||
"""Reset polling pause state, sync timestamp, and radio_manager before/after each test."""
|
||||
import app.radio_sync as radio_sync
|
||||
|
||||
prev_mc = radio_manager._meshcore
|
||||
prev_lock = radio_manager._operation_lock
|
||||
|
||||
radio_sync._polling_pause_count = 0
|
||||
radio_sync._last_contact_sync = 0.0
|
||||
yield
|
||||
radio_sync._polling_pause_count = 0
|
||||
radio_sync._last_contact_sync = 0.0
|
||||
radio_manager._meshcore = prev_mc
|
||||
radio_manager._operation_lock = prev_lock
|
||||
|
||||
|
||||
KEY_A = "aa" * 32
|
||||
@@ -236,11 +242,8 @@ class TestSyncRecentContactsToRadio:
|
||||
mock_result.type = EventType.OK
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("app.radio_sync.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
|
||||
result = await sync_recent_contacts_to_radio()
|
||||
radio_manager._meshcore = mock_mc
|
||||
result = await sync_recent_contacts_to_radio()
|
||||
|
||||
assert result["loaded"] == 2
|
||||
# Verify contacts are now marked as on_radio in DB
|
||||
@@ -268,11 +271,8 @@ class TestSyncRecentContactsToRadio:
|
||||
mock_result.type = EventType.OK
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("app.radio_sync.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
|
||||
result = await sync_recent_contacts_to_radio()
|
||||
radio_manager._meshcore = mock_mc
|
||||
result = await sync_recent_contacts_to_radio()
|
||||
|
||||
assert result["loaded"] == 2
|
||||
# KEY_A (favorite) should be loaded first, then KEY_B (most recent)
|
||||
@@ -298,11 +298,8 @@ class TestSyncRecentContactsToRadio:
|
||||
mock_result.type = EventType.OK
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("app.radio_sync.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
|
||||
result = await sync_recent_contacts_to_radio()
|
||||
radio_manager._meshcore = mock_mc
|
||||
result = await sync_recent_contacts_to_radio()
|
||||
|
||||
assert result["loaded"] == 2
|
||||
loaded_keys = [
|
||||
@@ -319,11 +316,8 @@ class TestSyncRecentContactsToRadio:
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # Found
|
||||
mock_mc.commands.add_contact = AsyncMock()
|
||||
|
||||
with patch("app.radio_sync.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
|
||||
result = await sync_recent_contacts_to_radio()
|
||||
radio_manager._meshcore = mock_mc
|
||||
result = await sync_recent_contacts_to_radio()
|
||||
|
||||
assert result["loaded"] == 0
|
||||
assert result["already_on_radio"] == 1
|
||||
@@ -335,34 +329,30 @@ class TestSyncRecentContactsToRadio:
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
|
||||
with patch("app.radio_sync.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
radio_manager._meshcore = mock_mc
|
||||
|
||||
# First call succeeds
|
||||
result1 = await sync_recent_contacts_to_radio()
|
||||
assert "throttled" not in result1
|
||||
# First call succeeds
|
||||
result1 = await sync_recent_contacts_to_radio()
|
||||
assert "throttled" not in result1
|
||||
|
||||
# Second call is throttled
|
||||
result2 = await sync_recent_contacts_to_radio()
|
||||
assert result2["throttled"] is True
|
||||
assert result2["loaded"] == 0
|
||||
# Second call is throttled
|
||||
result2 = await sync_recent_contacts_to_radio()
|
||||
assert result2["throttled"] is True
|
||||
assert result2["loaded"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_force_bypasses_throttle(self, test_db):
|
||||
"""force=True bypasses the throttle window."""
|
||||
mock_mc = MagicMock()
|
||||
|
||||
with patch("app.radio_sync.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
radio_manager._meshcore = mock_mc
|
||||
|
||||
# First call
|
||||
await sync_recent_contacts_to_radio()
|
||||
# First call
|
||||
await sync_recent_contacts_to_radio()
|
||||
|
||||
# Forced second call is not throttled
|
||||
result = await sync_recent_contacts_to_radio(force=True)
|
||||
assert "throttled" not in result
|
||||
# Forced second call is not throttled
|
||||
result = await sync_recent_contacts_to_radio(force=True)
|
||||
assert "throttled" not in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_connected_returns_error(self):
|
||||
@@ -384,11 +374,8 @@ class TestSyncRecentContactsToRadio:
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # Found
|
||||
|
||||
with patch("app.radio_sync.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
|
||||
result = await sync_recent_contacts_to_radio()
|
||||
radio_manager._meshcore = mock_mc
|
||||
result = await sync_recent_contacts_to_radio()
|
||||
|
||||
assert result["already_on_radio"] == 1
|
||||
# Should update the flag since contact.on_radio was False
|
||||
@@ -407,15 +394,37 @@ class TestSyncRecentContactsToRadio:
|
||||
mock_result.payload = {"error": "Radio full"}
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=mock_result)
|
||||
|
||||
with patch("app.radio_sync.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
|
||||
result = await sync_recent_contacts_to_radio()
|
||||
radio_manager._meshcore = mock_mc
|
||||
result = await sync_recent_contacts_to_radio()
|
||||
|
||||
assert result["loaded"] == 0
|
||||
assert result["failed"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_post_lock_meshcore_after_swap(self, test_db):
|
||||
"""If _meshcore is swapped between pre-check and lock acquisition,
|
||||
the function uses the new (post-lock) instance, not the stale one."""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
|
||||
old_mc = MagicMock(name="old_mc")
|
||||
new_mc = MagicMock(name="new_mc")
|
||||
new_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
mock_result = MagicMock()
|
||||
mock_result.type = EventType.OK
|
||||
new_mc.commands.add_contact = AsyncMock(return_value=mock_result)
|
||||
|
||||
# Pre-check sees old_mc (truthy, passes is_connected guard)
|
||||
radio_manager._meshcore = old_mc
|
||||
# Simulate reconnect swapping _meshcore before lock acquisition
|
||||
radio_manager._meshcore = new_mc
|
||||
|
||||
result = await sync_recent_contacts_to_radio()
|
||||
|
||||
assert result["loaded"] == 1
|
||||
# new_mc was used, not old_mc
|
||||
new_mc.commands.add_contact.assert_called_once()
|
||||
old_mc.commands.add_contact.assert_not_called()
|
||||
|
||||
|
||||
class TestSyncAndOffloadContacts:
|
||||
"""Test sync_and_offload_contacts: pull contacts from radio, save to DB, remove from radio."""
|
||||
|
||||
@@ -8,6 +8,7 @@ from meshcore import EventType
|
||||
|
||||
from app.database import Database
|
||||
from app.models import CommandRequest, TelemetryRequest
|
||||
from app.radio import radio_manager
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.contacts import (
|
||||
_fetch_repeater_response,
|
||||
@@ -23,6 +24,16 @@ KEY_A = "aa" * 32
|
||||
_MONOTONIC = "app.routers.contacts._monotonic"
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_db():
|
||||
"""Create an in-memory test database with schema + migrations."""
|
||||
@@ -263,7 +274,10 @@ class TestTelemetryRoute:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_404_when_contact_missing(self, test_db):
|
||||
mc = _mock_mc()
|
||||
with patch("app.routers.contacts.require_connected", return_value=mc):
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await request_telemetry(KEY_A, TelemetryRequest(password="pw"))
|
||||
|
||||
@@ -274,7 +288,10 @@ class TestTelemetryRoute:
|
||||
mc = _mock_mc()
|
||||
await _insert_contact(KEY_A, name="Client", contact_type=1)
|
||||
|
||||
with patch("app.routers.contacts.require_connected", return_value=mc):
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await request_telemetry(KEY_A, TelemetryRequest(password="pw"))
|
||||
|
||||
@@ -289,6 +306,7 @@ class TestTelemetryRoute:
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(
|
||||
"app.routers.contacts.prepare_repeater_connection",
|
||||
new_callable=AsyncMock,
|
||||
@@ -329,6 +347,7 @@ class TestTelemetryRoute:
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(
|
||||
"app.routers.contacts.prepare_repeater_connection",
|
||||
new_callable=AsyncMock,
|
||||
@@ -406,6 +425,7 @@ class TestTelemetryRoute:
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(
|
||||
"app.routers.contacts.prepare_repeater_connection",
|
||||
new_callable=AsyncMock,
|
||||
@@ -462,6 +482,7 @@ class TestTelemetryRoute:
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(
|
||||
"app.routers.contacts.prepare_repeater_connection",
|
||||
new_callable=AsyncMock,
|
||||
@@ -485,7 +506,10 @@ class TestRepeaterCommandRoute:
|
||||
return_value=_radio_result(EventType.ERROR, {"err": "bad"})
|
||||
)
|
||||
|
||||
with patch("app.routers.contacts.require_connected", return_value=mc):
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await send_repeater_command(KEY_A, CommandRequest(command="ver"))
|
||||
|
||||
@@ -502,6 +526,7 @@ class TestRepeaterCommandRoute:
|
||||
# Expire the deadline after a couple of ticks
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=[0.0, 5.0, 25.0]),
|
||||
patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock),
|
||||
):
|
||||
@@ -530,6 +555,7 @@ class TestRepeaterCommandRoute:
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
):
|
||||
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
|
||||
@@ -557,6 +583,7 @@ class TestRepeaterCommandRoute:
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
):
|
||||
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
|
||||
@@ -584,6 +611,7 @@ class TestRepeaterCommandRoute:
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
):
|
||||
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
|
||||
@@ -609,6 +637,7 @@ class TestRepeaterCommandRoute:
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
):
|
||||
response = await send_repeater_command(KEY_A, CommandRequest(command="ver"))
|
||||
@@ -631,6 +660,7 @@ class TestRepeaterCommandRoute:
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch(_MONOTONIC, side_effect=_advancing_clock()),
|
||||
patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock),
|
||||
):
|
||||
@@ -651,6 +681,7 @@ class TestTraceRoute:
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.contacts.random.randint", return_value=1234),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
@@ -667,6 +698,7 @@ class TestTraceRoute:
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.contacts.random.randint", return_value=1234),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
@@ -685,6 +717,7 @@ class TestTraceRoute:
|
||||
|
||||
with (
|
||||
patch("app.routers.contacts.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.contacts.random.randint", return_value=1234),
|
||||
):
|
||||
response = await request_trace(KEY_A)
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.models import (
|
||||
SendChannelMessageRequest,
|
||||
SendDirectMessageRequest,
|
||||
)
|
||||
from app.radio import radio_manager
|
||||
from app.repository import (
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
@@ -25,6 +26,16 @@ from app.routers.messages import (
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_db():
|
||||
"""Create an in-memory test database with schema + migrations."""
|
||||
@@ -96,6 +107,7 @@ class TestOutgoingDMBotTrigger:
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
|
||||
):
|
||||
request = SendDirectMessageRequest(destination=pub_key, text="!lasttime Alice")
|
||||
@@ -127,6 +139,7 @@ class TestOutgoingDMBotTrigger:
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.bot.run_bot_for_message", new=slow_bot),
|
||||
):
|
||||
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
|
||||
@@ -144,6 +157,7 @@ class TestOutgoingDMBotTrigger:
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
|
||||
):
|
||||
request = SendDirectMessageRequest(destination=pub_key, text="test")
|
||||
@@ -184,6 +198,7 @@ class TestOutgoingChannelBotTrigger:
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
|
||||
):
|
||||
@@ -210,6 +225,7 @@ class TestOutgoingChannelBotTrigger:
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()) as mock_bot,
|
||||
):
|
||||
@@ -234,6 +250,7 @@ class TestOutgoingChannelBotTrigger:
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=slow_bot),
|
||||
):
|
||||
@@ -250,6 +267,7 @@ class TestOutgoingChannelBotTrigger:
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()),
|
||||
):
|
||||
@@ -282,7 +300,10 @@ class TestResendChannelMessage:
|
||||
)
|
||||
assert msg_id is not None
|
||||
|
||||
with patch("app.routers.messages.require_connected", return_value=mc):
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
result = await resend_channel_message(msg_id, new_timestamp=False)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
@@ -341,6 +362,7 @@ class TestResendChannelMessage:
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch("app.routers.messages.time") as mock_time,
|
||||
):
|
||||
@@ -436,7 +458,10 @@ class TestResendChannelMessage:
|
||||
)
|
||||
assert msg_id is not None
|
||||
|
||||
with patch("app.routers.messages.require_connected", return_value=mc):
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
):
|
||||
await resend_channel_message(msg_id, new_timestamp=False)
|
||||
|
||||
call_kwargs = mc.commands.send_chan_msg.await_args.kwargs
|
||||
@@ -462,6 +487,7 @@ class TestResendChannelMessage:
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
result = await resend_channel_message(msg_id, new_timestamp=True)
|
||||
@@ -490,6 +516,7 @@ class TestResendChannelMessage:
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
result = await resend_channel_message(msg_id, new_timestamp=True)
|
||||
@@ -524,6 +551,7 @@ class TestResendChannelMessage:
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event") as mock_broadcast,
|
||||
):
|
||||
result = await resend_channel_message(msg_id, new_timestamp=True)
|
||||
|
||||
Reference in New Issue
Block a user