All 10 failures were tests that had drifted behind the product code, not
regressions in it. Diagnosed each individually:
test_mqtt_handler.py:
- test_checkin_creates_new_node, test_checkin_new_node_defaults_lat_lon:
unpacked 4 positional args from doc_set.call_args[0], but
fstore.doc_set(collection, doc_id, data, merge=False) always passes
merge as a kwarg, so only 3 positional args are ever recorded. Fixed
the unpack to 3.
- test_call_start_creates_call_doc, test_call_start_uses_now_when_started_at_missing:
mocked fstore.doc_get, but _on_call_start looks the node up via the
cached fstore.doc_get_cached (added when Firestore reads were cut to
stay in the free tier). The unmocked doc_get_cached returned a bare
MagicMock, which isn't awaitable. Mocked doc_get_cached instead; also
fixed the same 4-vs-3 positional-arg unpack on doc_set's merge=False call.
- test_call_end_updates_status_and_times, test_call_end_sets_audio_url_when_present:
mocked fstore.doc_update, but _on_call_end now writes via
fstore.doc_set(merge=True) (see the "Fix Upload 404 warning" commit —
doc_update raised "No document to update" when call_end arrived before
call_start). Also calls doc_get_cached to stamp org_id. Mocked
doc_get_cached and asserted against doc_set instead of doc_update.
test_node_sweeper.py:
- test_stale_online_node_marked_offline, test_stale_recording_node_marked_offline,
test_tz_naive_last_seen_is_handled, test_only_stale_nodes_updated_in_batch:
_sweep() now calls app.routers.tokens.release_token(node_id) for every
node it marks offline (added in 2a690ec, the PulseAudio/Discord-token
work). These tests never mocked it, so the module-level
patch("asyncio.to_thread", ...) meant for the node-query call leaked
into release_token's own internal to_thread call, feeding it raw node
dicts where it expected Firestore doc snapshots with .id — hence
"AttributeError: 'dict' object has no attribute 'id'". Patched
app.routers.tokens.release_token directly (it's imported inline inside
_sweep, so patching the source module works); the batch test also now
asserts release_token fires for exactly the two nodes that went offline.
No product code changed — app/internal/mqtt_handler.py, app/routers/tokens.py,
and app/internal/node_sweeper.py all behave as intended. This was pure test
drift across two unrelated feature additions (Firestore-read caching,
Discord-token release-on-offline) that landed without their tests being
updated.
93 passed, 0 failed.
Closes logan/server-26#10.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
300 lines
10 KiB
Python
300 lines
10 KiB
Python
"""
|
|
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_set(collection, doc_id, data, merge=False) — merge is passed as a
|
|
# kwarg in mqtt_handler.py, so only 3 positional args land in call_args[0].
|
|
_, _, 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:
|
|
# _on_call_start looks the node up via doc_get_cached (cached read,
|
|
# added to cut Firestore read volume — see doc_get_cached in
|
|
# app/internal/firestore.py), not the uncached doc_get.
|
|
mock_fstore.doc_get_cached = 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_set(collection, doc_id, data, merge=False) — merge is a kwarg here too.
|
|
_, _, 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_cached = 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:
|
|
# _on_call_end writes via doc_set(merge=True) now, not doc_update — see
|
|
# the "Fix Upload 404 warning" commit: doc_update raised "No document
|
|
# to update" when call_end raced ahead of call_start, so it was
|
|
# switched to a merging doc_set. It also reads the node via the
|
|
# cached doc_get_cached to stamp org_id.
|
|
mock_fstore.doc_get_cached = AsyncMock(return_value=None)
|
|
mock_fstore.doc_set = AsyncMock()
|
|
|
|
await handler._on_call_end("node-01", payload)
|
|
|
|
updates = mock_fstore.doc_set.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_get_cached = AsyncMock(return_value=None)
|
|
mock_fstore.doc_set = AsyncMock()
|
|
|
|
await handler._on_call_end("node-01", payload)
|
|
|
|
updates = mock_fstore.doc_set.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()
|