feat(tomtom_flow): Navi passthrough endpoint /api/traffic/flow (v0.9.4)

Phased PR2 of v0.9.3. Adds the on-demand tile passthrough so Navi can call
Central instead of navi-traffic, and so tiles outside the polled coverage set
still get persisted. central-gui restart only (new route); no supervisor, no
archive, no migration, no new stream/dep.

GET /api/traffic/flow/<z>/<x>/<y>.{png,pbf} -- exactly matches navi-traffic's
route for a drop-in frontend flip (s/navi-traffic/central:8000/). No auth
(tailnet-trusted, matches navi-traffic's existing posture; exempted in
middleware). Cache-Control max-age=60 (TomTom's advertised tile TTL).

- .png -> passthrough + cache only (raster unparseable for storage).
- .pbf -> passthrough + decode via the shared tomtom_flow_parse.decode_flow_tile
  and publish segments to CENTRAL_TRAFFIC_FLOW via the GUI's NATS connection
  (wrap_event, supervisor's exact envelope shape). Same minute-bucketed dedup id
  as the polling adapter, so adapter+passthrough fetches of one tile in the same
  minute collapse at the archive's (id, time) upsert -- intentional collision.
- Publish is best-effort: wrapped in try/except + guards get_js() None, so NATS
  state never blocks serving the tile (HTTP > storage).
- API key via ConfigStore(get_pool()).get_api_key("tomtom"); 503 if unset;
  redacted from any logged upstream error; 502 on upstream failure (no publish).

Single-flight (shared in-flight upstream request per tile) queued for v0.9.5.
navi-frontend flip + navi-traffic deprecation live in the navi repo (Matt's call).

Full suite: 787 passed, 1 skipped (central and unprivileged zvx, 3x each).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-05-26 00:04:02 +00:00
commit cf1d96e159
3 changed files with 195 additions and 2 deletions

View file

@ -0,0 +1,113 @@
"""Tests for the v0.9.4 tomtom_flow Navi passthrough endpoint.
Exercises _flow_passthrough directly with mocked upstream + NATS (mirrors how
test_events_feed_frontend calls route handlers). Uses the real Orbis fixture
from v0.9.3 for the decode-and-publish path.
"""
import json
import re
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from central.gui import routes
PBF = (Path(__file__).parent / "fixtures" / "tomtom_flow_orbis.pbf").read_bytes()
PNG = b"\x89PNG\r\n\x1a\n" + b"fake-png-bytes"
@contextmanager
def patches(key="testkey", tile=PNG, js=None, fetch_exc=None):
cs = MagicMock()
cs.get_api_key = AsyncMock(return_value=key)
fetch = AsyncMock(side_effect=fetch_exc) if fetch_exc else AsyncMock(return_value=tile)
with patch("central.gui.routes.get_pool", return_value=MagicMock()), \
patch("central.gui.routes.ConfigStore", return_value=cs), \
patch("central.gui.routes._fetch_tomtom_tile", fetch), \
patch("central.gui.routes.get_js", return_value=js):
yield
@pytest.mark.asyncio
async def test_png_passthrough_no_publish():
js = MagicMock()
js.publish = AsyncMock()
with patches(tile=PNG, js=js):
resp = await routes._flow_passthrough(10, 181, 374, "png")
assert resp.status_code == 200
assert resp.body == PNG
assert resp.media_type == "image/png"
assert resp.headers["Cache-Control"] == "public, max-age=60"
js.publish.assert_not_called() # raster is unparseable -> no storage
@pytest.mark.asyncio
async def test_pbf_passthrough_publishes_segments():
js = MagicMock()
js.publish = AsyncMock()
with patches(tile=PBF, js=js):
resp = await routes._flow_passthrough(10, 181, 374, "pbf")
assert resp.status_code == 200
assert resp.body == PBF
assert resp.media_type == "application/x-protobuf"
assert js.publish.await_count > 50
subject = js.publish.await_args_list[0].args[0]
payload = json.loads(js.publish.await_args_list[0].args[1])
assert subject == "central.traffic_flow.10.181.374"
# dedup id collides with the polling adapter's minute-bucketed pattern
assert re.match(r"^10/181/374:\d+:\d{4}-\d\d-\d\dT\d\d:\d\d$", payload["data"]["id"])
@pytest.mark.asyncio
async def test_missing_key_503():
with patches(key=None):
resp = await routes._flow_passthrough(10, 181, 374, "pbf")
assert resp.status_code == 503
assert b"not configured" in resp.body
@pytest.mark.asyncio
async def test_upstream_failure_502_no_publish():
js = MagicMock()
js.publish = AsyncMock()
with patches(fetch_exc=RuntimeError("boom"), js=js):
resp = await routes._flow_passthrough(10, 181, 374, "pbf")
assert resp.status_code == 502
assert b"upstream tile fetch failed" in resp.body
js.publish.assert_not_called()
@pytest.mark.asyncio
async def test_key_never_leaks_on_error(caplog):
secret = "SECRETKEY1234567890ABCDEF12345678"
with caplog.at_level("WARNING"):
with patches(key=secret, fetch_exc=RuntimeError(f"401 url=...key={secret}")):
resp = await routes._flow_passthrough(10, 181, 374, "png")
assert resp.status_code == 502
assert secret not in resp.body.decode()
assert secret not in caplog.text
# the redacted error string rode in the log record's `error` extra field
errs = " ".join(str(getattr(r, "error", "")) for r in caplog.records)
assert secret not in errs
assert "<KEY>" in errs
@pytest.mark.asyncio
async def test_publish_failure_still_returns_tile():
js = MagicMock()
js.publish = AsyncMock(side_effect=RuntimeError("nats down"))
with patches(tile=PBF, js=js):
resp = await routes._flow_passthrough(10, 181, 374, "pbf")
assert resp.status_code == 200 # storage is best-effort; tile still served
assert resp.body == PBF
@pytest.mark.asyncio
async def test_no_js_still_returns_tile():
with patches(tile=PBF, js=None): # GUI NATS not connected
resp = await routes._flow_passthrough(10, 181, 374, "pbf")
assert resp.status_code == 200
assert resp.body == PBF