thermograph/backend/tests/web/test_api.py

360 lines
16 KiB
Python
Raw Permalink 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 pytest
from fastapi.testclient import TestClient
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
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",
Migrate backend dataframe layer from pandas to polars (#90) * Migrate backend dataframe layer from pandas to polars Replace pandas with polars across the backend, dropping both pandas and its pyarrow parquet engine from the dependency set. numpy stays (the grading percentile math is unchanged). - climate.py: parquet IO, source→frame mappings, cache read/topup on polars. New _normalize_read casts the cached `date` column to pl.Date (older files were written by pandas as datetime64[ns]); frames now unify missing values as null so the grading boundary drops them consistently across sources. - grading.py: keep the numpy percentile core; swap the frame→numpy bridge to .to_numpy()/.drop_nulls(), day-of-year/year to polars dt expressions, and the per-row loop to iter_rows(named=True). - views.py: filter/anti-join/concat replace boolean-mask, isin and pd.concat; scalar dates are stdlib datetime.date; a local _months_before helper replaces DateOffset(months=) for the calendar-range default. - app.py, migrate.py: request-date parsing uses datetime.date, removing pandas from the endpoint and migrate layers entirely. - The date column is pl.Date end to end, eliminating the pandas normalize() calls and comparing cleanly against stdlib dates. Payloads are unchanged: calendar, day, grade and forecast responses are byte-for-byte identical to the pandas implementation on the same cached record. Tests ported to polars fixtures, with added coverage for the combined feels-like fallback, calendar month-offset (month-end/leap), and the concat/dedup "fresher source wins" rule. * Port notify.py to polars after merging dev's account system Merge origin/dev (accounts + notification subscriptions) and carry the pandas→ polars migration into the newly added notify.py, which the merge brought in still using pandas — with pandas removed from requirements this broke its import. - notify.py: _candidate_rows filters/sorts the recent bundle with polars expressions and returns iter_rows dicts; date scalars are datetime.date; history/recent emptiness via is_empty(). - test_notify.py: synthetic history/rows built with polars + datetime.
2026-07-15 19:07:38 +00:00
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()
Migrate backend dataframe layer from pandas to polars (#90) * Migrate backend dataframe layer from pandas to polars Replace pandas with polars across the backend, dropping both pandas and its pyarrow parquet engine from the dependency set. numpy stays (the grading percentile math is unchanged). - climate.py: parquet IO, source→frame mappings, cache read/topup on polars. New _normalize_read casts the cached `date` column to pl.Date (older files were written by pandas as datetime64[ns]); frames now unify missing values as null so the grading boundary drops them consistently across sources. - grading.py: keep the numpy percentile core; swap the frame→numpy bridge to .to_numpy()/.drop_nulls(), day-of-year/year to polars dt expressions, and the per-row loop to iter_rows(named=True). - views.py: filter/anti-join/concat replace boolean-mask, isin and pd.concat; scalar dates are stdlib datetime.date; a local _months_before helper replaces DateOffset(months=) for the calendar-range default. - app.py, migrate.py: request-date parsing uses datetime.date, removing pandas from the endpoint and migrate layers entirely. - The date column is pl.Date end to end, eliminating the pandas normalize() calls and comparing cleanly against stdlib dates. Payloads are unchanged: calendar, day, grade and forecast responses are byte-for-byte identical to the pandas implementation on the same cached record. Tests ported to polars fixtures, with added coverage for the combined feels-like fallback, calendar month-offset (month-end/leap), and the concat/dedup "fresher source wins" rule. * Port notify.py to polars after merging dev's account system Merge origin/dev (accounts + notification subscriptions) and carry the pandas→ polars migration into the newly added notify.py, which the merge brought in still using pandas — with pandas removed from requirements this broke its import. - notify.py: _candidate_rows filters/sorts the recent bundle with polars expressions and returns iter_rows dicts; date scalars are datetime.date; history/recent emptiness via is_empty(). - test_notify.py: synthetic history/rows built with polars + datetime.
2026-07-15 19:07:38 +00:00
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()
Migrate backend dataframe layer from pandas to polars (#90) * Migrate backend dataframe layer from pandas to polars Replace pandas with polars across the backend, dropping both pandas and its pyarrow parquet engine from the dependency set. numpy stays (the grading percentile math is unchanged). - climate.py: parquet IO, source→frame mappings, cache read/topup on polars. New _normalize_read casts the cached `date` column to pl.Date (older files were written by pandas as datetime64[ns]); frames now unify missing values as null so the grading boundary drops them consistently across sources. - grading.py: keep the numpy percentile core; swap the frame→numpy bridge to .to_numpy()/.drop_nulls(), day-of-year/year to polars dt expressions, and the per-row loop to iter_rows(named=True). - views.py: filter/anti-join/concat replace boolean-mask, isin and pd.concat; scalar dates are stdlib datetime.date; a local _months_before helper replaces DateOffset(months=) for the calendar-range default. - app.py, migrate.py: request-date parsing uses datetime.date, removing pandas from the endpoint and migrate layers entirely. - The date column is pl.Date end to end, eliminating the pandas normalize() calls and comparing cleanly against stdlib dates. Payloads are unchanged: calendar, day, grade and forecast responses are byte-for-byte identical to the pandas implementation on the same cached record. Tests ported to polars fixtures, with added coverage for the combined feels-like fallback, calendar month-offset (month-end/leap), and the concat/dedup "fresher source wins" rule. * Port notify.py to polars after merging dev's account system Merge origin/dev (accounts + notification subscriptions) and carry the pandas→ polars migration into the newly added notify.py, which the merge brought in still using pandas — with pandas removed from requirements this broke its import. - notify.py: _candidate_rows filters/sorts the recent bundle with polars expressions and returns iter_rows dicts; date scalars are datetime.date; history/recent emptiness via is_empty(). - test_notify.py: synthetic history/rows built with polars + datetime.
2026-07-15 19:07:38 +00:00
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):
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
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):
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
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
Migrate backend dataframe layer from pandas to polars (#90) * Migrate backend dataframe layer from pandas to polars Replace pandas with polars across the backend, dropping both pandas and its pyarrow parquet engine from the dependency set. numpy stays (the grading percentile math is unchanged). - climate.py: parquet IO, source→frame mappings, cache read/topup on polars. New _normalize_read casts the cached `date` column to pl.Date (older files were written by pandas as datetime64[ns]); frames now unify missing values as null so the grading boundary drops them consistently across sources. - grading.py: keep the numpy percentile core; swap the frame→numpy bridge to .to_numpy()/.drop_nulls(), day-of-year/year to polars dt expressions, and the per-row loop to iter_rows(named=True). - views.py: filter/anti-join/concat replace boolean-mask, isin and pd.concat; scalar dates are stdlib datetime.date; a local _months_before helper replaces DateOffset(months=) for the calendar-range default. - app.py, migrate.py: request-date parsing uses datetime.date, removing pandas from the endpoint and migrate layers entirely. - The date column is pl.Date end to end, eliminating the pandas normalize() calls and comparing cleanly against stdlib dates. Payloads are unchanged: calendar, day, grade and forecast responses are byte-for-byte identical to the pandas implementation on the same cached record. Tests ported to polars fixtures, with added coverage for the combined feels-like fallback, calendar month-offset (month-end/leap), and the concat/dedup "fresher source wins" rule. * Port notify.py to polars after merging dev's account system Merge origin/dev (accounts + notification subscriptions) and carry the pandas→ polars migration into the newly added notify.py, which the merge brought in still using pandas — with pandas removed from requirements this broke its import. - notify.py: _candidate_rows filters/sorts the recent bundle with polars expressions and returns iter_rows dicts; date scalars are datetime.date; history/recent emptiness via is_empty(). - test_notify.py: synthetic history/rows built with polars + datetime.
2026-07-15 19:07:38 +00:00
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):
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
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
UI events v2: consented session tier, failed-search capture, privacy rewrite Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
2026-07-23 23:11:06 +00:00
# --- failed-search capture (server-side only) ----------------------------------
def test_zero_result_search_records_the_query_text_server_side(client, monkeypatch):
"""Decision 2: the miss text is captured in the handler, never sent from the
browser. Only misses a query that found something is never stored."""
from core import events
seen = []
monkeypatch.setattr(events, "ENABLED", True)
monkeypatch.setattr(events, "SEARCH_MISS_ENABLED", True)
monkeypatch.setattr(events, "_upsert", lambda *a: None)
monkeypatch.setattr(events, "_upsert_search_miss", lambda q: seen.append(q))
monkeypatch.setattr(events.audit, "log_activity", lambda *a: None)
monkeypatch.setattr(places, "suggest", lambda q, up, lim: ([], None))
client.get("/thermograph/api/v2/suggest", params={"q": "Atlantis"})
assert seen == ["atlantis"]
monkeypatch.setattr(places, "suggest",
lambda q, up, lim: ([{"name": "Seattle"}], None))
client.get("/thermograph/api/v2/suggest", params={"q": "Seattle"})
assert seen == ["atlantis"] # a hit is never stored as text
def test_search_miss_text_is_not_written_to_the_access_log(client, monkeypatch):
"""The app's own access log records url.path, never the query string — so the
search text cannot leak into it. (Caddy's log is a separate concern: it logs
the full URI, which is why the miss capture must stay server-side and why the
Caddy log format needs the query stripped -- see UI-EVENTS.md.)"""
from core import audit
lines = []
monkeypatch.setattr(audit, "log_access", lambda rec: lines.append(rec))
monkeypatch.setattr(places, "suggest", lambda q, up, lim: ([], None))
client.get("/thermograph/api/v2/suggest", params={"q": "somewhere-secret"})
assert lines and all("somewhere-secret" not in str(v) for rec in lines for v in rec.values())