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.
152 lines
6.2 KiB
Python
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
|