Repo-split Stage 4: cut prod traffic to backend + frontend, dual-service (#14)

This commit is contained in:
emi 2026-07-21 20:01:30 +00:00
parent 1738f61c8f
commit 11872d7e64
6 changed files with 377 additions and 20 deletions

View file

@ -20,6 +20,15 @@ API_BASE = os.environ.get("THERMOGRAPH_API_BASE_INTERNAL")
if not API_BASE:
raise RuntimeError("THERMOGRAPH_API_BASE_INTERNAL must be set (e.g. http://backend:8137)")
# Backend's own routes (including the content API this client calls) sit
# under ITS OWN THERMOGRAPH_BASE, exactly like content.py computes for
# frontend's own routes -- both processes are always configured with the same
# value in every real deployment (compose, run.sh, CI all set it identically
# for both), so reading it here too keeps this client correct under a
# sub-path deployment instead of only working when BASE is empty/root.
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
_BASE_PREFIX = f"/{_BASE}" if _BASE else ""
_TTL = float(os.environ.get("THERMOGRAPH_SSR_CACHE_TTL", "600") or 600) # 10 min default
_cache: dict[str, tuple[float, object]] = {}
@ -46,7 +55,7 @@ def _get(path: str, *, origin: str | None = None) -> object:
if origin:
proto, _, host = origin.partition("://")
headers = {"host": host, "x-forwarded-proto": proto}
resp = httpx.get(f"{API_BASE}{path}", timeout=10.0, headers=headers)
resp = httpx.get(f"{API_BASE}{_BASE_PREFIX}{path}", timeout=10.0, headers=headers)
resp.raise_for_status()
data = resp.json()
_cache[cache_key] = (now, data)

28
app.py
View file

@ -1,26 +1,20 @@
"""SSR frontend service: server-rendered content pages (content.py) plus the
static JS/CSS/PWA asset bundle. All climate data comes from the backend's
content API over HTTP (api_client.py) -- this process holds no data of its
own and does no polars/DB work.
"""
import mimetypes
"""SSR frontend service: server-rendered content pages only (content.py). All
climate data comes from the backend's content API over HTTP (api_client.py) --
this process holds no data of its own and does no polars/DB work.
No static-asset mount here (repo-split Stage 4): every real topology routes
JS/CSS/images to backend's own StaticFiles mount -- prod/beta's Caddy never
sends those paths here, and backend's own dev/bare-metal reverse-proxy fallback
(THERMOGRAPH_FRONTEND_BASE_INTERNAL) only forwards the specific content paths
content.register() claims. A static mount here would just be unreachable dead
code in every deployment.
"""
from fastapi import FastAPI
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles
import content
import paths
# The PWA manifest is served as a static file; register its media type so
# StaticFiles labels it correctly (not in Python's default mimetypes map).
mimetypes.add_type("application/manifest+json", ".webmanifest")
app = FastAPI(title="Thermograph SSR", version="0.1.0")
# Compress every sizeable response, same threshold as the backend.
app.add_middleware(GZipMiddleware, minimum_size=1024)
@app.get("/healthz")
def healthz():
@ -32,5 +26,3 @@ def healthz():
# NOTE: "/" is not here -- the homepage is server-rendered by content.register().
content.register(app)
app.mount(content.BASE or "/", StaticFiles(directory=paths.STATIC_DIR), name="static")

View file

@ -21,7 +21,9 @@ FRONTEND_SSR = os.path.dirname(_HERE)
REPO_ROOT = os.path.dirname(FRONTEND_SSR)
BACKEND = os.path.join(REPO_ROOT, "backend")
sys.path.insert(0, FRONTEND_SSR)
# BACKEND first: content_payloads.py's own dependency chain (data/cities.py,
# data/store.py, ...) does a bare `import paths` that must resolve to
# backend/paths.py while THAT chain loads below.
sys.path.insert(0, BACKEND)
os.environ.setdefault("THERMOGRAPH_API_BASE_INTERNAL", "http://backend.invalid")
@ -50,9 +52,35 @@ from api import content_payloads # noqa: E402
from api import sitemap as sitemap_mod # noqa: E402
from data import cities # noqa: E402
# frontend_ssr/ has its OWN paths.py and app.py -- genuinely different modules
# that happen to share backend's bare top-level names. data/cities.py etc.
# above already bound their own `paths` name to backend's module object
# (permanent, unaffected by anything below); swap sys.modules["paths"] to
# frontend_ssr's version just for the span of importing content.py (which
# cascades into content_loader.py, the other consumer), then restore it so any
# LATER lazy backend import (api.homepage's own `import paths`, first
# triggered whenever a test actually calls content_payloads.home_payload())
# still sees backend's version, not frontend's.
_backend_paths_module = sys.modules.get("paths")
_frontend_paths_spec = importlib.util.spec_from_file_location("paths", os.path.join(FRONTEND_SSR, "paths.py"))
_frontend_paths_module = importlib.util.module_from_spec(_frontend_paths_spec)
sys.modules["paths"] = _frontend_paths_module
_frontend_paths_spec.loader.exec_module(_frontend_paths_module)
# Reprioritize sys.path so "app" -- imported lazily, per-test, by the `client`
# fixture below -- resolves to frontend_ssr/app.py rather than backend's
# app.py (the `app:app` launch shim). Nothing triggers a bare `import app`
# before that point, so ordering (not a swap) is enough for this one name.
sys.path.insert(0, FRONTEND_SSR)
import api_client # noqa: E402
import content # noqa: E402
if _backend_paths_module is not None:
sys.modules["paths"] = _backend_paths_module
else:
del sys.modules["paths"]
B = content.BASE
SLUG = "london-england-gb" # present in cities.json; GB -> renders in Celsius
FAKE_INDEXNOW_KEY = "test0000indexnow0000key000000000"

View file

@ -405,3 +405,14 @@ def test_brand_lockup_links_home(client, path):
assert href in ("./", f"{B}/"), f"{path}: brand links to {href!r}, not home"
assert '<span class="logo">' in head.split("<a href=", 1)[1], \
f"{path}: the mark sits outside the home link"
def test_form_is_hidden_for_now(client):
"""The footer digest signup is temporarily hidden (commented out in the
base template). The /digest endpoint (backend-owned) stays live; only the
UI is pulled. Restore the form and flip this back to asserting presence.
Ported from backend/tests/notifications/test_digest.py (repo-split Stage 4)
-- every page it checks is frontend-owned now."""
for path in ["/", "/about", "/climate", "/privacy", "/glossary"]:
html = client.get(f"{B}{path}").text
assert "data-digest" not in html, path

View file

@ -0,0 +1,141 @@
"""Tests for content_loader.py: schema validation (fail loud on a malformed
content/ file) and that the real glossary.yaml/pages.yaml load correctly.
Ported from backend/tests/web/test_content_loader.py (repo-split Stage 4) --
content_loader.py itself moved here in Stage 3."""
import pytest
import content_loader
def _write(tmp_path, name, text):
path = tmp_path / name
path.write_text(text, encoding="utf-8")
return str(path)
# --- load_glossary: schema validation --------------------------------------
def test_load_glossary_rejects_non_mapping_top_level(tmp_path):
path = _write(tmp_path, "g.yaml", "- just\n- a\n- list\n")
with pytest.raises(ValueError, match="top-level mapping"):
content_loader.load_glossary(path)
def test_load_glossary_rejects_missing_terms_key(tmp_path):
path = _write(tmp_path, "g.yaml", "not_terms: []\n")
with pytest.raises(ValueError, match="non-empty list"):
content_loader.load_glossary(path)
def test_load_glossary_rejects_empty_terms(tmp_path):
path = _write(tmp_path, "g.yaml", "terms: []\n")
with pytest.raises(ValueError, match="non-empty list"):
content_loader.load_glossary(path)
def test_load_glossary_rejects_missing_required_field(tmp_path):
path = _write(tmp_path, "g.yaml", """
terms:
- slug: foo
term: Foo
short: A short definition.
""")
with pytest.raises(ValueError, match="foo.*body"):
content_loader.load_glossary(path)
def test_load_glossary_rejects_blank_required_field(tmp_path):
path = _write(tmp_path, "g.yaml", """
terms:
- slug: foo
term: Foo
short: ""
body: Full body.
""")
with pytest.raises(ValueError, match="foo.*short"):
content_loader.load_glossary(path)
def test_load_glossary_rejects_duplicate_slug(tmp_path):
path = _write(tmp_path, "g.yaml", """
terms:
- slug: foo
term: Foo
short: s1
body: b1
- slug: foo
term: Foo Again
short: s2
body: b2
""")
with pytest.raises(ValueError, match="duplicate slug"):
content_loader.load_glossary(path)
def test_load_glossary_accepts_well_formed_content(tmp_path):
path = _write(tmp_path, "g.yaml", """
terms:
- slug: foo
term: Foo
short: A short definition.
body: A full body with <b>markup</b>.
- slug: bar
term: Bar
short: Another short definition.
body: Another full body.
""")
glossary = content_loader.load_glossary(path)
assert list(glossary) == ["foo", "bar"] # file order preserved
assert glossary["foo"] == {
"term": "Foo", "short": "A short definition.", "body": "A full body with <b>markup</b>.",
}
# --- load_pages: schema validation ------------------------------------------
def test_load_pages_rejects_missing_pages_key(tmp_path):
path = _write(tmp_path, "p.yaml", "not_pages: {}\n")
with pytest.raises(ValueError, match="non-empty mapping"):
content_loader.load_pages(path)
def test_load_pages_rejects_missing_required_field(tmp_path):
path = _write(tmp_path, "p.yaml", """
pages:
about:
title: About
""")
with pytest.raises(ValueError, match="about.*description"):
content_loader.load_pages(path)
def test_load_pages_accepts_well_formed_content(tmp_path):
path = _write(tmp_path, "p.yaml", """
pages:
about:
title: About Us
description: A description.
""")
pages = content_loader.load_pages(path)
assert pages == {"about": {"title": "About Us", "description": "A description."}}
# --- the real content/ files ------------------------------------------------
def test_real_glossary_loads_and_matches_the_rendered_site():
"""The module-level GLOSSARY content.py loads at import IS content_loader's
output this just re-asserts the real file is well-formed and non-trivial,
since a broken content/glossary.yaml would already have failed every other
test at collection time (content.py loads it at import)."""
glossary = content_loader.load_glossary()
assert len(glossary) >= 8
assert "percentile" in glossary
for slug, entry in glossary.items():
assert entry["term"] and entry["short"] and entry["body"], slug
def test_real_pages_loads_the_four_static_pages():
pages = content_loader.load_pages()
assert set(pages) == {"about", "privacy", "hub", "glossary_index"}
for key, entry in pages.items():
assert entry["title"] and entry["description"], key

176
tests/test_homepage.py Normal file
View file

@ -0,0 +1,176 @@
"""Tests for the rendered homepage -- ported from backend/tests/web/test_homepage.py
(repo-split Stage 4): content.py's Jinja rendering lives in this service now, not
backend's. The cache-only "unusual right now" precompute (api/homepage.py) itself
stays tested in backend/tests/web/test_homepage.py -- nothing about it moved.
"""
import datetime
import pathlib
import time
import pytest
from api import homepage
from data import cities
from api import content_payloads
from conftest import B
def _pick(name, pct, cls, grade, tail, slug=None):
return {
"slug": slug or (name.lower().replace(" ", "-") + "-xx"),
"name": name, "display": f"{name}, Somewhere",
"lat": 1.0, "lon": 2.0, "cell_id": "1_2",
"date": datetime.date.today().isoformat(),
"metric": "tmax", "metric_label": "High temp", "window_label": "mid-July",
"value": 99.0, "normal": 88.0, "percentile": pct,
"pct_label": homepage._pct_label(pct),
"grade": grade, "grade_label": homepage._grade_label(grade, tail), "cls": cls,
"departure": abs(pct - 50), "tail": tail,
}
@pytest.fixture
def no_feed(monkeypatch):
"""The homepage with no precomputed feed at all."""
monkeypatch.setattr(homepage, "load", lambda: None)
return None
def test_homepage_is_complete_without_js(client, no_feed):
"""A crawler (or a reader with JS off) gets the whole page as real HTML."""
r = client.get(f"{B}/")
assert r.status_code == 200
html = r.text
assert "How unusual is your weather?" in html
assert "How it works" in html
assert "Climate context for 1,000 cities" in html
assert "Free. No ads. No tracking." in html
# The interactive tool's DOM contract survives the move to Jinja.
for dom_id in ('id="find-btn"', 'id="date-input"', 'id="panel"',
'id="placeholder"', 'id="results"'):
assert dom_id in html
# Exactly one h1: the hero headline. The brand degrades to a <p>.
assert html.count("<h1") == 1
assert 'p class="site-name"' in html
def test_hero_locate_has_a_status_slot(client, no_feed):
"""The locate button needs somewhere to report a declined prompt or a
timeout. Without it the control fails silently, which is how it shipped
broken: navigator.geolocation exists on plain http:// but always errors."""
html = client.get(f"{B}/").text
assert 'id="hero-locate"' in html
assert 'id="hero-locate-msg"' in html
assert 'aria-live="polite"' in html
def test_homepage_brand_keeps_the_lockup_styling(client, no_feed):
"""The homepage renders the brand as <p class="site-name"> so the hero owns
the page's only h1. The lockup styling is written against `.brand h1`, so
the class MUST be matched there too otherwise the brand silently falls
back to block layout, which baseline-aligns the mark instead of centring it
and drops the monospace wordmark. Nothing else catches this: the page still
renders, and every other test still passes, while the header looks wrong.
"""
html = client.get(f"{B}/").text
assert 'class="site-name"' in html
assert "<h1 class=\"site-name\"" not in html # the hero owns the h1
css = (pathlib.Path(__file__).parents[2] / "frontend" / "style.css").read_text()
lockup = css.split(".brand h1", 1)[1].split("}", 1)[0]
assert ".brand .site-name" in lockup, (
"the .brand h1 lockup rule must also match .brand .site-name"
)
def test_city_chips_all_resolve(client, no_feed):
"""Every homepage chip must be a real, routable city — a stale slug would
render an empty chip list and leak a 404 link."""
resolved = [{"slug": s, "name": cities.get(s)["name"]}
for s in content_payloads.HOME_CITY_SLUGS if cities.get(s)]
assert len(resolved) == len(content_payloads.HOME_CITY_SLUGS)
html = client.get(f"{B}/").text
for city in resolved:
assert f"/climate/{city['slug']}" in html
assert cities.get(city["slug"]) is not None
def test_records_strip_shows_both_tails(client, monkeypatch):
"""Two-sided honesty: a qualifying cold-tail city appears alongside the heat."""
feed = {
"generated_at": time.time(), "date": datetime.date.today().isoformat(),
"considered": 800,
"picks": {"extreme": _pick("Phoenix", 99.4, "rec-hot", "Near Record", "warm")},
"ranked": [_pick("Phoenix", 99.4, "rec-hot", "Near Record", "warm"),
_pick("Ushuaia", 2.1, "very-cold", "Very Low", "cold")],
}
monkeypatch.setattr(homepage, "load", lambda: feed)
html = client.get(f"{B}/").text
assert "Unusual right now" in html
assert "Phoenix" in html and "Ushuaia" in html
# Band colour comes from the shared tokens, and the band word is always
# present too — colour is never the only signal.
assert "var(--rec-hot)" in html and "var(--very-cold)" in html
assert "Near Record" in html and "Very Low" in html
def test_strip_cards_carry_value_metric_and_normal(client, monkeypatch):
feed = {
"generated_at": time.time(), "date": datetime.date.today().isoformat(),
"considered": 800, "picks": {},
"ranked": [_pick("Tehran", 99.6, "rec-hot", "Near Record", "warm"),
_pick("Ushuaia", 0.4, "rec-cold", "Near Record", "cold")],
}
monkeypatch.setattr(homepage, "load", lambda: feed)
html = client.get(f"{B}/").text
# Both tails must be tellable apart in WORDS, not just by colour.
assert "Near Record hot" in html and "Near Record cold" in html
assert "normal" in html and "High temp" in html
assert "100th" not in html and "0th" not in html
# Temperatures render as convertible spans, and the converter is loaded, or
# the strip would stay in °F while the °F/°C toggle says °C.
assert 'class="temp" data-temp-f' in html
assert "climate.js" in html
def test_hero_uses_most_unusual_default(client, monkeypatch):
feed = {
"generated_at": time.time(), "date": datetime.date.today().isoformat(),
"considered": 800,
"picks": {"extreme": _pick("Phoenix", 96.0, "very-hot", "Very High", "warm")},
"ranked": [],
}
monkeypatch.setattr(homepage, "load", lambda: feed)
html = client.get(f"{B}/").text
assert "Today in Phoenix, Somewhere" in html
assert "96th percentile" in html
assert "the most unusual weather we're tracking" in html
def test_homepage_renders_without_feed(client, no_feed):
"""A cold checkout with no precomputed feed still serves a complete page."""
html = client.get(f"{B}/").text
assert "How unusual is your weather?" in html
assert "Unusual right now" not in html # strip is omitted, not broken
assert 'id="hero-grade"' in html # the frame is still there
def test_homepage_etag_revalidates(client, no_feed):
r = client.get(f"{B}/")
etag = r.headers["etag"]
again = client.get(f"{B}/", headers={"If-None-Match": etag})
assert again.status_code == 304
def test_privacy_page(client):
r = client.get(f"{B}/privacy")
assert r.status_code == 200
assert "no analytics library" in r.text.lower() or "no analytics" in r.text.lower()
assert "Use my location" in r.text
def test_privacy_is_linked_but_not_in_sitemap(client):
assert f"{B}/privacy" in client.get(f"{B}/").text
# It's a policy page, not a crawl target we want ranked.
assert "/privacy" not in client.get(f"{B}/sitemap.xml").text