""" 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 = """ NYSDOT-941.05-73.78 I-287 at Exit 4I-287Eastbound https://example.invalid/still.jpgfalse true """ 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("511NY") with pytest.raises(ValueError, match="DTD"): ny511.parse_inventory_xml(']>&a;')