Files
server-26/drb-c2-core/tests/test_mqtt_handler.py
Logan CusanoandClaude Opus 5.5 268470b6c0 nodes: store and show host disk free; warn when low (node-26#41)
Checkin now carries disk_free_bytes/disk_total_bytes. Stored on the node doc
(not overwritten by older nodes that don't send them). Node page shows a Disk
row; node cards warn amber under 15% free, red under 5% or 2 GB. A full disk
stopped node-002 recording for 17 days while it showed Online.

Frontend unverified locally (no Node, #207); CI build typechecks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:24:10 -04:00

316 lines
11 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_stores_disk_free_and_total(handler):
# node-26#41: a full disk stopped node-002 recording while it showed Online.
existing = {"node_id": "node-01", "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", {"disk_free_bytes": 5, "disk_total_bytes": 100})
updates = mock_fstore.doc_update.call_args[0][2]
assert updates["disk_free_bytes"] == 5 and updates["disk_total_bytes"] == 100
# An older node that doesn't report disk must not wipe the fields.
await handler._handle_checkin("node-01", {"name": "x"})
assert "disk_free_bytes" not in mock_fstore.doc_update.call_args[0][2]
@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()