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>
99 lines
4.4 KiB
Python
99 lines
4.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_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
|