"""Tests for issue #288: viewport bbox filtering on /api/live-data/{fast,slow}. Behaviour contract: * Without s/w/n/e params, the response is byte-for-byte identical to the pre-#288 implementation. (No filtering, no extra fields, no ETag change.) * With s/w/n/e supplied, heavy/dense layers are filtered to that viewport with a 20% padding box. * Light reference layers (datacenters, military_bases, power_plants, satellites, news, weather, …) are NEVER filtered, even when bounds are supplied — panning must never reveal an "empty world" of infrastructure. * World-scale bounds (lng_span >= 300 OR lat_span >= 120) short-circuit filtering and share the global ETag. * The ETag includes a 1°-quantized bbox so two viewports never poison each other's 304 cache. """ import pytest # ───────────────────────── /api/live-data/fast ───────────────────────────── class TestFastBboxFiltering: def _seed_fast(self, monkeypatch): """Plant deterministic heavy + light fixtures across the globe.""" from services.fetchers import _store # Heavy collections: dense across the world. commercial = [ {"lat": -60.0, "lng": -120.0, "id": "f-sw"}, # south Pacific {"lat": 35.0, "lng": -75.0, "id": "f-ne"}, # eastern US {"lat": 35.0, "lng": 100.0, "id": "f-asia"}, # Asia ] ships = [ {"lat": -60.0, "lng": -120.0, "id": "s-sw"}, {"lat": 35.0, "lng": -75.0, "id": "s-ne"}, ] cctv = [{"lat": 35.0, "lng": -75.0, "id": "c-1"}] # Sigint heavy collection. sigint = [ {"source": "meshtastic", "lat": 35.0, "lng": -75.0, "id": "sig-east"}, {"source": "meshtastic", "lat": 35.0, "lng": 100.0, "id": "sig-asia"}, ] # Light/reference layer — must NEVER be filtered. satellites = [ {"lat": -60.0, "lng": -120.0, "id": "sat-sw"}, {"lat": 35.0, "lng": -75.0, "id": "sat-ne"}, {"lat": 35.0, "lng": 100.0, "id": "sat-asia"}, ] monkeypatch.setitem(_store.latest_data, "commercial_flights", commercial) monkeypatch.setitem(_store.latest_data, "ships", ships) monkeypatch.setitem(_store.latest_data, "cctv", cctv) monkeypatch.setitem(_store.latest_data, "sigint", sigint) monkeypatch.setitem(_store.latest_data, "satellites", satellites) # Ensure all layers are on so the response includes them. for layer in ( "flights", "ships_military", "ships_cargo", "ships_civilian", "ships_passenger", "ships_tracked_yachts", "cctv", "sigint_meshtastic", "sigint_aprs", "satellites", ): monkeypatch.setitem(_store.active_layers, layer, True) def test_no_bbox_returns_world_data(self, client, monkeypatch): self._seed_fast(monkeypatch) r = client.get("/api/live-data/fast") assert r.status_code == 200 data = r.json() # All heavy fixtures pass through unchanged. assert len(data["commercial_flights"]) == 3 assert len(data["ships"]) == 2 assert len(data["sigint"]) == 2 # Light layer also full. assert len(data["satellites"]) == 3 def test_bbox_filters_heavy_layers(self, client, monkeypatch): self._seed_fast(monkeypatch) # Box tightly around the eastern-US fixture (lat 35, lng -75). # ±5° → after 20% padding inside _bbox_filter, ~±6° window. r = client.get("/api/live-data/fast?s=30&w=-80&n=40&e=-70") assert r.status_code == 200 data = r.json() # Heavy layers: only the eastern-US fixture survives. assert {f["id"] for f in data["commercial_flights"]} == {"f-ne"} assert {s["id"] for s in data["ships"]} == {"s-ne"} assert {c["id"] for c in data["cctv"]} == {"c-1"} assert {s["id"] for s in data["sigint"]} == {"sig-east"} def test_bbox_does_not_filter_light_layers(self, client, monkeypatch): self._seed_fast(monkeypatch) r = client.get("/api/live-data/fast?s=30&w=-80&n=40&e=-70") assert r.status_code == 200 data = r.json() # Satellites are a reference layer — must NOT be bbox-filtered. assert len(data["satellites"]) == 3 def test_world_scale_bbox_skips_filtering(self, client, monkeypatch): self._seed_fast(monkeypatch) # lng_span = 360 → treated as world-scale; same as no bbox. r = client.get("/api/live-data/fast?s=-90&w=-180&n=90&e=180") assert r.status_code == 200 data = r.json() assert len(data["commercial_flights"]) == 3 assert len(data["ships"]) == 2 def test_partial_bbox_is_treated_as_no_bbox(self, client, monkeypatch): self._seed_fast(monkeypatch) # Only three of four bounds → filtering must NOT engage. r = client.get("/api/live-data/fast?s=30&w=-80&n=40") assert r.status_code == 200 data = r.json() assert len(data["commercial_flights"]) == 3 def test_etag_changes_with_bbox(self, client, monkeypatch): self._seed_fast(monkeypatch) r_world = client.get("/api/live-data/fast") r_local = client.get("/api/live-data/fast?s=30&w=-80&n=40&e=-70") assert r_world.status_code == 200 assert r_local.status_code == 200 etag_world = r_world.headers.get("etag") etag_local = r_local.headers.get("etag") assert etag_world and etag_local assert etag_world != etag_local, ( "ETag must differ between world and regional bbox to prevent " "304 cache poisoning across viewports" ) def test_etag_stable_for_subdegree_pan(self, client, monkeypatch): self._seed_fast(monkeypatch) # Sub-degree pan should land in the same 1°-quantized bucket. r_a = client.get("/api/live-data/fast?s=30&w=-80&n=40&e=-70") r_b = client.get("/api/live-data/fast?s=30.3&w=-79.8&n=39.7&e=-70.4") assert r_a.headers.get("etag") == r_b.headers.get("etag") def test_if_none_match_returns_304_for_same_bbox(self, client, monkeypatch): self._seed_fast(monkeypatch) r1 = client.get("/api/live-data/fast?s=30&w=-80&n=40&e=-70") etag = r1.headers.get("etag") r2 = client.get( "/api/live-data/fast?s=30&w=-80&n=40&e=-70", headers={"If-None-Match": etag}, ) assert r2.status_code == 304 # ───────────────────────── /api/live-data/slow ───────────────────────────── class TestSlowBboxFiltering: def _seed_slow(self, monkeypatch): from services.fetchers import _store # Heavy collections. gdelt = [ {"lat": 35.0, "lng": -75.0, "id": "g-east"}, {"lat": 35.0, "lng": 100.0, "id": "g-asia"}, ] firms_fires = [ {"lat": 35.0, "lng": -75.0, "id": "fire-east"}, {"lat": -10.0, "lng": 120.0, "id": "fire-ido"}, ] # Light/reference layers — must always ship in full. datacenters = [ {"lat": 35.0, "lng": -75.0, "id": "dc-east"}, {"lat": 35.0, "lng": 100.0, "id": "dc-asia"}, {"lat": -10.0, "lng": 120.0, "id": "dc-ido"}, ] military_bases = [ {"lat": 35.0, "lng": -75.0, "id": "mb-east"}, {"lat": -10.0, "lng": 120.0, "id": "mb-ido"}, ] power_plants = [ {"lat": 35.0, "lng": -75.0, "id": "pp-east"}, {"lat": 35.0, "lng": 100.0, "id": "pp-asia"}, ] monkeypatch.setitem(_store.latest_data, "gdelt", gdelt) monkeypatch.setitem(_store.latest_data, "firms_fires", firms_fires) monkeypatch.setitem(_store.latest_data, "datacenters", datacenters) monkeypatch.setitem(_store.latest_data, "military_bases", military_bases) monkeypatch.setitem(_store.latest_data, "power_plants", power_plants) for layer in ( "global_incidents", "firms", "datacenters", "military_bases", "power_plants", ): monkeypatch.setitem(_store.active_layers, layer, True) def test_no_bbox_returns_world_data(self, client, monkeypatch): self._seed_slow(monkeypatch) r = client.get("/api/live-data/slow") assert r.status_code == 200 data = r.json() assert len(data["gdelt"]) == 2 assert len(data["firms_fires"]) == 2 assert len(data["datacenters"]) == 3 def test_bbox_filters_heavy_layers(self, client, monkeypatch): self._seed_slow(monkeypatch) r = client.get("/api/live-data/slow?s=30&w=-80&n=40&e=-70") assert r.status_code == 200 data = r.json() assert {g["id"] for g in data["gdelt"]} == {"g-east"} assert {f["id"] for f in data["firms_fires"]} == {"fire-east"} def test_bbox_leaves_reference_layers_untouched(self, client, monkeypatch): """Datacenters, bases, and power plants are infrastructure overlays — they must remain world-scale so panning never hides them.""" self._seed_slow(monkeypatch) r = client.get("/api/live-data/slow?s=30&w=-80&n=40&e=-70") assert r.status_code == 200 data = r.json() assert len(data["datacenters"]) == 3 assert len(data["military_bases"]) == 2 assert len(data["power_plants"]) == 2 def test_antimeridian_bbox(self, client, monkeypatch): from services.fetchers import _store # Box that straddles the antimeridian (Pacific): w=170, e=-170. gdelt = [ {"lat": 0.0, "lng": 175.0, "id": "in-west"}, {"lat": 0.0, "lng": -175.0, "id": "in-east"}, {"lat": 0.0, "lng": 0.0, "id": "out-mid"}, ] monkeypatch.setitem(_store.latest_data, "gdelt", gdelt) monkeypatch.setitem(_store.active_layers, "global_incidents", True) r = client.get("/api/live-data/slow?s=-10&w=170&n=10&e=-170") assert r.status_code == 200 data = r.json() ids = {g["id"] for g in data["gdelt"]} assert "in-west" in ids assert "in-east" in ids assert "out-mid" not in ids # ─────────────────── Direct helper coverage (defensive) ───────────────────── class TestHelpers: def test_has_full_bbox(self): from routers.data import _has_full_bbox assert _has_full_bbox(1, 2, 3, 4) assert not _has_full_bbox(None, 2, 3, 4) assert not _has_full_bbox(1, None, 3, 4) assert not _has_full_bbox(1, 2, None, 4) assert not _has_full_bbox(1, 2, 3, None) def test_bbox_etag_suffix_quantizes(self): from routers.data import _bbox_etag_suffix a = _bbox_etag_suffix(30.1, -79.6, 39.9, -70.1) b = _bbox_etag_suffix(30.4, -79.2, 39.4, -70.8) assert a == b, "Sub-degree pan must collapse to the same ETag suffix" assert a.startswith("|bbox=") def test_bbox_etag_suffix_world_collapses(self): from routers.data import _bbox_etag_suffix # World-scale → empty suffix (shares the global ETag). assert _bbox_etag_suffix(-90, -180, 90, 180) == "" def test_bbox_etag_suffix_partial_is_empty(self): from routers.data import _bbox_etag_suffix assert _bbox_etag_suffix(None, -180, 90, 180) == "" def test_apply_bbox_preserves_non_list_values(self): from routers.data import _apply_bbox_to_payload, _FAST_BBOX_HEAVY_KEYS payload = { "commercial_flights": [{"lat": 35, "lng": -75, "id": "x"}], "satellite_source": "tle", # not a list, must pass through "sigint_totals": {"total": 1}, # dict — must pass through } out = _apply_bbox_to_payload(dict(payload), _FAST_BBOX_HEAVY_KEYS, 30, -80, 40, -70) assert out["satellite_source"] == "tle" assert out["sigint_totals"] == {"total": 1}