"""CelesTrak satellites overlay: GP JSON parser, 2h cache, groups, bbox.""" from __future__ import annotations import asyncio from datetime import datetime, timezone import httpx import satellites from live_layers import _cache, overlay_catalog from main import app BASE = "http://test" # Two real CelesTrak GP JSON records (trimmed to the OMM fields sgp4 needs). ISS = { "OBJECT_NAME": "ISS (ZARYA)", "OBJECT_ID": "1998-067A", "EPOCH": "2026-08-31T11:11:23.184384", "MEAN_MOTION": 15.4894954, "ECCENTRICITY": 0.00050456, "INCLINATION": 51.6314, "RA_OF_ASC_NODE": 287.5025, "ARG_OF_PERICENTER": 92.8598, "MEAN_ANOMALY": 267.2968, "EPHEMERIS_TYPE": 0, "CLASSIFICATION_TYPE": "U", "NORAD_CAT_ID": 25544, "ELEMENT_SET_NO": 999, "REV_AT_EPOCH": 58342, "BSTAR": 9.9862358e-5, "MEAN_MOTION_DOT": 5.046e-5, "MEAN_MOTION_DDOT": 0, } HST = { "OBJECT_NAME": "HST", "OBJECT_ID": "1990-037B", "EPOCH": "2026-08-31T11:11:23.184384", "MEAN_MOTION": 15.0865888, "ECCENTRICITY": 0.0002426, "INCLINATION": 28.4697, "RA_OF_ASC_NODE": 102.1854, "ARG_OF_PERICENTER": 152.8462, "MEAN_ANOMALY": 207.2795, "EPHEMERIS_TYPE": 0, "CLASSIFICATION_TYPE": "U", "NORAD_CAT_ID": 20580, "ELEMENT_SET_NO": 999, "REV_AT_EPOCH": 12345, "BSTAR": 2.9e-5, "MEAN_MOTION_DOT": 0.0, "MEAN_MOTION_DDOT": 0, } FIXTURE = [ISS, HST] NOW = datetime(2026, 8, 31, 12, 0, 0, tzinfo=timezone.utc) async def _get(path: str) -> httpx.Response: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url=BASE) as client: return await client.get(path) def test_propagate_gp_shape(): rows = satellites.propagate_gp(FIXTURE, "stations", NOW) assert len(rows) == 2 by_id = {r["id"]: r for r in rows} assert set(by_id) == {"25544", "20580"} iss = by_id["25544"] assert iss["name"] == "ISS (ZARYA)" assert iss["group"] == "stations" # ISS is in LEO: ~400 km, |lat| <= inclination 51.63, lon in range. assert 300 < iss["alt_km"] < 500 assert -51.7 <= iss["lat"] <= 51.7 assert -180 <= iss["lon"] <= 180 for key in ("id", "name", "lat", "lon", "alt_km", "group"): assert key in iss def test_propagate_gp_skips_malformed(): bad = [{"OBJECT_NAME": "x"}, None, 42, {"NORAD_CAT_ID": 1}] assert satellites.propagate_gp(bad, "stations", NOW) == [] def test_parse_groups_defaults_and_validation(): assert satellites.parse_groups("stations,weather") == ["stations", "weather"] assert satellites.parse_groups("weather,gps-ops") == ["weather", "gps-ops"] # starlink is allowed only when explicitly requested assert satellites.parse_groups("starlink") == ["starlink"] assert satellites.parse_groups("stations,stations") == ["stations"] for bad in ("", None, "debris", "stations,active", "stations, weather, active"): try: satellites.parse_groups(bad) except ValueError: pass else: raise AssertionError(f"expected ValueError for {bad!r}") def test_overlay_catalog_has_satellites_stub(): entry = overlay_catalog()["satellites"] assert entry["kind"] == "points" assert entry["endpoint"] == "/api/satellites" assert "CelesTrak" in entry["attribution"] def test_unknown_group_400(): resp = asyncio.run(_get("/api/satellites?groups=debris")) assert resp.status_code == 400 def test_default_groups_ok_with_fake_fetch(monkeypatch): async def fake(groups, bbox=None, limit=2000): return {"satellites": [], "source": "celestrak", "tle_epoch": None, "timestamp": "t"} monkeypatch.setattr("main.fetch_satellites", fake) resp = asyncio.run(_get("/api/satellites")) assert resp.status_code == 200 body = resp.json() assert body["source"] == "celestrak" assert "max-age" in (resp.headers.get("cache-control") or "").lower() def test_2h_cache_does_not_refetch(monkeypatch): satellites._last_good.clear() _cache.clear() hits = {"n": 0} class FakeResp: def __init__(self, data): self._data = data def raise_for_status(self): pass def json(self): return self._data class FakeClient: def __init__(self): pass async def get(self, url, params=None, headers=None): hits["n"] += 1 assert "celestrak.org/NORAD/elements/gp.php" in url return FakeResp(FIXTURE) monkeypatch.setattr("live_layers._http", FakeClient()) async def run(): p1 = await satellites.fetch_satellites(["stations"]) p2 = await satellites.fetch_satellites(["stations"]) return p1, p2 p1, p2 = asyncio.run(run()) assert len(p1["satellites"]) == 2 assert p1["tle_epoch"] == "2026-08-31T11:11:23.184384" # Same element blob served from cache (no refetch), same ids/epochs. assert [s["id"] for s in p2["satellites"]] == [s["id"] for s in p1["satellites"]] assert p2["tle_epoch"] == p1["tle_epoch"] assert hits["n"] == 1 _cache.clear() satellites._last_good.clear() def test_bbox_culls_satellites(monkeypatch): """bbox filtering in fetch_satellites, deterministic via fake propagation.""" async def fake_elements(group): return [{"x": 1}], "2026-08-31T11:11:23.184384" monkeypatch.setattr("satellites._group_elements", fake_elements) def fake_propagate(elements, group, now): return [ {"id": "a", "name": "A", "lat": 10.0, "lon": 20.0, "alt_km": 400.0, "group": group}, {"id": "b", "name": "B", "lat": 45.0, "lon": -70.0, "alt_km": 400.0, "group": group}, {"id": "c", "name": "C", "lat": -10.0, "lon": 30.0, "alt_km": 400.0, "group": group}, ] monkeypatch.setattr("satellites.propagate_gp", fake_propagate) payload = asyncio.run( satellites.fetch_satellites(["stations"], bbox="-80,0,-60,50") ) ids = [s["id"] for s in payload["satellites"]] assert ids == ["b"] # only (45, -70) falls inside the box def test_bbox_culls_nothing_when_empty(): from satellites import fetch_satellites # No bbox: all rows returned up to limit. # (skip network; just sanity-check the arg is accepted by signature) assert callable(fetch_satellites) def test_satnogs_fallback_parser(): payload = [{ "tle0": "0 ISS (ZARYA)", "tle1": "1 25544U 98067A 26243.85334329 .00004554 00000-0 90917-4 0 9992", "tle2": "2 25544 51.6312 285.5873 0005057 94.2999 265.8567 15.48953200583481", "norad_cat_id": 25544, "updated": "2026-09-01T01:19:54.327653Z", }] rows, epoch = satellites.propagate_satnogs_tle(payload, "stations", NOW) assert len(rows) == 1 row = rows[0] assert row["id"] == "25544" assert row["name"] == "ISS (ZARYA)" assert row["group"] == "stations" assert epoch == "2026-09-01T01:19:54.327653Z" assert 300 < row["alt_km"] < 500