thermograph/tests/test_api.py

169 lines
7.3 KiB
Python
Raw Normal View History

"""Route-level tests over the FastAPI app with the weather/geocode layer faked —
they exercise the real routing, validation, derived-store and ETag plumbing, and
would catch wiring regressions (e.g. a handler calling a deleted helper)."""
import pandas as pd
import pytest
from fastapi.testclient import TestClient
import app as appmod
import climate
@pytest.fixture
def client(monkeypatch, history, recent):
monkeypatch.setattr(climate, "get_history",
lambda cell: (history.copy(), {"cached": True, "cache_age_days": 3}))
monkeypatch.setattr(climate, "get_recent_forecast", lambda cell: recent.copy())
monkeypatch.setattr(climate, "load_cached_history", lambda cell: history.copy())
monkeypatch.setattr(climate, "recent_stamp", lambda cell_id: "rs-test")
monkeypatch.setattr(climate, "reverse_geocode", lambda lat, lon: "Testville, Washington")
return TestClient(appmod.app)
Q = {"lat": 47.6062, "lon": -122.3321}
def test_place_serves_any_point_worldwide(client):
for q in (Q, {"lat": 48.8566, "lon": 2.3522}, {"lat": -33.8688, "lon": 151.2093}):
r = client.get("/thermograph/api/v2/place", params=q)
assert r.status_code == 200
assert r.json()["place"] == "Testville, Washington"
assert set(r.json()["cell"]) == {"center_lat", "center_lon"}
def test_grade_shape_and_conditional_revalidation(client, history):
r = client.get("/thermograph/api/v2/grade", params=Q)
assert r.status_code == 200
body = r.json()
assert body["place"] == "Testville, Washington"
assert body["climatology"]["tmax"] is not None
days = [d["date"] for d in body["recent"]]
assert days == sorted(days, reverse=True) # newest first
assert body["recent"][0]["tmax"]["grade"]
etag = r.headers["etag"]
r304 = client.get("/thermograph/api/v2/grade", params=Q,
headers={"If-None-Match": etag})
assert r304.status_code == 304 and r304.headers["etag"] == etag
# Without the validator the derived store replays the exact same bytes.
r2 = client.get("/thermograph/api/v2/grade", params=Q)
assert r2.status_code == 200 and r2.content == r.content
def test_grade_is_aliased_across_api_versions(client):
for prefix in ("api", "api/v1", "api/v2"):
assert client.get(f"/thermograph/{prefix}/grade", params=Q).status_code == 200
def test_query_validation_rejects_out_of_range(client):
assert client.get("/thermograph/api/v2/grade",
params={"lat": 999, "lon": 0}).status_code == 422
assert client.get("/thermograph/api/v2/grade",
params={**Q, "days": 0}).status_code == 422
def test_day_detail_and_ladders(client, history):
r = client.get("/thermograph/api/v2/day", params=Q)
assert r.status_code == 200
body = r.json()
assert body["latest"] == pd.Timestamp(history["date"].max()).date().isoformat()
tmax = body["detail"]["metrics"]["tmax"]
assert tmax["ladder"]["tiers"][0]["c"] == "rec-hot"
assert tmax["obs"]["grade"]
@pytest.mark.parametrize("path", ["grade", "calendar", "day", "forecast", "cell"])
def test_every_data_route_maps_rate_limit_to_503(client, monkeypatch, path):
def rate_limited(cell):
raise RuntimeError("Open-Meteo request failed (429): rate-limited")
monkeypatch.setattr(climate, "get_history", rate_limited)
# A distinct spot per route: a store row cached by an earlier test would
# otherwise be checked only after the (failing) history fetch anyway.
r = client.get(f"/thermograph/api/v2/{path}", params={"lat": 51.5, "lon": -0.1})
assert r.status_code == 503
assert "rate-limited" in r.json()["detail"]
def test_cell_prefetch_never_fetches_upstream(client, monkeypatch):
def boom(cell):
raise AssertionError("prefetch=1 must never fetch weather upstream")
monkeypatch.setattr(climate, "get_history", boom)
monkeypatch.setattr(climate, "get_recent_forecast", boom)
monkeypatch.setattr(climate, "load_cached_history", lambda cell: None)
r = client.get("/thermograph/api/v2/cell", params={**Q, "prefetch": 1})
assert r.status_code == 204 # cold cell: no body, no quota spent
def test_cell_prefetch_builds_history_slices_only(client):
r = client.get("/thermograph/api/v2/cell", params={"lat": 10.0, "lon": 10.0, "prefetch": 1})
assert r.status_code == 200
assert set(r.json()["slices"]) == {"calendar", "day"}
def test_calendar_placeless_payload_is_not_persisted(client, monkeypatch):
q = {"lat": -10.0, "lon": 20.0, "months": 2}
monkeypatch.setattr(climate, "reverse_geocode", lambda lat, lon: None)
r = client.get("/thermograph/api/v2/calendar", params=q)
assert r.status_code == 200 and r.json()["place"] is None
# The placeless payload wasn't cached, so the next request retries the
# label instead of replaying bare coordinates for the life of the token.
monkeypatch.setattr(climate, "reverse_geocode", lambda lat, lon: "Resolved, Now")
assert client.get("/thermograph/api/v2/calendar", params=q).json()["place"] == "Resolved, Now"
def test_calendar_compact_range(client, history):
r = client.get("/thermograph/api/v2/calendar", params={**Q, "months": 2})
assert r.status_code == 200
body = r.json()
assert body["range"]["end"] == pd.Timestamp(history["date"].max()).date().isoformat()
day = body["days"][0]
assert {"date", "dsr", "tmax", "tmin", "precip"} <= set(day)
assert set(day["tmax"]) == {"v", "pct", "c", "g"}
def test_forecast_grades_future_days(client):
r = client.get("/thermograph/api/v2/forecast", params=Q)
assert r.status_code == 200
body = r.json()
assert body["forecast"] is True
days = [d["date"] for d in body["recent"]]
assert days and days == sorted(days, reverse=True) # furthest-out first
assert min(days) > body["target_date"] # strictly future
def test_cell_bundle_matches_per_view_payloads(client):
r = client.get("/thermograph/api/v2/cell", params=Q)
assert r.status_code == 200
slices = r.json()["slices"]
assert set(slices) == {"calendar", "grade", "forecast", "day"}
for s in slices.values():
assert s["etag"] and s["data"]
# The grade slice must be byte-for-byte what /grade serves (same store row).
grade = client.get("/thermograph/api/v2/grade", params=Q)
assert grade.json() == slices["grade"]["data"]
assert grade.headers["etag"] == slices["grade"]["etag"]
r304 = client.get("/thermograph/api/v2/cell", params=Q,
headers={"If-None-Match": r.headers["etag"]})
assert r304.status_code == 304
def test_suggest_falls_back_to_upstream_geocoder(client, monkeypatch):
upstream = [{"name": "Seattle", "admin1": "Washington", "country": "United States",
"country_code": "US", "lat": 47.6, "lon": -122.33, "population": 737015}]
monkeypatch.setattr(climate, "geocode", lambda q, count=5: list(upstream))
r = client.get("/thermograph/api/v2/suggest", params={"q": "seattle-fallback-probe"})
assert r.status_code == 200
body = r.json()
assert body["results"][0]["name"] == "Seattle"
assert body["corrected"] is None
def test_pages_serve_with_origin_filled_in(client):
r = client.get("/thermograph/")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert "__ORIGIN__" not in r.text
assert client.head("/thermograph/calendar").status_code == 200