""" Unit tests for MQTTHandler — topic dispatch and Firestore write logic. Firestore is mocked throughout; no MQTT broker or DB required. """ import pytest from unittest.mock import AsyncMock, patch, call from app.internal.mqtt_handler import MQTTHandler @pytest.fixture def handler(): return MQTTHandler() # --------------------------------------------------------------------------- # Topic dispatch # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_dispatch_routes_checkin(handler): with patch.object(handler, "_handle_checkin", new=AsyncMock()) as m: await handler._dispatch("nodes/node-01/checkin", {"name": "Pi"}) m.assert_called_once_with("node-01", {"name": "Pi"}) @pytest.mark.asyncio async def test_dispatch_routes_status(handler): with patch.object(handler, "_handle_status", new=AsyncMock()) as m: await handler._dispatch("nodes/node-01/status", {"status": "online"}) m.assert_called_once_with("node-01", {"status": "online"}) @pytest.mark.asyncio async def test_dispatch_routes_metadata(handler): with patch.object(handler, "_handle_metadata", new=AsyncMock()) as m: await handler._dispatch("nodes/node-01/metadata", {"event": "call_start"}) m.assert_called_once_with("node-01", {"event": "call_start"}) @pytest.mark.asyncio async def test_dispatch_ignores_wrong_prefix(handler): with patch.object(handler, "_handle_checkin", new=AsyncMock()) as m: await handler._dispatch("other/topic/here", {}) m.assert_not_called() @pytest.mark.asyncio async def test_dispatch_ignores_malformed_topic(handler): with patch.object(handler, "_handle_checkin", new=AsyncMock()) as m: await handler._dispatch("nodes/checkin", {}) # only 2 parts m.assert_not_called() # --------------------------------------------------------------------------- # Checkin — new node # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_checkin_creates_new_node(handler): with patch("app.internal.mqtt_handler.fstore") as mock_fstore: mock_fstore.doc_get = AsyncMock(return_value=None) mock_fstore.doc_set = AsyncMock() await handler._handle_checkin( "new-node", {"name": "Pi Zero W", "lat": 40.7, "lon": -74.0}, ) mock_fstore.doc_set.assert_called_once() _, _, doc, _ = mock_fstore.doc_set.call_args[0] assert doc["node_id"] == "new-node" assert doc["name"] == "Pi Zero W" assert doc["status"] == "unconfigured" assert doc["configured"] is False assert doc["lat"] == 40.7 @pytest.mark.asyncio async def test_checkin_new_node_defaults_lat_lon(handler): """Missing lat/lon in payload should default to 0.0.""" with patch("app.internal.mqtt_handler.fstore") as mock_fstore: mock_fstore.doc_get = AsyncMock(return_value=None) mock_fstore.doc_set = AsyncMock() await handler._handle_checkin("new-node", {}) _, _, doc, _ = mock_fstore.doc_set.call_args[0] assert doc["lat"] == 0.0 assert doc["lon"] == 0.0 # --------------------------------------------------------------------------- # Checkin — existing node # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_checkin_updates_existing_configured_node(handler): existing = { "node_id": "node-01", "name": "Old Name", "lat": 0.0, "lon": 0.0, "status": "online", "configured": True, } with patch("app.internal.mqtt_handler.fstore") as mock_fstore: mock_fstore.doc_get = AsyncMock(return_value=existing) mock_fstore.doc_update = AsyncMock() await handler._handle_checkin("node-01", {"name": "New Name", "lat": 1.1, "lon": 2.2}) updates = mock_fstore.doc_update.call_args[0][2] assert updates["name"] == "New Name" assert updates["lat"] == 1.1 assert updates["status"] == "online" assert "last_seen" in updates @pytest.mark.asyncio async def test_checkin_does_not_promote_unconfigured_to_online(handler): existing = { "node_id": "node-02", "name": "Node", "lat": 0.0, "lon": 0.0, "status": "unconfigured", "configured": False, } with patch("app.internal.mqtt_handler.fstore") as mock_fstore: mock_fstore.doc_get = AsyncMock(return_value=existing) mock_fstore.doc_update = AsyncMock() await handler._handle_checkin("node-02", {}) updates = mock_fstore.doc_update.call_args[0][2] assert "status" not in updates @pytest.mark.asyncio async def test_checkin_does_not_override_recording_status(handler): existing = { "node_id": "node-03", "name": "Node", "lat": 0.0, "lon": 0.0, "status": "recording", "configured": True, } with patch("app.internal.mqtt_handler.fstore") as mock_fstore: mock_fstore.doc_get = AsyncMock(return_value=existing) mock_fstore.doc_update = AsyncMock() await handler._handle_checkin("node-03", {}) updates = mock_fstore.doc_update.call_args[0][2] assert "status" not in updates # --------------------------------------------------------------------------- # Status update # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_handle_status_updates_firestore(handler): with patch("app.internal.mqtt_handler.fstore") as mock_fstore: mock_fstore.doc_update = AsyncMock() await handler._handle_status("node-01", {"status": "recording"}) updates = mock_fstore.doc_update.call_args[0][2] assert updates["status"] == "recording" assert "last_seen" in updates @pytest.mark.asyncio async def test_handle_status_ignores_empty_payload(handler): with patch("app.internal.mqtt_handler.fstore") as mock_fstore: mock_fstore.doc_update = AsyncMock() await handler._handle_status("node-01", {}) mock_fstore.doc_update.assert_not_called() # --------------------------------------------------------------------------- # Metadata — call_start / call_end # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_call_start_creates_call_doc(handler): node = {"node_id": "node-01", "assigned_system_id": "sys-001"} payload = { "event": "call_start", "call_id": "call-abc123", "tgid": 1234, "tgid_name": "Police Dispatch", "started_at": "2026-01-01T00:00:00+00:00", "freq": 851012500, "srcaddr": 42, } with patch("app.internal.mqtt_handler.fstore") as mock_fstore: mock_fstore.doc_get = AsyncMock(return_value=node) mock_fstore.doc_set = AsyncMock() await handler._on_call_start("node-01", payload) mock_fstore.doc_set.assert_called_once() _, _, doc, _ = mock_fstore.doc_set.call_args[0] assert doc["call_id"] == "call-abc123" assert doc["node_id"] == "node-01" assert doc["system_id"] == "sys-001" assert doc["talkgroup_id"] == 1234 assert doc["talkgroup_name"] == "Police Dispatch" assert doc["status"] == "active" assert doc["audio_url"] is None @pytest.mark.asyncio async def test_call_start_handles_missing_call_id(handler): with patch("app.internal.mqtt_handler.fstore") as mock_fstore: mock_fstore.doc_get = AsyncMock(return_value={}) mock_fstore.doc_set = AsyncMock() await handler._on_call_start("node-01", {"event": "call_start"}) mock_fstore.doc_set.assert_not_called() @pytest.mark.asyncio async def test_call_start_uses_now_when_started_at_missing(handler): node = {"node_id": "node-01", "assigned_system_id": None} payload = {"call_id": "call-xyz", "tgid": 99} with patch("app.internal.mqtt_handler.fstore") as mock_fstore: mock_fstore.doc_get = AsyncMock(return_value=node) mock_fstore.doc_set = AsyncMock() await handler._on_call_start("node-01", payload) _, _, doc, _ = mock_fstore.doc_set.call_args[0] assert doc["started_at"] is not None @pytest.mark.asyncio async def test_call_end_updates_status_and_times(handler): payload = { "call_id": "call-abc123", "ended_at": "2026-01-01T00:05:00+00:00", } with patch("app.internal.mqtt_handler.fstore") as mock_fstore: mock_fstore.doc_update = AsyncMock() await handler._on_call_end("node-01", payload) updates = mock_fstore.doc_update.call_args[0][2] assert updates["status"] == "ended" assert updates["ended_at"] is not None @pytest.mark.asyncio async def test_call_end_sets_audio_url_when_present(handler): payload = { "call_id": "call-abc123", "ended_at": "2026-01-01T00:05:00+00:00", "audio_url": "https://storage.example.com/call.mp3", } with patch("app.internal.mqtt_handler.fstore") as mock_fstore: mock_fstore.doc_update = AsyncMock() await handler._on_call_end("node-01", payload) updates = mock_fstore.doc_update.call_args[0][2] assert updates["audio_url"] == "https://storage.example.com/call.mp3" @pytest.mark.asyncio async def test_call_end_ignores_missing_call_id(handler): with patch("app.internal.mqtt_handler.fstore") as mock_fstore: mock_fstore.doc_update = AsyncMock() await handler._on_call_end("node-01", {"ended_at": "2026-01-01T00:05:00+00:00"}) mock_fstore.doc_update.assert_not_called()