511NY DOT cameras + traffic events map layers (#183)

GET /traffic/511 serves a bbox slice of the statewide 511NY camera and event
feeds from an in-memory cache (cameras 1h, events 2m TTL, fetched lazily) --
public data, so no Firestore writes. A failed refresh keeps the last good data
and reports the error; a schema change (nothing parses) is an error, not an
empty layer. Frontend: opt-in "DOT Cameras" and "Traffic Events" overlays that
fetch only while shown, with an on-map notice when the feed is down or stale.
NY511_API_KEY is optional in config (the API answers without one today; the
terms require a registered key).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-09-27 14:26:38 -04:00
co-authored by Claude Opus 5.5
parent 1a01497f4d
commit 3347ced863
10 changed files with 527 additions and 2 deletions
+98
View File
@@ -0,0 +1,98 @@
"""
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_truncates_description():
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
assert len(e["description"]) == ny511._DESCRIPTION_MAX + 1
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 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")
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 [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