remove radio dependency fallback shim

This commit is contained in:
Jack Kingsman
2026-03-09 23:29:25 -07:00
parent a000fc88a5
commit 3e941a5b20
5 changed files with 80 additions and 134 deletions

View File

@@ -1,21 +1,8 @@
"""Shared dependencies for FastAPI routers."""
from fastapi import HTTPException
from app.services.radio_runtime import RadioRuntime
from app.services.radio_runtime import radio_runtime as radio_manager
def require_connected():
"""Dependency that ensures radio is connected and returns meshcore instance.
Raises HTTPException 503 if radio is not connected.
"""
if isinstance(radio_manager, RadioRuntime):
return radio_manager.require_connected()
if getattr(radio_manager, "is_setup_in_progress", False) is True:
raise HTTPException(status_code=503, detail="Radio is initializing")
mc = getattr(radio_manager, "meshcore", None)
if not getattr(radio_manager, "is_connected", False) or mc is None:
raise HTTPException(status_code=503, detail="Radio not connected")
return mc
"""Dependency that ensures radio is connected and returns meshcore instance."""
return radio_manager.require_connected()

View File

@@ -9,6 +9,7 @@ import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from app.radio import radio_manager
from app.repository import (
@@ -29,6 +30,15 @@ def _reset_radio_state():
radio_manager._operation_lock = prev_lock
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)
async def _insert_contact(public_key, name="Alice", **overrides):
"""Insert a contact into the test database."""
data = {
@@ -102,10 +112,7 @@ class TestRadioDisconnectedHandler:
# 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()
with _patch_require_connected(MagicMock()):
response = await client.post(
"/api/messages/direct", json={"destination": pub_key, "text": "Hi"}
)
@@ -120,10 +127,7 @@ class TestMessagesEndpoint:
@pytest.mark.asyncio
async def test_send_direct_message_requires_connection(self, test_db, client):
"""Sending message when disconnected returns 503."""
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.meshcore = None
with _patch_require_connected():
response = await client.post(
"/api/messages/direct", json={"destination": "abc123", "text": "Hello"}
)
@@ -134,10 +138,7 @@ class TestMessagesEndpoint:
@pytest.mark.asyncio
async def test_send_channel_message_requires_connection(self, test_db, client):
"""Sending channel message when disconnected returns 503."""
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.meshcore = None
with _patch_require_connected():
response = await client.post(
"/api/messages/channel",
json={"channel_key": "0123456789ABCDEF0123456789ABCDEF", "text": "Hello"},
@@ -164,12 +165,9 @@ class TestMessagesEndpoint:
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_rm,
_patch_require_connected(mock_mc),
patch("app.routers.messages.broadcast_event") as mock_broadcast,
):
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
response = await client.post(
"/api/messages/direct",
json={"destination": pub_key, "text": "Hello"},
@@ -202,13 +200,10 @@ class TestMessagesEndpoint:
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_rm,
_patch_require_connected(mock_mc),
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
patch("app.routers.messages.broadcast_event") as mock_broadcast,
):
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
response = await client.post(
"/api/messages/channel",
json={"channel_key": chan_key, "text": "Hello room"},
@@ -226,10 +221,7 @@ class TestMessagesEndpoint:
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix.return_value = None
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
with _patch_require_connected(mock_mc):
response = await client.post(
"/api/messages/direct", json={"destination": "nonexistent", "text": "Hello"}
)
@@ -257,11 +249,9 @@ class TestMessagesEndpoint:
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_rm,
_patch_require_connected(mock_mc),
patch("app.routers.messages.MessageRepository") as mock_msg_repo,
):
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
# Simulate duplicate - create returns None
mock_msg_repo.create = AsyncMock(return_value=None)
@@ -294,11 +284,9 @@ class TestMessagesEndpoint:
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_rm,
_patch_require_connected(mock_mc),
patch("app.routers.messages.MessageRepository") as mock_msg_repo,
):
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
# Simulate duplicate - create returns None
mock_msg_repo.create = AsyncMock(return_value=None)
@@ -315,10 +303,7 @@ class TestMessagesEndpoint:
@pytest.mark.asyncio
async def test_resend_channel_message_requires_connection(self, test_db, client):
"""Resend endpoint returns 503 when radio is disconnected."""
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.meshcore = None
with _patch_require_connected():
response = await client.post("/api/messages/channel/1/resend")
assert response.status_code == 503
@@ -353,10 +338,7 @@ class TestMessagesEndpoint:
)
radio_manager._meshcore = mock_mc
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
with _patch_require_connected(mock_mc):
response = await client.post(f"/api/messages/channel/{msg_id}/resend")
assert response.status_code == 200
@@ -394,10 +376,7 @@ class TestMessagesEndpoint:
mock_mc.commands.set_channel = AsyncMock()
mock_mc.commands.send_chan_msg = AsyncMock()
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
with _patch_require_connected(mock_mc):
response = await client.post(f"/api/messages/channel/{msg_id}/resend")
assert response.status_code == 400
@@ -414,10 +393,7 @@ class TestMessagesEndpoint:
mock_mc.commands.set_channel = AsyncMock()
mock_mc.commands.send_chan_msg = AsyncMock()
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
with _patch_require_connected(mock_mc):
response = await client.post("/api/messages/channel/999999/resend")
assert response.status_code == 404

View File

@@ -9,6 +9,7 @@ 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
@@ -55,6 +56,15 @@ def _make_error_response():
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."""
@@ -83,11 +93,9 @@ class TestSyncChannelsFromRadio:
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_dep_rm,
_patch_require_connected(mock_mc),
patch("app.routers.channels.radio_manager") as mock_ch_rm,
):
mock_dep_rm.is_connected = True
mock_dep_rm.meshcore = mock_mc
mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc)
response = await client.post("/api/channels/sync?max_channels=5")
@@ -119,11 +127,9 @@ class TestSyncChannelsFromRadio:
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_dep_rm,
_patch_require_connected(mock_mc),
patch("app.routers.channels.radio_manager") as mock_ch_rm,
):
mock_dep_rm.is_connected = True
mock_dep_rm.meshcore = mock_mc
mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc)
response = await client.post("/api/channels/sync?max_channels=5")
@@ -146,11 +152,9 @@ class TestSyncChannelsFromRadio:
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_dep_rm,
_patch_require_connected(mock_mc),
patch("app.routers.channels.radio_manager") as mock_ch_rm,
):
mock_dep_rm.is_connected = True
mock_dep_rm.meshcore = mock_mc
mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc)
response = await client.post("/api/channels/sync?max_channels=3")
@@ -178,11 +182,9 @@ class TestSyncChannelsFromRadio:
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_dep_rm,
_patch_require_connected(mock_mc),
patch("app.routers.channels.radio_manager") as mock_ch_rm,
):
mock_dep_rm.is_connected = True
mock_dep_rm.meshcore = mock_mc
mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc)
await client.post("/api/channels/sync?max_channels=3")
@@ -193,10 +195,7 @@ class TestSyncChannelsFromRadio:
@pytest.mark.asyncio
async def test_sync_requires_connection(self, test_db, client):
"""Sync returns 503 when radio is not connected."""
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.meshcore = None
with _patch_require_connected():
response = await client.post("/api/channels/sync")
assert response.status_code == 503
@@ -216,11 +215,9 @@ class TestSyncChannelsFromRadio:
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_dep_rm,
_patch_require_connected(mock_mc),
patch("app.routers.channels.radio_manager") as mock_ch_rm,
):
mock_dep_rm.is_connected = True
mock_dep_rm.meshcore = mock_mc
mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc)
await client.post("/api/channels/sync?max_channels=3")
@@ -246,11 +243,9 @@ class TestSyncChannelsFromRadio:
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_dep_rm,
_patch_require_connected(mock_mc),
patch("app.routers.channels.radio_manager") as mock_ch_rm,
):
mock_dep_rm.is_connected = True
mock_dep_rm.meshcore = mock_mc
mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc)
response = await client.post("/api/channels/sync?max_channels=3")

View File

@@ -10,6 +10,7 @@ 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
@@ -31,6 +32,15 @@ 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."""
@@ -505,10 +515,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
with _patch_require_connected(mock_mc):
response = await client.post("/api/contacts/sync")
assert response.status_code == 200
@@ -521,10 +528,7 @@ class TestSyncContacts:
@pytest.mark.asyncio
async def test_sync_requires_connection(self, test_db, client):
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.meshcore = None
with _patch_require_connected():
response = await client.post("/api/contacts/sync")
assert response.status_code == 503
@@ -547,10 +551,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
with _patch_require_connected(mock_mc):
response = await client.post("/api/contacts/sync")
assert response.status_code == 200
@@ -771,10 +772,7 @@ class TestAddRemoveRadio:
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
with _patch_require_connected(mock_mc):
response = await client.post(f"/api/contacts/{KEY_A}/add-to-radio")
assert response.status_code == 200
@@ -800,10 +798,7 @@ class TestAddRemoveRadio:
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
with _patch_require_connected(mock_mc):
response = await client.post(f"/api/contacts/{KEY_A}/add-to-radio")
assert response.status_code == 200
@@ -821,10 +816,7 @@ class TestAddRemoveRadio:
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
with _patch_require_connected(mock_mc):
response = await client.post(f"/api/contacts/{KEY_A}/add-to-radio")
assert response.status_code == 200
@@ -842,10 +834,7 @@ class TestAddRemoveRadio:
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
with _patch_require_connected(mock_mc):
response = await client.post(f"/api/contacts/{KEY_A}/remove-from-radio")
assert response.status_code == 200
@@ -857,10 +846,7 @@ class TestAddRemoveRadio:
@pytest.mark.asyncio
async def test_add_requires_connection(self, test_db, client):
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.meshcore = None
with _patch_require_connected():
response = await client.post(f"/api/contacts/{KEY_A}/add-to-radio")
assert response.status_code == 503
@@ -869,10 +855,7 @@ class TestAddRemoveRadio:
async def test_remove_not_found(self, test_db, client):
mock_mc = MagicMock()
with patch("app.dependencies.radio_manager") as mock_dep_rm:
mock_dep_rm.is_connected = True
mock_dep_rm.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 == 404

View File

@@ -7,6 +7,11 @@ import pytest
from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager
from app.radio_sync import is_polling_paused
from app.services.radio_runtime import RadioRuntime
def _runtime(manager):
return RadioRuntime(lambda: manager)
@pytest.fixture(autouse=True)
@@ -180,11 +185,11 @@ class TestRequireConnected:
from app.dependencies import require_connected
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = MagicMock()
mock_rm.is_setup_in_progress = True
manager = MagicMock()
manager.is_connected = True
manager.meshcore = MagicMock()
manager.is_setup_in_progress = True
with patch("app.dependencies.radio_manager", _runtime(manager)):
with pytest.raises(HTTPException) as exc_info:
require_connected()
@@ -197,11 +202,11 @@ class TestRequireConnected:
from app.dependencies import require_connected
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_setup_in_progress = False
mock_rm.is_connected = False
mock_rm.meshcore = None
manager = MagicMock()
manager.is_setup_in_progress = False
manager.is_connected = False
manager.meshcore = None
with patch("app.dependencies.radio_manager", _runtime(manager)):
with pytest.raises(HTTPException) as exc_info:
require_connected()
@@ -212,11 +217,11 @@ class TestRequireConnected:
from app.dependencies import require_connected
mock_mc = MagicMock()
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_setup_in_progress = False
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
manager = MagicMock()
manager.is_setup_in_progress = False
manager.is_connected = True
manager.meshcore = mock_mc
with patch("app.dependencies.radio_manager", _runtime(manager)):
result = require_connected()
assert result is mock_mc