Initial commit — DRB client (edge node) stack
Includes edge-node (FastAPI/MQTT/Discord voice), op25-container (SDR decoder), and icecast (audio streaming).
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import os
|
||||
|
||||
# Satisfy pydantic-settings required fields before any app module is imported.
|
||||
os.environ.setdefault("NODE_ID", "test-node-01")
|
||||
os.environ.setdefault("MQTT_BROKER", "localhost")
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Unit tests for config_manager — pure file I/O, no external services.
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from app.models import NodeConfig, SystemConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# save / load round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_save_and_load_roundtrip(tmp_path):
|
||||
config = NodeConfig(
|
||||
node_id="test-node-01",
|
||||
node_name="Test Node",
|
||||
lat=40.7128,
|
||||
lon=-74.0060,
|
||||
configured=True,
|
||||
assigned_system_id="sys-001",
|
||||
)
|
||||
|
||||
config_file = tmp_path / "node_config.json"
|
||||
with patch("app.internal.config_manager._CONFIG_FILE", config_file):
|
||||
import app.internal.config_manager as cm
|
||||
cm.save_node_config(config)
|
||||
loaded = cm.load_node_config()
|
||||
|
||||
assert loaded.node_id == config.node_id
|
||||
assert loaded.node_name == config.node_name
|
||||
assert loaded.lat == pytest.approx(config.lat)
|
||||
assert loaded.lon == pytest.approx(config.lon)
|
||||
assert loaded.configured is True
|
||||
assert loaded.assigned_system_id == "sys-001"
|
||||
|
||||
|
||||
def test_save_writes_valid_json(tmp_path):
|
||||
config = NodeConfig(
|
||||
node_id="node-x",
|
||||
node_name="X",
|
||||
lat=1.0,
|
||||
lon=2.0,
|
||||
)
|
||||
config_file = tmp_path / "node_config.json"
|
||||
with patch("app.internal.config_manager._CONFIG_FILE", config_file):
|
||||
import app.internal.config_manager as cm
|
||||
cm.save_node_config(config)
|
||||
|
||||
data = json.loads(config_file.read_text())
|
||||
assert data["node_id"] == "node-x"
|
||||
assert data["configured"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load fallback behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_load_missing_file_returns_defaults(tmp_path):
|
||||
config_file = tmp_path / "nonexistent.json"
|
||||
with patch("app.internal.config_manager._CONFIG_FILE", config_file):
|
||||
import app.internal.config_manager as cm
|
||||
result = cm.load_node_config()
|
||||
|
||||
assert result.configured is False
|
||||
|
||||
|
||||
def test_load_invalid_json_returns_defaults(tmp_path):
|
||||
config_file = tmp_path / "node_config.json"
|
||||
config_file.write_text("not valid json {{{")
|
||||
|
||||
with patch("app.internal.config_manager._CONFIG_FILE", config_file):
|
||||
import app.internal.config_manager as cm
|
||||
result = cm.load_node_config()
|
||||
|
||||
assert result.configured is False
|
||||
|
||||
|
||||
def test_load_partial_json_returns_defaults(tmp_path):
|
||||
"""A truncated file (e.g. mid-write crash) should fall back to defaults."""
|
||||
config_file = tmp_path / "node_config.json"
|
||||
config_file.write_text('{"node_id": "x"') # truncated
|
||||
|
||||
with patch("app.internal.config_manager._CONFIG_FILE", config_file):
|
||||
import app.internal.config_manager as cm
|
||||
result = cm.load_node_config()
|
||||
|
||||
assert result.configured is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# apply_system_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_apply_system_config_writes_json(tmp_path):
|
||||
system = SystemConfig(
|
||||
system_id="sys-001",
|
||||
name="Test System",
|
||||
type="P25",
|
||||
config={"freq": 851.0125, "nac": 0x293},
|
||||
)
|
||||
|
||||
with patch("app.internal.config_manager.settings") as mock_settings:
|
||||
mock_settings.config_path = str(tmp_path)
|
||||
import app.internal.config_manager as cm
|
||||
result = cm.apply_system_config(system)
|
||||
|
||||
assert result is True
|
||||
written = json.loads((tmp_path / "active.cfg.json").read_text())
|
||||
assert written["freq"] == pytest.approx(851.0125)
|
||||
assert written["nac"] == 0x293
|
||||
|
||||
|
||||
def test_apply_system_config_returns_false_on_error(tmp_path):
|
||||
system = SystemConfig(
|
||||
system_id="sys-001",
|
||||
name="Test",
|
||||
type="DMR",
|
||||
config={},
|
||||
)
|
||||
|
||||
with patch("app.internal.config_manager.settings") as mock_settings:
|
||||
# Point at a path that can't be written (a file, not a dir)
|
||||
blocker = tmp_path / "blocker"
|
||||
blocker.touch()
|
||||
mock_settings.config_path = str(blocker)
|
||||
import app.internal.config_manager as cm
|
||||
result = cm.apply_system_config(system)
|
||||
|
||||
assert result is False
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Unit tests for MetadataWatcher state machine.
|
||||
All OP25 HTTP calls are mocked — no running services required.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from app.internal.metadata_watcher import MetadataWatcher, HANG_THRESHOLD
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def watcher():
|
||||
w = MetadataWatcher()
|
||||
w.on_call_start = AsyncMock()
|
||||
w.on_call_end = AsyncMock()
|
||||
return w
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Call start
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_starts_when_tgid_appears(watcher):
|
||||
status = [{"tgid": 1234, "tag": "Police Dispatch"}]
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=status)):
|
||||
await watcher._tick()
|
||||
|
||||
assert watcher.is_active
|
||||
assert watcher.current_tgid == 1234
|
||||
watcher.on_call_start.assert_called_once()
|
||||
payload = watcher.on_call_start.call_args[0][0]
|
||||
assert payload["tgid"] == 1234
|
||||
assert payload["tgid_name"] == "Police Dispatch"
|
||||
assert "call_id" in payload
|
||||
assert "started_at" in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tgid_zero_does_not_start_call(watcher):
|
||||
status = [{"tgid": 0}]
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=status)):
|
||||
await watcher._tick()
|
||||
|
||||
assert not watcher.is_active
|
||||
watcher.on_call_start.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tgid_none_string_does_not_start_call(watcher):
|
||||
status = [{"tgid": "None"}]
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=status)):
|
||||
await watcher._tick()
|
||||
|
||||
assert not watcher.is_active
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_op25_offline_does_not_start_call(watcher):
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=None)):
|
||||
await watcher._tick()
|
||||
|
||||
assert not watcher.is_active
|
||||
watcher.on_call_start.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hang / call end
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hang_below_threshold_keeps_call_alive(watcher):
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=[{"tgid": 1234}])):
|
||||
await watcher._tick()
|
||||
|
||||
assert watcher.is_active
|
||||
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=[{"tgid": 0}])):
|
||||
for _ in range(HANG_THRESHOLD - 1):
|
||||
await watcher._tick()
|
||||
|
||||
assert watcher.is_active
|
||||
watcher.on_call_end.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hang_at_threshold_ends_call(watcher):
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=[{"tgid": 1234}])):
|
||||
await watcher._tick()
|
||||
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=[{"tgid": 0}])):
|
||||
for _ in range(HANG_THRESHOLD):
|
||||
await watcher._tick()
|
||||
|
||||
assert not watcher.is_active
|
||||
watcher.on_call_end.assert_called_once()
|
||||
payload = watcher.on_call_end.call_args[0][0]
|
||||
assert "call_id" in payload
|
||||
assert "ended_at" in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_op25_offline_triggers_hang_and_ends_call(watcher):
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=[{"tgid": 1234}])):
|
||||
await watcher._tick()
|
||||
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=None)):
|
||||
for _ in range(HANG_THRESHOLD):
|
||||
await watcher._tick()
|
||||
|
||||
assert not watcher.is_active
|
||||
watcher.on_call_end.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hang_counter_resets_when_tgid_returns(watcher):
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=[{"tgid": 1234}])):
|
||||
await watcher._tick()
|
||||
|
||||
# Partial hang — not enough to end
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=[{"tgid": 0}])):
|
||||
for _ in range(HANG_THRESHOLD - 1):
|
||||
await watcher._tick()
|
||||
|
||||
assert watcher.is_active
|
||||
|
||||
# tgid returns — counter resets
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=[{"tgid": 1234}])):
|
||||
await watcher._tick()
|
||||
|
||||
assert watcher._hang_counter == 0
|
||||
assert watcher.is_active
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Talkgroup changes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_talkgroup_change_closes_old_and_opens_new(watcher):
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=[{"tgid": 1111}])):
|
||||
await watcher._tick()
|
||||
|
||||
first_call_id = watcher.active_call_id
|
||||
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=[{"tgid": 2222}])):
|
||||
await watcher._tick()
|
||||
|
||||
assert watcher.is_active
|
||||
assert watcher.current_tgid == 2222
|
||||
assert watcher.active_call_id != first_call_id
|
||||
watcher.on_call_end.assert_called_once()
|
||||
assert watcher.on_call_start.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status format variations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_dict_status_instead_of_list(watcher):
|
||||
"""OP25 terminal may return a bare dict instead of a list."""
|
||||
status = {"tgid": 9999, "tag": "Fire"}
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=status)):
|
||||
await watcher._tick()
|
||||
|
||||
assert watcher.current_tgid == 9999
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tg_id_key_alias(watcher):
|
||||
"""Some OP25 builds use 'tg_id' instead of 'tgid'."""
|
||||
status = [{"tg_id": 5555, "tag": "EMS"}]
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=status)):
|
||||
await watcher._tick()
|
||||
|
||||
assert watcher.current_tgid == 5555
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multichannel_uses_first_active(watcher):
|
||||
"""When multiple channels are returned, first active tgid wins."""
|
||||
status = [
|
||||
{"tgid": 0},
|
||||
{"tgid": 7777, "tag": "Roads"},
|
||||
{"tgid": 8888, "tag": "Other"},
|
||||
]
|
||||
with patch("app.internal.metadata_watcher.op25_client.get_terminal_status", new=AsyncMock(return_value=status)):
|
||||
await watcher._tick()
|
||||
|
||||
assert watcher.current_tgid == 7777
|
||||
Reference in New Issue
Block a user