Initial commit — DRB server stack
Includes c2-core (FastAPI/MQTT/Firestore), discord-bot (slash commands), frontend (Next.js admin UI), and mosquitto config.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# All C2 core settings have defaults — no env setup needed.
|
||||
# Add any shared fixtures here if required in the future.
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
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()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Unit tests for node_sweeper — datetime comparison logic and Firestore update gating.
|
||||
Firestore and asyncio.to_thread are mocked — no real DB required.
|
||||
"""
|
||||
import pytest
|
||||
import asyncio
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from app.internal.node_sweeper import _sweep
|
||||
|
||||
|
||||
def _node(node_id, status, age_seconds):
|
||||
"""Helper: build a node dict with last_seen age_seconds ago (tz-aware)."""
|
||||
return {
|
||||
"node_id": node_id,
|
||||
"status": status,
|
||||
"last_seen": datetime.now(timezone.utc) - timedelta(seconds=age_seconds),
|
||||
}
|
||||
|
||||
|
||||
def _node_naive(node_id, status, age_seconds):
|
||||
"""Helper: tz-naive last_seen (simulates some Firestore SDK versions)."""
|
||||
return {
|
||||
"node_id": node_id,
|
||||
"status": status,
|
||||
"last_seen": datetime.utcnow() - timedelta(seconds=age_seconds),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core sweep logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_online_node_marked_offline():
|
||||
nodes = [_node("node-01", "online", age_seconds=120)]
|
||||
|
||||
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
|
||||
patch("app.internal.node_sweeper.fstore") as mock_fstore:
|
||||
mock_fstore.doc_update = AsyncMock()
|
||||
await _sweep()
|
||||
|
||||
mock_fstore.doc_update.assert_called_once_with(
|
||||
"nodes", "node-01", {"status": "offline"}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_recording_node_marked_offline():
|
||||
nodes = [_node("node-02", "recording", age_seconds=200)]
|
||||
|
||||
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
|
||||
patch("app.internal.node_sweeper.fstore") as mock_fstore:
|
||||
mock_fstore.doc_update = AsyncMock()
|
||||
await _sweep()
|
||||
|
||||
mock_fstore.doc_update.assert_called_once_with(
|
||||
"nodes", "node-02", {"status": "offline"}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fresh_node_not_touched():
|
||||
nodes = [_node("node-03", "online", age_seconds=10)]
|
||||
|
||||
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
|
||||
patch("app.internal.node_sweeper.fstore") as mock_fstore:
|
||||
mock_fstore.doc_update = AsyncMock()
|
||||
await _sweep()
|
||||
|
||||
mock_fstore.doc_update.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_offline_node_skipped():
|
||||
"""Offline nodes must be skipped even if last_seen is ancient."""
|
||||
nodes = [_node("node-04", "offline", age_seconds=9999)]
|
||||
|
||||
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
|
||||
patch("app.internal.node_sweeper.fstore") as mock_fstore:
|
||||
mock_fstore.doc_update = AsyncMock()
|
||||
await _sweep()
|
||||
|
||||
mock_fstore.doc_update.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_with_no_last_seen_skipped():
|
||||
nodes = [{"node_id": "node-05", "status": "online", "last_seen": None}]
|
||||
|
||||
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
|
||||
patch("app.internal.node_sweeper.fstore") as mock_fstore:
|
||||
mock_fstore.doc_update = AsyncMock()
|
||||
await _sweep()
|
||||
|
||||
mock_fstore.doc_update.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timezone edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tz_naive_last_seen_is_handled():
|
||||
"""Firestore may return tz-naive datetimes; sweeper must not crash."""
|
||||
nodes = [_node_naive("node-06", "online", age_seconds=120)]
|
||||
|
||||
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
|
||||
patch("app.internal.node_sweeper.fstore") as mock_fstore:
|
||||
mock_fstore.doc_update = AsyncMock()
|
||||
await _sweep()
|
||||
|
||||
mock_fstore.doc_update.assert_called_once_with(
|
||||
"nodes", "node-06", {"status": "offline"}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tz_naive_fresh_node_not_touched():
|
||||
nodes = [_node_naive("node-07", "online", age_seconds=5)]
|
||||
|
||||
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
|
||||
patch("app.internal.node_sweeper.fstore") as mock_fstore:
|
||||
mock_fstore.doc_update = AsyncMock()
|
||||
await _sweep()
|
||||
|
||||
mock_fstore.doc_update.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_only_stale_nodes_updated_in_batch():
|
||||
nodes = [
|
||||
_node("node-08", "online", age_seconds=200), # stale → offline
|
||||
_node("node-09", "online", age_seconds=5), # fresh → skip
|
||||
_node("node-10", "offline", age_seconds=500), # already offline → skip
|
||||
_node("node-11", "recording", age_seconds=150), # stale recording → offline
|
||||
]
|
||||
|
||||
with patch("asyncio.to_thread", new=AsyncMock(return_value=nodes)), \
|
||||
patch("app.internal.node_sweeper.fstore") as mock_fstore:
|
||||
mock_fstore.doc_update = AsyncMock()
|
||||
await _sweep()
|
||||
|
||||
assert mock_fstore.doc_update.call_count == 2
|
||||
updated_ids = {call.args[1] for call in mock_fstore.doc_update.call_args_list}
|
||||
assert updated_ids == {"node-08", "node-11"}
|
||||
Reference in New Issue
Block a user