thermograph/backend/tests/api/test_content_routes.py
Emi Griffith 72e141afd3 Emit https origin for the public host in canonical/og/sitemap URLs
The SSR frontend's _origin() (canonical, og:url, og:image, robots, sitemap
<loc>) and the backend content API's _origin() (the jsonld url folded into
each payload) both trusted x-forwarded-proto / request.url.scheme. Behind
Caddy — which terminates TLS and reverse-proxies plain HTTP — those read
"http", so on the HTTPS-only public site every absolute URL emitted http://,
which 308-redirects to https://: canonicals self-conflict and the sitemap
lists redirecting URLs.

Take the scheme from the configured public origin (THERMOGRAPH_BASE_URL, set
per-host in the deploy env) when the request arrives on that host, so prod and
beta emit https. localhost and LAN dev never match the public host and keep the
observed scheme, so plain-HTTP development is unchanged; the frontend keeps its
X-Forwarded-Host precedence for the proxy-fallback path.

Tests assert canonical/og:url/og:image, robots Sitemap and every sitemap <loc>
are https for the public host (and via X-Forwarded-Host), that a LAN host stays
http, and that the backend payload's jsonld url is likewise https.
2026-07-24 16:04:42 -07:00

152 lines
6.2 KiB
Python

"""Route tests for the SSR content JSON API (api/content_routes.py).
The point of these is the latency fix: a derived-store cache HIT must be O(1) —
it must NOT load the ~45-year archive. So the weather layer is faked with call
counters, and the core assertion is that a second identical request loads the
history zero more times than the first. ETag/304 revalidation and the 404/payload
shapes are covered alongside, against a fresh per-test store.
"""
import pytest
from fastapi.testclient import TestClient
from web import app as appmod
from data import climate
from data import cities
BASE = "/thermograph/api/v2/content"
CITY = {
"slug": "testville",
"name": "Testville",
"admin1": "Washington",
"country": "United States",
"country_code": "US",
"lat": 47.6062,
"lon": -122.3321,
"population": 100000,
}
@pytest.fixture
def counts():
return {"load_cached_history": 0, "get_history": 0}
@pytest.fixture
def client(monkeypatch, history, recent, counts, tmp_store):
"""TestClient with the weather + city layer faked and the history loaders
wrapped in counters. tmp_store gives each test a fresh derived store so the
miss->hit sequence is deterministic."""
def load_cached_history(cell):
counts["load_cached_history"] += 1
return history.clone()
def get_history(cell):
counts["get_history"] += 1
return history.clone(), {"cached": True, "cache_age_days": 3}
monkeypatch.setattr(climate, "load_cached_history", load_cached_history)
monkeypatch.setattr(climate, "get_history", get_history)
monkeypatch.setattr(climate, "get_recent_forecast", lambda cell: recent.clone())
# Stable token: content_token() -> payloads -> climate.history_max_date.
monkeypatch.setattr(climate, "history_max_date", lambda cell_id: "2026-07-01")
monkeypatch.setattr(climate, "reverse_geocode", lambda lat, lon: "Testville, Washington")
# Only "testville" is a known city; anything else -> 404.
monkeypatch.setattr(cities, "get", lambda slug: CITY if slug == "testville" else None)
return TestClient(appmod.app)
# --- the regression guard: a cache hit loads no history ----------------------
def test_cache_hit_does_not_load_history(client, counts):
"""First request misses -> builds -> loads history exactly once. A second
identical request is served from the derived store and loads history ZERO
more times. This is the whole point of the fix."""
r1 = client.get(f"{BASE}/city/testville")
assert r1.status_code == 200
assert counts["load_cached_history"] == 1
assert counts["get_history"] == 0 # cached load was non-empty, no live fetch
r2 = client.get(f"{BASE}/city/testville")
assert r2.status_code == 200
assert r2.content == r1.content # replayed from the store
assert counts["load_cached_history"] == 1 # unchanged: no history load on the hit
assert counts["get_history"] == 0
def test_304_revalidation_loads_no_history(client, counts):
"""If-None-Match on a stored payload -> 304 with no history load."""
r1 = client.get(f"{BASE}/city/testville")
assert r1.status_code == 200
etag = r1.headers["etag"]
before = counts["load_cached_history"]
r304 = client.get(f"{BASE}/city/testville", headers={"If-None-Match": etag})
assert r304.status_code == 304
assert r304.headers["etag"] == etag
assert counts["load_cached_history"] == before # 304 loaded nothing
def test_month_and_records_hits_skip_history_load(client, counts):
"""Same O(1)-hit guarantee for the month and records handlers."""
for path in ("month/july", "records"):
counts["load_cached_history"] = 0
assert client.get(f"{BASE}/city/testville/{path}").status_code == 200
assert counts["load_cached_history"] == 1
assert client.get(f"{BASE}/city/testville/{path}").status_code == 200
assert counts["load_cached_history"] == 1 # second call is a store hit
# --- payload shapes ----------------------------------------------------------
def test_city_payload_shape(client):
body = client.get(f"{BASE}/city/testville").json()
assert body["city"]["slug"] == "testville"
assert body["canonical_path"] == "/climate/testville"
assert len(body["months"]) == 12
assert body["today_vs_normal"] is not None # recent forecast folded in
def test_month_payload_shape(client):
body = client.get(f"{BASE}/city/testville/month/july").json()
assert body["month_slug"] == "july"
assert body["canonical_path"] == "/climate/testville/july"
def test_records_payload_shape(client):
body = client.get(f"{BASE}/city/testville/records").json()
assert body["canonical_path"] == "/climate/testville/records"
# --- https origin (jsonld url) -----------------------------------------------
def test_public_host_jsonld_url_is_https(client):
"""The public site is HTTPS-only behind Caddy, which proxies plain HTTP, so
x-forwarded-proto reads "http". A request on the configured public host
(THERMOGRAPH_BASE_URL defaults to https://thermograph.org) must still fold an
https:// origin into the payload's jsonld url; otherwise the frontend renders
an http:// canonical/JSON-LD that 308-redirects."""
import json as _json
hdr = {"host": "thermograph.org", "x-forwarded-proto": "http"}
for path in ("city/testville", "city/testville/records"):
s = _json.dumps(client.get(f"{BASE}/{path}", headers=hdr).json())
assert "https://thermograph.org/thermograph/climate/testville" in s
assert "http://thermograph.org" not in s
def test_non_public_host_jsonld_keeps_forwarded_scheme(client):
hdr = {"host": "192.168.1.10:8000", "x-forwarded-proto": "http"}
body = client.get(f"{BASE}/city/testville", headers=hdr).json()
assert body["jsonld"]["@graph"][0]["url"].startswith("http://192.168.1.10:8000/")
# --- 404s --------------------------------------------------------------------
def test_unknown_slug_is_404(client, counts):
for path in ("city/nope", "city/nope/month/july", "city/nope/records"):
assert client.get(f"{BASE}/{path}").status_code == 404
assert counts["load_cached_history"] == 0 # never reached the archive
def test_unknown_month_is_404(client):
assert client.get(f"{BASE}/city/testville/month/smarch").status_code == 404