Repo-split Stage 4: cut prod traffic to backend + frontend, dual-service (#14)
This commit is contained in:
parent
1738f61c8f
commit
11872d7e64
6 changed files with 377 additions and 20 deletions
|
|
@ -20,6 +20,15 @@ API_BASE = os.environ.get("THERMOGRAPH_API_BASE_INTERNAL")
|
||||||
if not API_BASE:
|
if not API_BASE:
|
||||||
raise RuntimeError("THERMOGRAPH_API_BASE_INTERNAL must be set (e.g. http://backend:8137)")
|
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
|
_TTL = float(os.environ.get("THERMOGRAPH_SSR_CACHE_TTL", "600") or 600) # 10 min default
|
||||||
_cache: dict[str, tuple[float, object]] = {}
|
_cache: dict[str, tuple[float, object]] = {}
|
||||||
|
|
||||||
|
|
@ -46,7 +55,7 @@ def _get(path: str, *, origin: str | None = None) -> object:
|
||||||
if origin:
|
if origin:
|
||||||
proto, _, host = origin.partition("://")
|
proto, _, host = origin.partition("://")
|
||||||
headers = {"host": host, "x-forwarded-proto": proto}
|
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()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
_cache[cache_key] = (now, data)
|
_cache[cache_key] = (now, data)
|
||||||
|
|
|
||||||
28
app.py
28
app.py
|
|
@ -1,26 +1,20 @@
|
||||||
"""SSR frontend service: server-rendered content pages (content.py) plus the
|
"""SSR frontend service: server-rendered content pages only (content.py). All
|
||||||
static JS/CSS/PWA asset bundle. All climate data comes from the backend's
|
climate data comes from the backend's content API over HTTP (api_client.py) --
|
||||||
content API over HTTP (api_client.py) -- this process holds no data of its
|
this process holds no data of its own and does no polars/DB work.
|
||||||
own and does no polars/DB work.
|
|
||||||
"""
|
|
||||||
import mimetypes
|
|
||||||
|
|
||||||
|
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 import FastAPI
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
import content
|
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")
|
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")
|
@app.get("/healthz")
|
||||||
def healthz():
|
def healthz():
|
||||||
|
|
@ -32,5 +26,3 @@ def healthz():
|
||||||
|
|
||||||
# NOTE: "/" is not here -- the homepage is server-rendered by content.register().
|
# NOTE: "/" is not here -- the homepage is server-rendered by content.register().
|
||||||
content.register(app)
|
content.register(app)
|
||||||
|
|
||||||
app.mount(content.BASE or "/", StaticFiles(directory=paths.STATIC_DIR), name="static")
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ FRONTEND_SSR = os.path.dirname(_HERE)
|
||||||
REPO_ROOT = os.path.dirname(FRONTEND_SSR)
|
REPO_ROOT = os.path.dirname(FRONTEND_SSR)
|
||||||
BACKEND = os.path.join(REPO_ROOT, "backend")
|
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)
|
sys.path.insert(0, BACKEND)
|
||||||
|
|
||||||
os.environ.setdefault("THERMOGRAPH_API_BASE_INTERNAL", "http://backend.invalid")
|
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 api import sitemap as sitemap_mod # noqa: E402
|
||||||
from data import cities # 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 api_client # noqa: E402
|
||||||
import content # 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
|
B = content.BASE
|
||||||
SLUG = "london-england-gb" # present in cities.json; GB -> renders in Celsius
|
SLUG = "london-england-gb" # present in cities.json; GB -> renders in Celsius
|
||||||
FAKE_INDEXNOW_KEY = "test0000indexnow0000key000000000"
|
FAKE_INDEXNOW_KEY = "test0000indexnow0000key000000000"
|
||||||
|
|
|
||||||
|
|
@ -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 href in ("./", f"{B}/"), f"{path}: brand links to {href!r}, not home"
|
||||||
assert '<span class="logo">' in head.split("<a href=", 1)[1], \
|
assert '<span class="logo">' in head.split("<a href=", 1)[1], \
|
||||||
f"{path}: the mark sits outside the home link"
|
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
|
||||||
|
|
|
||||||
141
tests/test_content_loader.py
Normal file
141
tests/test_content_loader.py
Normal 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
176
tests/test_homepage.py
Normal 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
|
||||||
Loading…
Reference in a new issue