"""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 pytest from fastapi.testclient import TestClient from web import app as appmod from data import climate from data import places @pytest.fixture def client(monkeypatch, history, recent): monkeypatch.setattr(climate, "get_history", lambda cell: (history.clone(), {"cached": True, "cache_age_days": 3})) monkeypatch.setattr(climate, "get_recent_forecast", lambda cell: recent.clone()) monkeypatch.setattr(climate, "load_cached_history", lambda cell: history.clone()) 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_api_version_reports_backend_contract(client): r = client.get("/thermograph/api/version") assert r.status_code == 200 body = r.json() assert {"backend_version", "min_frontend", "payload_ver"} <= body.keys() assert body["backend_version"] == "2" 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"] == history["date"].max().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 climate.WeatherUnavailable(climate.limit_message(False)) 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_upstream_failure_classification(client, monkeypatch): q = {"lat": 52.52, "lon": 13.4} def daily_quota(cell): raise climate.WeatherUnavailable(climate.limit_message(True), daily=True) monkeypatch.setattr(climate, "get_history", daily_quota) r = client.get("/thermograph/api/v2/grade", params=q) assert r.status_code == 503 and "tomorrow" in r.json()["detail"] # A raw upstream 429 that no fetcher classified still maps to a clean 503. def raw_429(cell): e = RuntimeError("upstream said no") e.response = type("R", (), {"status_code": 429})() raise e monkeypatch.setattr(climate, "get_history", raw_429) r = client.get("/thermograph/api/v2/grade", params=q) assert r.status_code == 503 and "rate-limited" in r.json()["detail"] # Anything else is a genuine upstream fault: 502 with the raw error. def boom(cell): raise RuntimeError("parquet cache corrupted") monkeypatch.setattr(climate, "get_history", boom) r = client.get("/thermograph/api/v2/grade", params=q) assert r.status_code == 502 and "parquet cache corrupted" 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"] == history["date"].max().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 _LOCAL_HIT = [{"name": "Seattle", "admin1": "Washington", "country": "United States", "country_code": "US", "lat": 47.6, "lon": -122.33, "population": 737015, "match": "prefix"}] def test_geocode_local_first_skips_network(client, monkeypatch): """A local GeoNames hit answers /geocode with no outbound Nominatim call, and the internal prefix/fuzzy tag is stripped from the wire shape.""" monkeypatch.setattr(places, "search", lambda q, limit=5: list(_LOCAL_HIT)) def _boom(*a, **k): raise AssertionError("Nominatim must not be called when the local index hits") monkeypatch.setattr(climate, "geocode_nominatim", _boom) r = client.get("/thermograph/api/v2/geocode", params={"q": "seattle"}) assert r.status_code == 200 res = r.json()["results"] assert res[0]["name"] == "Seattle" assert "match" not in res[0] def test_geocode_falls_back_to_nominatim_on_local_miss(client, monkeypatch): """A local miss (or a still-loading index) falls back to Nominatim /search — the path that covers neighbourhoods/postcodes the cities dump lacks.""" monkeypatch.setattr(places, "search", lambda q, limit=5: []) # genuine miss nom = [{"name": "West Seattle", "admin1": "Washington", "country": "United States", "country_code": "US", "lat": 47.57, "lon": -122.38, "population": None}] called = [] monkeypatch.setattr(climate, "geocode_nominatim", lambda q, count=5: (called.append(q), nom)[1]) r = client.get("/thermograph/api/v2/geocode", params={"q": "west seattle"}) assert r.status_code == 200 assert r.json()["results"][0]["name"] == "West Seattle" assert called == ["west seattle"] def test_suggest_is_local_only_no_network(client, monkeypatch): """Autocomplete is served purely from the local index — even a reachable Nominatim must never be hit per keystroke.""" monkeypatch.setattr(places, "search", lambda q, limit=5: list(_LOCAL_HIT)) def _boom(*a, **k): raise AssertionError("/suggest must not make an outbound geocoder call") monkeypatch.setattr(climate, "geocode_nominatim", _boom) r = client.get("/thermograph/api/v2/suggest", params={"q": "seattle"}) assert r.status_code == 200 body = r.json() assert body["results"][0]["name"] == "Seattle" assert body["corrected"] is None def test_cell_neighbors_flag_enqueues_warming(client, monkeypatch): from web import app as appmod monkeypatch.setattr(appmod, "_WARM_SEEN", {}) # No lifespan in TestClient without a context manager, so no worker drains # the queue — enqueued cells just accumulate for inspection. monkeypatch.setattr(appmod, "_warm_queue", __import__("queue").Queue()) r = client.get("/thermograph/api/v2/cell", params={"lat": 35.0, "lon": 25.0, "neighbors": 1}) assert r.status_code == 200 assert appmod._warm_queue.qsize() == 8 assert len(appmod._WARM_SEEN) == 8 # Same spot again within the TTL: nothing new enqueued. client.get("/thermograph/api/v2/cell", params={"lat": 35.0, "lon": 25.0, "neighbors": 1}, headers={"If-None-Match": r.headers["etag"]}) assert appmod._warm_queue.qsize() == 8 def test_warm_cell_materializes_history_slices(client, tmp_store, monkeypatch, history): from web import app as appmod from data import grid from api import payloads cell = grid.snap(-33.87, 151.21) appmod._warm_cell(cell) token = payloads.history_token(history) start_ts, end_ts = payloads.cal_span(history, None, None, 24) assert tmp_store.get_payload("calendar", cell["id"], payloads.calendar_key(start_ts, end_ts, 24), token) is not None last = history["date"].max() assert tmp_store.get_payload("day", cell["id"], payloads.day_key(last), token) is not None def test_warm_cell_never_fetches_upstream(client, monkeypatch): from web import app as appmod def boom(cell): raise AssertionError("warming 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) appmod._warm_cell({"id": "1_1", "center_lat": 0.03, "center_lon": 0.03}) # cold: no-op def _score_history(years=45, seed=5): import datetime import numpy as np import polars as pl end = datetime.date(2026, 7, 11) start = datetime.date(end.year - years, end.month, end.day) dates = [start + datetime.timedelta(days=i) for i in range((end - start).days + 1)] n = len(dates) rng = np.random.default_rng(seed) doy = np.array([d.timetuple().tm_yday for d in dates]) tmax = 55 + 30 * np.sin((doy - 100) / 366.0 * 2 * np.pi) + rng.normal(0, 8, n) return pl.DataFrame({ "date": dates, "tmax": np.round(tmax, 1), "tmin": np.round(tmax - 15, 1), "feels": np.round(tmax + 1, 1), "humid": np.round(np.clip(12 + rng.normal(0, 3, n), 1, None), 1), "wetbulb": np.round(tmax - 12, 1), "wind": np.round(np.clip(8 + rng.normal(0, 3, n), 0, None), 1), "gust": np.round(np.clip(16 + rng.normal(0, 5, n), 0, None), 1), "precip": np.where(rng.random(n) < 0.3, 0.2, 0.0), }).with_columns(pl.col("date").dt.ordinal_day().cast(pl.Int16).alias("doy")) @pytest.fixture def score_client(monkeypatch): hist = _score_history() monkeypatch.setattr(climate, "get_history", lambda cell: (hist.clone(), {"cached": True, "cache_age_days": 3})) monkeypatch.setattr(climate, "reverse_geocode", lambda lat, lon: "Testville, Washington") return TestClient(appmod.app) def test_score_shape_and_conditional_revalidation(score_client): r = score_client.get("/thermograph/api/v2/score", params=Q) assert r.status_code == 200 body = r.json() assert body["place"] == "Testville, Washington" s = body["scores"] assert set(s["slices"]) == {"annual", "djf", "mam", "jja", "son"} assert s["slices"]["annual"]["overall"]["score"] is not None etag = r.headers["etag"] r304 = score_client.get("/thermograph/api/v2/score", params=Q, headers={"If-None-Match": etag}) assert r304.status_code == 304 and r304.headers["etag"] == etag # Second GET replays the exact same bytes from the derived store. r2 = score_client.get("/thermograph/api/v2/score", params=Q) assert r2.status_code == 200 and r2.content == r.content