diff --git a/app/dependencies.py b/app/dependencies.py index af0a8b0..d0f6cc2 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -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() diff --git a/tests/test_api.py b/tests/test_api.py index 40ad309..c3dd3c6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_channels_router.py b/tests/test_channels_router.py index 02aca10..382985c 100644 --- a/tests/test_channels_router.py +++ b/tests/test_channels_router.py @@ -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") diff --git a/tests/test_contacts_router.py b/tests/test_contacts_router.py index 12d3453..bc5e543 100644 --- a/tests/test_contacts_router.py +++ b/tests/test_contacts_router.py @@ -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 diff --git a/tests/test_radio_operation.py b/tests/test_radio_operation.py index 74be246..db415da 100644 --- a/tests/test_radio_operation.py +++ b/tests/test_radio_operation.py @@ -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