P1 of the radio-ID plan. The node now reports `radios`, one
{radio, start, end} per transmission, on /upload and on call_end. Both go
through app/internal/radios.py into `radios` + `radio_ids` on the call doc.
/upload writes them before the pipeline starts, because call_end arrives
after it.
call_end also writes srcaddr now. Only call_start used to, and the node
usually learns the talker after call_start, so most calls kept None and the
correlator's srcaddr match rarely fired. /upload fills srcaddr from the
latest radio when the doc has none.
Older nodes send neither field; nothing is blanked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
101 lines
3.7 KiB
Python
101 lines
3.7 KiB
Python
"""
|
|
Every radio on a call reaches the call doc (server-26#304) — from /upload,
|
|
which starts the pipeline, and from call_end, which used to drop srcaddr.
|
|
"""
|
|
import json
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.internal import radios
|
|
from app.internal.mqtt_handler import MQTTHandler
|
|
from app.main import app
|
|
from app.routers import upload
|
|
|
|
LIST = [{"radio": 555, "start": 0.0, "end": 1.0}, {"radio": 777, "start": 2.0, "end": 2.5},
|
|
{"radio": 555, "start": 3.5, "end": 4.0}]
|
|
|
|
|
|
def test_parse_accepts_json_and_drops_junk():
|
|
raw = json.dumps(LIST + [{"radio": 0, "start": 1, "end": 2}, {"radio": "x"}, "junk",
|
|
{"radio": 9, "start": -1, "end": 2}, {"radio": 8, "start": 5, "end": 4}])
|
|
assert radios.parse(raw) == LIST + [{"radio": 8, "start": 5.0, "end": 5.0}]
|
|
assert radios.parse("not json") == []
|
|
assert radios.parse({"radio": 1}) == []
|
|
assert radios.parse(None) == []
|
|
|
|
|
|
def test_call_fields_keeps_distinct_ids_in_first_heard_order():
|
|
assert radios.call_fields(LIST)["radio_ids"] == [555, 777]
|
|
assert radios.call_fields([]) == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_call_end_writes_srcaddr_and_radios():
|
|
"""call_start used to be the only writer of srcaddr; the talker is usually learned later."""
|
|
handler = MQTTHandler()
|
|
with patch("app.internal.mqtt_handler.fstore") as fs:
|
|
fs.doc_get_cached = AsyncMock(return_value=None)
|
|
fs.doc_set = AsyncMock()
|
|
await handler._on_call_end("node-01", {"call_id": "c1", "srcaddr": 555, "radios": LIST})
|
|
updates = fs.doc_set.call_args[0][2]
|
|
assert updates["srcaddr"] == 555
|
|
assert updates["radios"] == LIST
|
|
assert updates["radio_ids"] == [555, 777]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_call_end_without_radios_leaves_them_alone():
|
|
"""An older node sends neither field; a merge write must not blank what /upload stored."""
|
|
handler = MQTTHandler()
|
|
with patch("app.internal.mqtt_handler.fstore") as fs:
|
|
fs.doc_get_cached = AsyncMock(return_value=None)
|
|
fs.doc_set = AsyncMock()
|
|
await handler._on_call_end("node-01", {"call_id": "c1", "srcaddr": None})
|
|
updates = fs.doc_set.call_args[0][2]
|
|
assert "srcaddr" not in updates and "radios" not in updates
|
|
|
|
|
|
def _upload(existing_call, form_radios):
|
|
store = {
|
|
"node_keys": {"api_key": "k"},
|
|
"nodes": {"approval_status": "approved", "org_id": "org"},
|
|
"calls": existing_call,
|
|
}
|
|
writes = []
|
|
|
|
async def doc_get(c, i):
|
|
return store.get(c)
|
|
|
|
async def doc_set(c, i, data, merge=True):
|
|
writes.append((c, data))
|
|
|
|
data = {"call_id": "c1", "node_id": "n1"}
|
|
if form_radios is not None:
|
|
data["radios"] = form_radios
|
|
with patch.object(upload.fstore, "doc_get", doc_get), \
|
|
patch.object(upload.fstore, "doc_set", doc_set), \
|
|
patch.object(upload, "upload_audio", AsyncMock(return_value="gs://b/c1.mp3")), \
|
|
patch.object(upload.dedup, "find_duplicate_of", AsyncMock(return_value="other")):
|
|
resp = TestClient(app).post(
|
|
"/upload", data=data,
|
|
files={"file": ("a.mp3", b"\x00\x01", "audio/mpeg")},
|
|
headers={"Authorization": "Bearer k"},
|
|
)
|
|
assert resp.status_code == 200
|
|
return writes[0][1]
|
|
|
|
|
|
def test_upload_stores_the_radio_list_before_the_pipeline_runs():
|
|
first = _upload({}, json.dumps(LIST))
|
|
assert first["radios"] == LIST
|
|
assert first["radio_ids"] == [555, 777]
|
|
# call_start had no talker yet: take the latest one, as call_end would.
|
|
assert first["srcaddr"] == 555
|
|
|
|
|
|
def test_upload_keeps_an_existing_srcaddr_and_tolerates_no_list():
|
|
assert "srcaddr" not in _upload({"srcaddr": 999}, json.dumps(LIST))
|
|
assert "radios" not in _upload({}, None)
|