""" 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