Files
server-26/drb-c2-core/tests/test_ny511.py
Logan CusanoandClaude Opus 5.5 aa5b71d853 Live ops wall + pluggable camera providers with licence gating (server-26#198)
Backend (c2-core):
- app/internal/cameras/: provider interface, licence model, per-provider
  catalog cache; one adapter per source in design-canvas/camera-sources.md.
  Implemented: 511NY (legacy shape + tolerant XML inventory parser, 511x
  endpoints UNVERIFIED), Caltrans, DelDOT, Ontario 511, DriveBC, Calgary,
  TfL, NSW, Fintraffic. Stubs: NYC DOT, Westchester DPW, TRANSCOM, CTroads,
  511NJ, Austin, TxDOT, MD CHART, WSDOT, Tallinn, Tarktee, Warendorf,
  TrafficLand, Windy, EarthCam.
- Every provider defaults OFF (config/camera_providers). Gated licences
  (agreement required / unverified / non-commercial / paid) need an explicit
  acknowledgement stored with who/when and written to audit_log; the audit
  write is a precondition. Stubs and prohibited sources cannot be enabled.
  Ack is bound to the licence status it was given for.
- GET /cameras (signed-in), GET/PUT /admin/camera-providers (admin only).
- /traffic/511 follows the ny511 provider switch and returns the DAA credit
  + NYSDOT as-is disclaimer; 511NY event descriptions no longer truncated
  ("may not alter the substance of a report").

Frontend:
- LiveView becomes the ops wall: Region > Incident > Camera drill-down,
  worst-first rail (MapView), KPIs, transcript ticker, scrubber incident
  bands, auto-tour, deep links (#inc=&cam=&tour), Esc steps up.
- Incident panel: nearest cameras (path-weighted), fused situational context
  (ADS-B, AIS, 511NY events, NEXRAD tile sample), units, call spine.
- MapView keeps every layer; adds Camera Viewsheds (approx.) overlay and a
  nearest-camera layer for the selected incident.
- CameraFeed hygiene: hidden-tab/off-screen gating, 2 concurrent HLS cap,
  last good frame + stale badge, frozen-frame signature, HLS stall watchdog,
  provider attribution rendered on the imagery.
- Camera/event popups show source credit, update time and the disclaimer.
- Admin > Camera providers tab with licence status, health, gated confirm.
- Camera detection is not built (blocked on #199/#200); seam only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:56:06 -04:00

138 lines
6.4 KiB
Python

"""
server-26#183 — 511NY cameras/events layer.
The feed is scraped from a third party, so the tests pin the two failure shapes
that would otherwise look like "no traffic right now": a refresh error must keep
the last good data AND report the error, and a schema change (every record
unparseable) must be an error, not an empty list.
"""
import asyncio
from unittest.mock import AsyncMock, patch
from fastapi.testclient import TestClient
from app.main import app
from app.internal import ny511
from app.internal.auth import require_service_or_firebase_token
client = TestClient(app)
CAM = {"Latitude": 41.03, "Longitude": -73.76, "ID": "NYSDOT-1", "Name": "I-287 at Exit 5",
"DirectionOfTravel": "Unknown", "RoadwayName": "I-287", "Url": "https://511ny.org/map/Cctv/1",
"VideoUrl": None, "Disabled": False, "Blocked": False}
EVENT = {"Latitude": 41.019265, "Longitude": -73.797869, "ID": "TRANSCOM-1", "EventType": "roadwork",
"EventSubType": "Gas main repairs", "Severity": "Unknown", "RoadwayName": "NY 100",
"DirectionOfTravel": "Both directions", "CountyName": "Westchester", "Description": "x" * 900,
"StartDate": "28/09/2026 09:00:00", "PlannedEndDate": "", "LastUpdated": "26/09/2026 14:01:12"}
def setup_function():
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"admin": True}
for feed in (ny511.cameras, ny511.events):
feed.items, feed.fetched_at, feed.error, feed._next_attempt = [], None, None, 0.0
def teardown_function():
app.dependency_overrides.pop(require_service_or_firebase_token, None)
def test_normalize_camera_skips_disabled_blocked_and_zero_coords():
assert ny511.normalize_camera(CAM)["image_url"] == "https://511ny.org/map/Cctv/1"
assert ny511.normalize_camera({**CAM, "Disabled": True}) is None
assert ny511.normalize_camera({**CAM, "Blocked": True}) is None
assert ny511.normalize_camera({**CAM, "Latitude": 0, "Longitude": 0}) is None
def test_normalize_event_parses_day_first_dates_and_keeps_description_whole():
e = ny511.normalize_event(EVENT)
assert e["start_local"] == "2026-09-28T09:00:00" # DD/MM, not MM/DD
assert e["planned_end_local"] is None
# DAA: "You may not alter the substance of a report" -- no 500-char cut any more.
assert e["description"] == EVENT["Description"]
long = ny511.normalize_event({**EVENT, "Description": "y" * (ny511._DESCRIPTION_MAX + 50)})
assert len(long["description"]) == ny511._DESCRIPTION_MAX + 1 # pathological only
def test_failed_refresh_keeps_last_good_data_and_reports_error():
feed = ny511._Feed("getcameras", 3600, ny511.normalize_camera)
with patch.object(ny511, "_fetch", AsyncMock(return_value=[ny511.normalize_camera(CAM)])):
asyncio.run(feed.get())
feed._next_attempt = 0.0
with patch.object(ny511, "_fetch", AsyncMock(side_effect=RuntimeError("boom"))):
asyncio.run(feed.get())
assert len(feed.items) == 1 and feed.fetched_at is not None
assert "boom" in feed.error
assert feed._next_attempt - feed.fetched_at <= ny511.RETRY_AFTER_FAILURE_S + 5 # retries soon, not after the full TTL
def test_schema_change_is_an_error_not_an_empty_layer():
class Resp:
def raise_for_status(self): pass
def json(self): return [{"lat": 1, "lng": 2}] # renamed fields -> nothing parses
class Client:
async def __aenter__(self): return self
async def __aexit__(self, *a): pass
async def get(self, *a, **k): return Resp()
with patch.object(ny511.httpx, "AsyncClient", lambda **k: Client()):
try:
asyncio.run(ny511._fetch("getcameras", ny511.normalize_camera))
assert False, "expected a schema-change error"
except ValueError as e:
assert "schema" in str(e)
def _serving(value: bool):
from app.internal.cameras import registry
return patch.object(registry, "is_serving", AsyncMock(return_value=value))
def test_endpoint_is_off_until_the_ny511_provider_is_enabled():
with _serving(False):
r = client.get("/traffic/511", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4})
body = r.json()
assert r.status_code == 200 and body["cameras"] == [] and body["events"] == []
assert body["events_status"]["disabled"] is True
def test_endpoint_filters_to_bbox_and_reports_status():
far = {**CAM, "ID": "NYSDOT-2", "Latitude": 42.9, "Longitude": -78.8} # Buffalo
for feed, rows, norm in ((ny511.cameras, [CAM, far], ny511.normalize_camera), (ny511.events, [EVENT], ny511.normalize_event)):
feed.items = [norm(r) for r in rows]
feed.fetched_at, feed._next_attempt = 1.0, float("inf")
with _serving(True):
r = client.get("/traffic/511", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4})
assert r.status_code == 200
body = r.json()
assert body["credit"]["attribution"] == "powered by 511NY" and "as is" in body["credit"]["disclaimer"]
assert [c["id"] for c in body["cameras"]] == ["NYSDOT-1"]
assert body["cameras_status"] == {"fetched_at": 1.0, "error": None, "total_in_bbox": 1, "truncated": False}
assert len(body["events"]) == 1
def test_endpoint_rejects_inverted_bbox():
r = client.get("/traffic/511", params={"south": 41.4, "west": -74.0, "north": 40.9, "east": -73.4})
assert r.status_code == 400
def test_xml_inventory_maps_onto_legacy_fields():
xml = """<?xml version="1.0"?><cameras xmlns="urn:x">
<camera><ID>NYSDOT-9</ID><Latitude>41.05</Latitude><Longitude>-73.78</Longitude>
<Name>I-287 at Exit 4</Name><RoadwayName>I-287</RoadwayName><DirectionOfTravel>Eastbound</DirectionOfTravel>
<ImageUrl>https://example.invalid/still.jpg</ImageUrl><Disabled>false</Disabled></camera>
<camera id="NYSDOT-10" latitude="41.1" longitude="-73.7"><Disabled>true</Disabled></camera>
</cameras>"""
rows = ny511.parse_inventory_xml(xml)
cams = [ny511.normalize_camera(r) for r in rows]
assert cams[0]["id"] == "NYSDOT-9" and cams[0]["image_url"] == "https://example.invalid/still.jpg"
assert cams[1] is None # disabled flag honoured from XML text
def test_xml_parser_refuses_html_and_entities():
import pytest
with pytest.raises(ValueError, match="HTML"):
ny511.parse_inventory_xml("<!doctype html><html><body>511NY</body></html>")
with pytest.raises(ValueError, match="DTD"):
ny511.parse_inventory_xml('<?xml version="1.0"?><!DOCTYPE x [<!ENTITY a "b">]><x>&a;</x>')