Merge pull request 'Promote dev to main (reconciled bot + hardening merge, test harness, in-image CI)' (#7) from dev into main

This commit is contained in:
emi 2026-07-23 05:35:11 +00:00
commit 510d94df96
22 changed files with 77185 additions and 741 deletions

View file

@ -39,3 +39,18 @@ jobs:
- name: Build
run: docker build -t thermograph-frontend:ci .
# Run the hermetic unit tier INSIDE the image we just built (it ships
# tests/ + all runtime deps via COPY . /app/): fixture-backed rendering
# tests, no backend, no network. The integration tier
# (tests/integration/, needs a live backend container) stays a local
# `make`/scripts concern -- see scripts/backend-for-tests.sh.
# requirements-dev.txt only layers pytest on top, so the install is tiny.
# -u 0: the image's default user can't write to site-packages.
# unset THERMOGRAPH_BASE: the image bakes the prod root-mount; the tests
# expect the app default (/thermograph), same rationale as the backend.
- name: Run unit tests in the built image
run: |
docker run --rm -u 0 thermograph-frontend:ci \
sh -c "unset THERMOGRAPH_BASE; pip install -q --no-cache-dir pytest==8.4.1 && python -m pytest tests/unit -q"

1
.gitignore vendored
View file

@ -3,3 +3,4 @@ __pycache__/
*.pyc
.DS_Store
.env
.venv-test/

26
Makefile Normal file
View file

@ -0,0 +1,26 @@
# thermograph-frontend — local dev/test targets.
.PHONY: help test test-unit test-integration backend-up backend-down capture-fixtures clean-venv
help: ## List targets
@grep -hE '^[a-z][a-zA-Z0-9_-]*:.*## ' $(MAKEFILE_LIST) | awk -F':.*## ' '{printf " %-18s %s\n", $$1, $$2}'
test: ## Run the whole suite (unit hermetic + integration against a real backend)
./scripts/test.sh $(ARGS)
test-unit: ## Hermetic unit tier — SSR rendering fed committed fixtures, no Docker
./scripts/test.sh tests/unit $(ARGS)
test-integration: ## Integration tier — pulls + runs the real backend image, tests the live contract
./scripts/test.sh tests/integration $(ARGS)
backend-up: ## Pull + run the backend container for local dev (prints its base URL)
./scripts/backend-for-tests.sh up
backend-down: ## Stop the local backend container
./scripts/backend-for-tests.sh down
capture-fixtures: ## Refresh tests/fixtures/*.json from a live backend
./scripts/capture-fixtures.sh
clean-venv: ## Remove the test venv
rm -rf .venv-test

40
docker-compose.test.yml Normal file
View file

@ -0,0 +1,40 @@
# Test harness: pull the published BACKEND image and run it (+ a throwaway TimescaleDB)
# so the frontend's integration tests exercise the real HTTP contract instead of
# importing backend source. Used by scripts/backend-for-tests.sh; not a deploy file.
# The frontend itself is NOT a service here — the tests run the SSR app in-process
# (TestClient) with api_client pointed at this backend. DB data is tmpfs (ephemeral).
services:
db:
image: timescale/timescaledb:${TIMESCALEDB_TAG:-latest-pg18}
environment:
POSTGRES_USER: thermograph
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-testing}
POSTGRES_DB: thermograph
tmpfs:
# pg18 places PGDATA in a version subdir under the mount — mount at /var/lib/postgresql.
- /var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U thermograph -d thermograph"]
interval: 3s
timeout: 5s
retries: 20
backend:
# The published backend image is pulled (no local build). Default a published tag;
# override THERMOGRAPH_BACKEND_TEST_TAG to pin a specific backend build (e.g. a sha).
image: ${REGISTRY_HOST:-git.thermograph.org}/${BACKEND_IMAGE_PATH:-emi/thermograph-backend/app}:${THERMOGRAPH_BACKEND_TEST_TAG:-v0.0.2-split-ci}
depends_on:
db:
condition: service_healthy
environment:
THERMOGRAPH_DATABASE_URL: postgresql+asyncpg://thermograph:${POSTGRES_PASSWORD:-testing}@db:5432/thermograph
# Required at import; the frontend tests never drive the backend's reverse proxy,
# so an unreachable placeholder is fine.
THERMOGRAPH_FRONTEND_BASE_INTERNAL: http://127.0.0.1:1
THERMOGRAPH_BASE: "/"
THERMOGRAPH_ENABLE_NOTIFIER: "0"
PORT: "8137"
WORKERS: "1"
ports:
# Host port defaults to 18137 to avoid a dev server on 8137.
- "127.0.0.1:${BACKEND_TEST_HOST_PORT:-18137}:8137"

2
requirements-dev.txt Normal file
View file

@ -0,0 +1,2 @@
-r requirements.txt
pytest==8.4.1

44
scripts/backend-for-tests.sh Executable file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Pull the published backend image and run it (+ a throwaway DB) for the frontend's
# integration tests, or for local dev. The frontend's api_client then makes real HTTP
# to it instead of importing backend source.
#
# scripts/backend-for-tests.sh up # pull + start, wait until healthy, print base URL
# scripts/backend-for-tests.sh down # stop + remove (incl. the throwaway db volume)
# scripts/backend-for-tests.sh url # print the base URL (no lifecycle change)
#
# Which backend build: THERMOGRAPH_BACKEND_TEST_TAG (default a published tag; set a
# sha-<12hex> to pin one). Host port: BACKEND_TEST_HOST_PORT (default 18137).
set -euo pipefail
cd "$(dirname "$0")/.."
export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-testing}"
export BACKEND_TEST_HOST_PORT="${BACKEND_TEST_HOST_PORT:-18137}"
BASE_URL="http://127.0.0.1:${BACKEND_TEST_HOST_PORT}"
COMPOSE=(docker compose -f docker-compose.test.yml)
case "${1:-up}" in
up)
echo "==> pulling backend image (${THERMOGRAPH_BACKEND_TEST_TAG:-v0.0.2-split-ci})" >&2
"${COMPOSE[@]}" pull backend >&2
echo "==> starting backend + db" >&2
"${COMPOSE[@]}" up -d >&2
echo "==> waiting for backend /healthz (up to 90s)" >&2
for _ in $(seq 1 45); do
curl -fsS -o /dev/null "$BASE_URL/healthz" 2>/dev/null && { echo "$BASE_URL"; exit 0; }
sleep 2
done
echo "!! backend never became healthy" >&2
"${COMPOSE[@]}" logs --tail=40 backend >&2
exit 1
;;
down)
"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
echo "==> backend stopped" >&2
;;
url)
echo "$BASE_URL"
;;
*)
echo "usage: $0 {up|down|url}" >&2; exit 2 ;;
esac

31
scripts/capture-fixtures.sh Executable file
View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Refresh the committed unit-test fixtures (tests/fixtures/*.json) by capturing the
# real payloads a live backend returns for a known city. Run when the backend's content
# contract changes. Brings the backend harness up, saves each api_client endpoint's
# response, and tears it down. Requires jq for pretty output.
#
# scripts/capture-fixtures.sh
set -euo pipefail
cd "$(dirname "$0")/.."
SLUG="${FIXTURE_CITY:-london-england-gb}" # GB city -> Celsius rendering
MONTH="${FIXTURE_MONTH:-july}"
OUT=tests/fixtures
mkdir -p "$OUT"
BASE=$(scripts/backend-for-tests.sh up)
trap 'scripts/backend-for-tests.sh down >/dev/null 2>&1 || true' EXIT
fetch() { # <endpoint-path> <fixture-name>
echo "==> $2.json <- $1"
curl -fsS "$BASE/$1" --max-time 40 | { command -v jq >/dev/null && jq . || cat; } > "$OUT/$2.json"
}
fetch "api/v2/content/home" home
fetch "api/v2/content/sitemap" sitemap
fetch "api/v2/content/hub" hub
fetch "api/v2/content/city/$SLUG" city
fetch "api/v2/content/city/$SLUG/month/$MONTH" month
fetch "api/v2/content/city/$SLUG/records" records
echo "==> captured $(ls "$OUT"/*.json | wc -l) fixtures for $SLUG into $OUT/"

26
scripts/test.sh Executable file
View file

@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Build a Python 3.12 dev venv and run pytest. Pass pytest args through:
# ./scripts/test.sh tests/unit # hermetic unit tier (no Docker)
# ./scripts/test.sh tests/integration # integration tier (pulls + runs the backend)
# ./scripts/test.sh # everything
# 3.12 explicitly because a common box python (pyenv 3.10) lacks _sqlite3 and the pinned
# deps target 3.12.
set -euo pipefail
cd "$(dirname "$0")/.."
VENV="${VENV:-.venv-test}"
if [ ! -x "$VENV/bin/pytest" ]; then
echo "==> Building dev venv ($VENV) on Python 3.12"
if command -v uv >/dev/null 2>&1; then
uv venv --python 3.12 "$VENV"
VIRTUAL_ENV="$VENV" uv pip install -q -r requirements-dev.txt
else
PY="$(command -v python3.12 || echo "$HOME/.local/bin/python3.12")"
"$PY" -m venv "$VENV"
"$VENV/bin/pip" install -q --upgrade pip
"$VENV/bin/pip" install -q -r requirements-dev.txt
fi
fi
echo "==> pytest ($("$VENV/bin/python" --version))"
exec "$VENV/bin/python" -m pytest "$@"

View file

@ -1,108 +1,45 @@
"""Shared test setup for the SSR service.
"""Shared SSR test setup — self-contained, NO backend checkout.
KNOWN GAP (repo-split Stage 7b): this suite does NOT run standalone in this
repo. It still assumes the layout from before the split -- a sibling
`backend/` checkout with its own `tests/conftest.py` (`make_history`/
`make_recent`) and `api.content_payloads` module, which this repo no longer
has. Extracted verbatim as reference material for the real fix (a genuine
cross-repo contract-test job, or a self-contained set of fixtures owned
here), not yet built -- see this repo's `.forgejo/workflows/build.yml` for
the same gap on the boot-check side. Do not assume `pytest tests` passes
here until that follow-up lands.
Original docstring, still accurate for what the suite is trying to prove:
this package used to live in the same repo as the backend, so a `client`
fixture could fake api_client by calling the REAL backend payload builders
(api.content_payloads) against the same synthetic history/recent fixtures
backend/tests/conftest.py uses, instead of hand-maintaining a parallel set of
literal JSON fixtures -- so a shape drift between content_payloads.py and how
content.py consumes it failed here too, not just in the backend's own suite.
Replaces the old sibling-`backend/` coupling. Two tiers:
* unit (tests/unit/) hermetic. The `client` fixture serves the SSR app with
`api_client` monkeypatched to return committed fixtures (tests/fixtures/, refreshed
by scripts/capture-fixtures.sh). No backend, no Docker.
* integration (tests/integration/) the `live_client` fixture serves the SSR app with
`api_client` pointed at a REAL backend container (scripts/backend-for-tests.sh pulls
+ runs it). Skips automatically when Docker/the image isn't available.
"""
import importlib.util
import json
import os
import subprocess
import sys
import httpx
import pytest
_HERE = os.path.dirname(os.path.abspath(__file__))
FRONTEND_SSR = os.path.dirname(_HERE)
REPO_ROOT = os.path.dirname(FRONTEND_SSR)
BACKEND = os.path.join(REPO_ROOT, "backend")
# 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)
REPO = os.path.dirname(_HERE)
if REPO not in sys.path:
sys.path.insert(0, REPO) # SSR modules (content.py, app.py, api_client.py) live at repo root
# api_client raises at import if this is unset. Unit tests monkeypatch api_client so it is
# never used; the integration fixture overrides it to the live backend.
os.environ.setdefault("THERMOGRAPH_API_BASE_INTERNAL", "http://backend.invalid")
os.environ.setdefault("THERMOGRAPH_BASE", "/thermograph")
import content # noqa: E402 — imports api_client under the env set above
def _load_module(name: str, path: str):
"""importlib rather than a bare `import conftest` -- pytest registers
THIS file's own module under the plain name "conftest" too, so a bare
import here could self-collide instead of resolving to backend's file."""
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
# Reuse the exact synthetic weather fixtures backend/tests/conftest.py
# defines (its module-level code also sandboxes data/store/audit paths into a
# tmp dir), so a page rendered here and the same page rendered by the
# backend's own test_content.py agree on every number.
_backend_conftest = _load_module("_backend_test_conftest", os.path.join(BACKEND, "tests", "conftest.py"))
make_history = _backend_conftest.make_history
make_recent = _backend_conftest.make_recent
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
B = content.BASE # "/thermograph"
SLUG = "london-england-gb" # captured into tests/fixtures/ (GB -> Celsius)
MONTH = "july"
FAKE_INDEXNOW_KEY = "test0000indexnow0000key000000000"
_FIXTURE_NAMES = ("home", "sitemap", "hub", "city", "month", "records")
_HARNESS = os.path.join(REPO, "scripts", "backend-for-tests.sh")
@pytest.fixture(scope="session")
def history():
return make_history()
@pytest.fixture(scope="session")
def recent(history):
return make_recent(history)
def load_fixture(name: str):
with open(os.path.join(_HERE, "fixtures", f"{name}.json"), encoding="utf-8") as fh:
return json.load(fh)
def _not_found(detail: str) -> httpx.HTTPStatusError:
@ -111,47 +48,75 @@ def _not_found(detail: str) -> httpx.HTTPStatusError:
return httpx.HTTPStatusError(detail, request=req, response=resp)
# --- unit tier ---------------------------------------------------------------
@pytest.fixture
def client(monkeypatch, history, recent):
"""A TestClient over the SSR app, with api_client's HTTP calls replaced by
direct calls into the real backend payload builders -- see module
docstring. Origin defaults to the TestClient's own base URL, matching what
a real request through content.py's _origin(request) would forward."""
def client(monkeypatch):
"""TestClient over the SSR app with api_client replaced by the committed fixtures.
Exercises the rendering layer (content.py + format.py) with zero backend."""
from fastapi.testclient import TestClient
import api_client
import app as ssr_app
fx = {n: load_fixture(n) for n in _FIXTURE_NAMES}
monkeypatch.setattr(api_client, "home", lambda: fx["home"])
monkeypatch.setattr(api_client, "sitemap", lambda: fx["sitemap"])
monkeypatch.setattr(api_client, "hub", lambda: fx["hub"])
monkeypatch.setattr(api_client, "indexnow_key", lambda: FAKE_INDEXNOW_KEY)
def _city(slug, *, origin=None):
c = cities.get(slug)
if c is None:
if slug != SLUG:
raise _not_found("Unknown city.")
return content_payloads.city_payload(origin or "http://testserver", B, c, history, recent)
return fx["city"]
def _city_month(slug, month):
c = cities.get(slug)
if c is None:
raise _not_found("Unknown city.")
if month not in content_payloads.MONTH_INDEX:
def _month(slug, month):
if slug != SLUG or month != MONTH:
raise _not_found("Unknown month.")
return content_payloads.month_payload(B, c, history, content_payloads.MONTH_INDEX[month])
return fx["month"]
def _city_records(slug, *, origin=None):
c = cities.get(slug)
if c is None:
def _records(slug, *, origin=None):
if slug != SLUG:
raise _not_found("Unknown city.")
return content_payloads.records_payload(origin or "http://testserver", B, c, history)
def _sitemap():
return [{"path": p, "changefreq": cf, "priority": pr} for p, cf, pr in sitemap_mod.sitemap_entries()]
return fx["records"]
monkeypatch.setattr(api_client, "city", _city)
monkeypatch.setattr(api_client, "city_month", _city_month)
monkeypatch.setattr(api_client, "city_records", _city_records)
monkeypatch.setattr(api_client, "hub", content_payloads.hub_payload)
monkeypatch.setattr(api_client, "home", content_payloads.home_payload)
monkeypatch.setattr(api_client, "sitemap", _sitemap)
monkeypatch.setattr(api_client, "indexnow_key", lambda: FAKE_INDEXNOW_KEY)
# api_client's TTL cache is keyed by path/origin, not by test -- stale
# entries from an earlier test would otherwise leak real-looking but
# wrong data into this one.
api_client._cache.clear()
monkeypatch.setattr(api_client, "city_month", _month)
monkeypatch.setattr(api_client, "city_records", _records)
return TestClient(ssr_app.app)
import app as appmod
# --- integration tier --------------------------------------------------------
def _docker_ok() -> bool:
try:
return subprocess.run(["docker", "info"], capture_output=True, timeout=15).returncode == 0
except Exception:
return False
@pytest.fixture(scope="session")
def backend_service():
"""Pull + run a real backend container; yield its base URL. Skips if Docker isn't
available or the image can't be pulled/started, so the unit tier still runs anywhere."""
if not _docker_ok():
pytest.skip("Docker not available — skipping integration tier")
try:
url = subprocess.run([_HARNESS, "up"], capture_output=True, text=True, timeout=300, check=True).stdout.strip()
except subprocess.CalledProcessError as e:
pytest.skip(f"backend harness failed to start (image unavailable?): {e.stderr[-300:]}")
try:
yield url
finally:
subprocess.run([_HARNESS, "down"], capture_output=True, timeout=60)
@pytest.fixture
def live_client(backend_service, monkeypatch):
"""TestClient over the SSR app with api_client making REAL HTTP to the live backend
(which runs THERMOGRAPH_BASE=/, so the API is at /api/v{N}, no prefix)."""
from fastapi.testclient import TestClient
return TestClient(appmod.app)
import api_client
import app as ssr_app
monkeypatch.setattr(api_client, "API_BASE", backend_service)
monkeypatch.setattr(api_client, "_BASE_PREFIX", "")
api_client._cache.clear()
return TestClient(ssr_app.app)

328
tests/fixtures/city.json vendored Normal file
View file

@ -0,0 +1,328 @@
{
"city": {
"slug": "london-england-gb",
"name": "London",
"admin1": "England",
"country": "United Kingdom",
"country_code": "GB",
"lat": 51.50853,
"lon": -0.12574,
"population": 8961989
},
"display": "London, England, United Kingdom",
"title": "London, GB",
"year_range": [
1980,
2026
],
"n_years": 46,
"months": [
{
"name": "January",
"slug": "january",
"high_f": 44.5,
"low_f": 35.3,
"range_lo_f": 27.2,
"range_hi_f": 51.9,
"precip_f": 0.1
},
{
"name": "February",
"slug": "february",
"high_f": 45.6,
"low_f": 35.1,
"range_lo_f": 26.9,
"range_hi_f": 52.8,
"precip_f": 0.1
},
{
"name": "March",
"slug": "march",
"high_f": 50.7,
"low_f": 38.3,
"range_lo_f": 30.9,
"range_hi_f": 57.1,
"precip_f": 0.1
},
{
"name": "April",
"slug": "april",
"high_f": 55.5,
"low_f": 40.1,
"range_lo_f": 33.4,
"range_hi_f": 63.4,
"precip_f": 0.1
},
{
"name": "May",
"slug": "may",
"high_f": 61.5,
"low_f": 46.5,
"range_lo_f": 40.3,
"range_hi_f": 69.9,
"precip_f": 0.1
},
{
"name": "June",
"slug": "june",
"high_f": 67.0,
"low_f": 52.2,
"range_lo_f": 46.5,
"range_hi_f": 75.3,
"precip_f": 0.1
},
{
"name": "July",
"slug": "july",
"high_f": 71.1,
"low_f": 56.2,
"range_lo_f": 51.3,
"range_hi_f": 79.8,
"precip_f": 0.1
},
{
"name": "August",
"slug": "august",
"high_f": 71.0,
"low_f": 56.5,
"range_lo_f": 51.2,
"range_hi_f": 78.9,
"precip_f": 0.1
},
{
"name": "September",
"slug": "september",
"high_f": 65.6,
"low_f": 52.4,
"range_lo_f": 46.7,
"range_hi_f": 72.1,
"precip_f": 0.1
},
{
"name": "October",
"slug": "october",
"high_f": 58.4,
"low_f": 47.2,
"range_lo_f": 39.6,
"range_hi_f": 64.2,
"precip_f": 0.1
},
{
"name": "November",
"slug": "november",
"high_f": 50.5,
"low_f": 41.0,
"range_lo_f": 32.1,
"range_hi_f": 57.3,
"precip_f": 0.1
},
{
"name": "December",
"slug": "december",
"high_f": 45.7,
"low_f": 36.9,
"range_lo_f": 27.5,
"range_hi_f": 53.5,
"precip_f": 0.1
}
],
"warmest_month_slug": "july",
"coldest_month_slug": "february",
"wettest_month_slug": "january",
"all_time_records": {
"tmax": {
"max": 97.5,
"max_date": "2022-07-19",
"min": 20.3,
"min_date": "1987-01-12"
},
"tmin": {
"max": 71.8,
"max_date": "2022-07-19",
"min": 3.3,
"min_date": "1981-12-13"
},
"feels": {
"max": 100.6,
"max_date": "2019-07-25",
"min": -5.3,
"min_date": "1986-02-10"
},
"fmax": {
"max": 100.6,
"max_date": "2019-07-25",
"min": 11.8,
"min_date": "1987-01-12"
},
"fmin": {
"max": 73.2,
"max_date": "2026-06-26",
"min": -5.3,
"min_date": "1986-02-10"
},
"humid": {
"max": 17.4,
"max_date": "2026-06-24",
"min": 1.9,
"min_date": "1987-01-12"
},
"wind": {
"max": 41.5,
"max_date": "1987-10-16",
"min": 2.3,
"min_date": "2024-12-27"
},
"gust": {
"max": 81.7,
"max_date": "2000-10-30",
"min": 5.8,
"min_date": "2024-12-27"
},
"precip": {
"max": 1.23,
"max_date": "2021-06-18",
"min": 0.0,
"min_date": "1980-01-01"
}
},
"today_vs_normal": {
"date": "2026-07-22",
"cards": [
{
"label": "High",
"metric": "tmax",
"value_f": 79.4,
"percentile": 89.1,
"grade": "High",
"cls": "hot"
},
{
"label": "Low",
"metric": "tmin",
"value_f": 62.8,
"percentile": 94.3,
"grade": "Very High",
"cls": "very-hot"
},
{
"label": "Feels-like",
"metric": "feels",
"value_f": 78.0,
"percentile": 82.5,
"grade": "High",
"cls": "hot"
},
{
"label": "Humidity",
"metric": "humid",
"value_f": 10.9,
"percentile": 46.4,
"grade": "Normal",
"cls": "normal"
},
{
"label": "Wind",
"metric": "wind",
"value_f": 9.6,
"percentile": 36.8,
"grade": "Below Normal",
"cls": "cool"
},
{
"label": "Gust",
"metric": "gust",
"value_f": 21.5,
"percentile": 41.3,
"grade": "Normal",
"cls": "normal"
},
{
"label": "Precip",
"metric": "precip",
"value_f": 0.0,
"percentile": null,
"grade": "Dry",
"cls": "dry"
}
]
},
"flavor": {
"extract": "London is the capital and largest city of England and the United Kingdom, with a population of 9.1 million people in 2024. Its wider metropolitan area is the largest in Western Europe, with a population of 15.4 million.",
"title": "London",
"url": "https://en.wikipedia.org/wiki/London"
},
"event": {
"text": "The Great Smog of December 1952 blanketed London in toxic coal fog for days, killing thousands and prompting the UK's landmark Clean Air Act.",
"url": "https://en.wikipedia.org/wiki/Great_Smog_of_London"
},
"default_unit": "C",
"breadcrumb": [
{
"name": "Home",
"href": "/"
},
{
"name": "Climate",
"href": "/climate"
},
{
"name": "United Kingdom",
"href": null
},
{
"name": "London",
"href": null
}
],
"canonical_path": "/climate/london-england-gb",
"page_title": "London, GB climate: daily normals, records & how unusual it is now",
"page_description": "London, GB averages highs of 22°C in July and lows of 2°C in February. Every day graded against 46 years of local history.",
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Dataset",
"name": "London, England, United Kingdom climate normals and records",
"description": "Average temperatures, precipitation and record highs and lows for London, England, United Kingdom, from ~46 years of daily climate history.",
"url": "http://127.0.0.1:18137/climate/london-england-gb",
"temporalCoverage": "1980/2026",
"spatialCoverage": {
"@type": "Place",
"name": "London, England, United Kingdom",
"geo": {
"@type": "GeoCoordinates",
"latitude": 51.50853,
"longitude": -0.12574
}
},
"creator": {
"@type": "Organization",
"name": "Thermograph"
},
"isBasedOn": "https://open-meteo.com/ (ERA5 reanalysis)"
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "http://127.0.0.1:18137/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Climate",
"item": "http://127.0.0.1:18137/climate"
},
{
"@type": "ListItem",
"position": 3,
"name": "London"
}
]
}
]
}
}

55
tests/fixtures/home.json vendored Normal file
View file

@ -0,0 +1,55 @@
{
"unusual": null,
"stale": false,
"ranked": [],
"cities": [
{
"slug": "new-york-city-new-york-us",
"name": "New York City"
},
{
"slug": "london-england-gb",
"name": "London"
},
{
"slug": "tokyo-jp",
"name": "Tokyo"
},
{
"slug": "sydney-new-south-wales-au",
"name": "Sydney"
},
{
"slug": "sao-paulo-br",
"name": "São Paulo"
},
{
"slug": "lagos-ng",
"name": "Lagos"
},
{
"slug": "mumbai-maharashtra-in",
"name": "Mumbai"
},
{
"slug": "mexico-city-mx",
"name": "Mexico City"
},
{
"slug": "cairo-eg",
"name": "Cairo"
},
{
"slug": "toronto-ontario-ca",
"name": "Toronto"
},
{
"slug": "berlin-state-of-berlin-de",
"name": "Berlin"
},
{
"slug": "seattle-washington-us",
"name": "Seattle"
}
]
}

5626
tests/fixtures/hub.json vendored Normal file

File diff suppressed because it is too large Load diff

114
tests/fixtures/month.json vendored Normal file
View file

@ -0,0 +1,114 @@
{
"month_name": "July",
"month_slug": "july",
"year_range": [
1980,
2026
],
"n_years": 46,
"avg_high_f": 71.1,
"avg_low_f": 56.2,
"typical_high_range_f": [
64.3,
79.8
],
"typical_low_range_f": [
51.3,
61.1
],
"avg_precip_f": 0.1,
"records": [
{
"label": "High",
"high_f": 97.5,
"high_date": "2022-07-19",
"high_tag": "Highest",
"low_f": 57.1,
"low_date": "1980-07-01",
"low_tag": "Lowest"
},
{
"label": "Low",
"high_f": 71.8,
"high_date": "2022-07-19",
"high_tag": "Highest",
"low_f": 45.9,
"low_date": "1984-07-03",
"low_tag": "Lowest"
},
{
"label": "Feels-like",
"high_f": 100.6,
"high_date": "2019-07-25",
"high_tag": "Highest",
"low_f": 40.3,
"low_date": "1980-07-01",
"low_tag": "Lowest"
},
{
"label": "Humidity",
"high_f": 16.0,
"high_date": "1994-07-31",
"high_tag": "Highest",
"low_f": 6.7,
"low_date": "2000-07-11",
"low_tag": "Lowest"
},
{
"label": "Wind",
"high_f": 24.7,
"high_date": "1985-07-22",
"high_tag": "Highest",
"low_f": 3.0,
"low_date": "1992-07-08",
"low_tag": "Lowest"
},
{
"label": "Gust",
"high_f": 54.1,
"high_date": "2023-07-15",
"high_tag": "Highest",
"low_f": 7.4,
"low_date": "1992-07-08",
"low_tag": "Lowest"
},
{
"label": "Precip",
"high_f": 4.224,
"high_date": "2012",
"high_tag": "Wettest",
"low_f": 0.607,
"low_date": "2022",
"low_tag": "Driest"
}
],
"prev": {
"name": "June",
"slug": "june"
},
"next": {
"name": "August",
"slug": "august"
},
"breadcrumb": [
{
"name": "Home",
"href": "/"
},
{
"name": "Climate",
"href": "/climate"
},
{
"name": "London",
"href": "/climate/london-england-gb"
},
{
"name": "July",
"href": null
}
],
"canonical_path": "/climate/london-england-gb/july",
"page_title": "London, GB in July: normal weather & records · Thermograph",
"page_description": "London, GB averages 22°C highs and 13°C lows in July. Every day graded against 46 years of local history."
}

585
tests/fixtures/records.json vendored Normal file
View file

@ -0,0 +1,585 @@
{
"year_range": [
1980,
2026
],
"n_years": 46,
"rows": [
{
"label": "High",
"high_f": 97.5,
"high_date": "2022-07-19",
"low_f": 20.3,
"low_date": "1987-01-12",
"dry_streak_days": null
},
{
"label": "Low",
"high_f": 71.8,
"high_date": "2022-07-19",
"low_f": 3.3,
"low_date": "1981-12-13",
"dry_streak_days": null
},
{
"label": "Feels-like",
"high_f": 100.6,
"high_date": "2019-07-25",
"low_f": -5.3,
"low_date": "1986-02-10",
"dry_streak_days": null
},
{
"label": "Humidity",
"high_f": 17.4,
"high_date": "2026-06-24",
"low_f": 1.9,
"low_date": "1987-01-12",
"dry_streak_days": null
},
{
"label": "Wind",
"high_f": 41.5,
"high_date": "1987-10-16",
"low_f": 2.3,
"low_date": "2024-12-27",
"dry_streak_days": null
},
{
"label": "Gust",
"high_f": 81.7,
"high_date": "2000-10-30",
"low_f": 5.8,
"low_date": "2024-12-27",
"dry_streak_days": null
},
{
"label": "Precip",
"high_f": 1.23,
"high_date": "2021-06-18",
"low_f": null,
"low_date": "1995-07-31",
"dry_streak_days": 33
}
],
"all_time_records": {
"tmax": {
"max": 97.5,
"max_date": "2022-07-19",
"min": 20.3,
"min_date": "1987-01-12"
},
"tmin": {
"max": 71.8,
"max_date": "2022-07-19",
"min": 3.3,
"min_date": "1981-12-13"
},
"feels": {
"max": 100.6,
"max_date": "2019-07-25",
"min": -5.3,
"min_date": "1986-02-10"
},
"fmax": {
"max": 100.6,
"max_date": "2019-07-25",
"min": 11.8,
"min_date": "1987-01-12"
},
"fmin": {
"max": 73.2,
"max_date": "2026-06-26",
"min": -5.3,
"min_date": "1986-02-10"
},
"humid": {
"max": 17.4,
"max_date": "2026-06-24",
"min": 1.9,
"min_date": "1987-01-12"
},
"wind": {
"max": 41.5,
"max_date": "1987-10-16",
"min": 2.3,
"min_date": "2024-12-27"
},
"gust": {
"max": 81.7,
"max_date": "2000-10-30",
"min": 5.8,
"min_date": "2024-12-27"
},
"precip": {
"max": 1.23,
"max_date": "2021-06-18",
"min": 0.0,
"min_date": "1980-01-01"
}
},
"monthly": [
{
"name": "January",
"slug": "january",
"high": {
"warm": {
"value_f": 57.9,
"date": "2022-01-01"
},
"cold": {
"value_f": 20.3,
"date": "1987-01-12"
}
},
"low": {
"warm": {
"value_f": 54.4,
"date": "2008-01-19"
},
"cold": {
"value_f": 11.4,
"date": "1982-01-14"
}
}
},
{
"name": "February",
"slug": "february",
"high": {
"warm": {
"value_f": 61.0,
"date": "1990-02-23"
},
"cold": {
"value_f": 25.5,
"date": "1991-02-07"
}
},
"low": {
"warm": {
"value_f": 53.8,
"date": "2004-02-04"
},
"cold": {
"value_f": 3.3,
"date": "1986-02-10"
}
}
},
{
"name": "March",
"slug": "march",
"high": {
"warm": {
"value_f": 70.1,
"date": "2021-03-31"
},
"cold": {
"value_f": 31.1,
"date": "1986-03-01"
}
},
"low": {
"warm": {
"value_f": 51.8,
"date": "2006-03-26"
},
"cold": {
"value_f": 17.7,
"date": "1986-03-03"
}
}
},
{
"name": "April",
"slug": "april",
"high": {
"warm": {
"value_f": 76.7,
"date": "2011-04-23"
},
"cold": {
"value_f": 38.6,
"date": "2013-04-04"
}
},
"low": {
"warm": {
"value_f": 56.0,
"date": "2024-04-06"
},
"cold": {
"value_f": 25.0,
"date": "1996-04-02"
}
}
},
{
"name": "May",
"slug": "may",
"high": {
"warm": {
"value_f": 88.7,
"date": "2026-05-26"
},
"cold": {
"value_f": 46.1,
"date": "1996-05-18"
}
},
"low": {
"warm": {
"value_f": 61.3,
"date": "2018-05-27"
},
"cold": {
"value_f": 31.1,
"date": "1981-05-05"
}
}
},
{
"name": "June",
"slug": "june",
"high": {
"warm": {
"value_f": 90.4,
"date": "2026-06-26"
},
"cold": {
"value_f": 53.0,
"date": "1991-06-01"
}
},
"low": {
"warm": {
"value_f": 71.5,
"date": "2026-06-26"
},
"cold": {
"value_f": 38.0,
"date": "1989-06-05"
}
}
},
{
"name": "July",
"slug": "july",
"high": {
"warm": {
"value_f": 97.5,
"date": "2022-07-19"
},
"cold": {
"value_f": 57.1,
"date": "1980-07-01"
}
},
"low": {
"warm": {
"value_f": 71.8,
"date": "2022-07-19"
},
"cold": {
"value_f": 45.9,
"date": "1984-07-03"
}
}
},
{
"name": "August",
"slug": "august",
"high": {
"warm": {
"value_f": 94.4,
"date": "2003-08-10"
},
"cold": {
"value_f": 57.7,
"date": "1987-08-25"
}
},
"low": {
"warm": {
"value_f": 67.9,
"date": "2020-08-12"
},
"cold": {
"value_f": 44.0,
"date": "1993-08-25"
}
}
},
{
"name": "September",
"slug": "september",
"high": {
"warm": {
"value_f": 86.2,
"date": "2023-09-09"
},
"cold": {
"value_f": 49.1,
"date": "1993-09-27"
}
},
"low": {
"warm": {
"value_f": 65.1,
"date": "2016-09-06"
},
"cold": {
"value_f": 38.9,
"date": "1986-09-20"
}
}
},
{
"name": "October",
"slug": "october",
"high": {
"warm": {
"value_f": 79.4,
"date": "2011-10-01"
},
"cold": {
"value_f": 43.6,
"date": "2008-10-29"
}
},
"low": {
"warm": {
"value_f": 66.2,
"date": "2018-10-13"
},
"cold": {
"value_f": 28.8,
"date": "1983-10-30"
}
}
},
{
"name": "November",
"slug": "november",
"high": {
"warm": {
"value_f": 63.3,
"date": "2005-11-02"
},
"cold": {
"value_f": 33.2,
"date": "2010-11-28"
}
},
"low": {
"warm": {
"value_f": 57.0,
"date": "2010-11-04"
},
"cold": {
"value_f": 22.8,
"date": "2010-11-28"
}
}
},
{
"name": "December",
"slug": "december",
"high": {
"warm": {
"value_f": 58.4,
"date": "1993-12-19"
},
"cold": {
"value_f": 24.3,
"date": "2010-12-03"
}
},
"low": {
"warm": {
"value_f": 54.9,
"date": "2021-12-30"
},
"cold": {
"value_f": 3.3,
"date": "1981-12-13"
}
}
}
],
"seasonal": [
{
"name": "Winter",
"span": "DecFeb",
"high": {
"warm": {
"value_f": 61.0,
"date": "1990-02-23"
},
"cold": {
"value_f": 20.3,
"date": "1987-01-12"
}
},
"low": {
"warm": {
"value_f": 54.9,
"date": "2021-12-30"
},
"cold": {
"value_f": 3.3,
"date": "1981-12-13"
}
}
},
{
"name": "Spring",
"span": "MarMay",
"high": {
"warm": {
"value_f": 88.7,
"date": "2026-05-26"
},
"cold": {
"value_f": 31.1,
"date": "1986-03-01"
}
},
"low": {
"warm": {
"value_f": 61.3,
"date": "2018-05-27"
},
"cold": {
"value_f": 17.7,
"date": "1986-03-03"
}
}
},
{
"name": "Summer",
"span": "JunAug",
"high": {
"warm": {
"value_f": 97.5,
"date": "2022-07-19"
},
"cold": {
"value_f": 53.0,
"date": "1991-06-01"
}
},
"low": {
"warm": {
"value_f": 71.8,
"date": "2022-07-19"
},
"cold": {
"value_f": 38.0,
"date": "1989-06-05"
}
}
},
{
"name": "Autumn",
"span": "SepNov",
"high": {
"warm": {
"value_f": 86.2,
"date": "2023-09-09"
},
"cold": {
"value_f": 33.2,
"date": "2010-11-28"
}
},
"low": {
"warm": {
"value_f": 66.2,
"date": "2018-10-13"
},
"cold": {
"value_f": 22.8,
"date": "2010-11-28"
}
}
}
],
"hemisphere": "Northern",
"breadcrumb": [
{
"name": "Home",
"href": "/"
},
{
"name": "Climate",
"href": "/climate"
},
{
"name": "London",
"href": "/climate/london-england-gb"
},
{
"name": "Records",
"href": null
}
],
"canonical_path": "/climate/london-england-gb/records",
"page_title": "London, GB weather records: hottest & coldest days since 1980",
"page_description": "London, GB's hottest day hit 36°C (Jul 2022); its coldest fell to -16°C (Dec 1981). Every day graded against 46 years of local history.",
"jsonld": {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Dataset",
"name": "London, England, United Kingdom monthly and seasonal weather records",
"description": "Record high and low temperatures for London, England, United Kingdom by month and by meteorological season, with the dates they occurred, from ~46 years of daily climate history.",
"url": "http://127.0.0.1:18137/climate/london-england-gb/records",
"temporalCoverage": "1980/2026",
"spatialCoverage": {
"@type": "Place",
"name": "London, England, United Kingdom",
"geo": {
"@type": "GeoCoordinates",
"latitude": 51.50853,
"longitude": -0.12574
}
},
"creator": {
"@type": "Organization",
"name": "Thermograph"
},
"isBasedOn": "https://open-meteo.com/ (ERA5 reanalysis)"
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "http://127.0.0.1:18137/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Climate",
"item": "http://127.0.0.1:18137/climate"
},
{
"@type": "ListItem",
"position": 3,
"name": "London",
"item": "http://127.0.0.1:18137/climate/london-england-gb"
},
{
"@type": "ListItem",
"position": 4,
"name": "Records"
}
]
}
]
}
}

70037
tests/fixtures/sitemap.json vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,72 @@
"""Integration tier: run the SSR app against a REAL backend container (pulled by
scripts/backend-for-tests.sh). Exercises the actual HTTP contract instead of backend
source catches drift the unit fixtures can't. Asserts the contract (reachability,
version negotiation, payload shape, SSR renders 200), not exact climate numbers.
Skips automatically when Docker/the image isn't available (see tests/conftest.py).
"""
import json
import os
import httpx
import pytest
B = "/thermograph"
SLUG = "london-england-gb"
MONTH = "july"
API = "v2" # frontend api_client.API_VERSION
_FIXTURES = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "fixtures")
def _live(backend_service, path):
# The backend container runs THERMOGRAPH_BASE=/, so its API is at /api/<ver>/...
return httpx.get(f"{backend_service}{path}", timeout=40.0)
def test_backend_version_is_compatible(backend_service):
"""The live backend advertises a contract the frontend (API_VERSION=v2) can talk to."""
v = _live(backend_service, "/api/version").json()
assert v["backend_version"] == "2", v # frontend targets /api/v2
assert int(v["min_frontend"]) <= 2, v # backend still accepts this frontend
@pytest.mark.parametrize("ep", ["home", "sitemap", "hub", "city", "month", "records"])
def test_fixture_shapes_match_live_backend(backend_service, ep):
"""The committed unit fixtures still match the live payload shapes — proves the two
tiers can't silently diverge. Compares top-level structure, not values."""
paths = {
"home": f"/api/{API}/content/home",
"sitemap": f"/api/{API}/content/sitemap",
"hub": f"/api/{API}/content/hub",
"city": f"/api/{API}/content/city/{SLUG}",
"month": f"/api/{API}/content/city/{SLUG}/month/{MONTH}",
"records": f"/api/{API}/content/city/{SLUG}/records",
}
live = _live(backend_service, paths[ep])
assert live.status_code == 200, live.text[:200]
live_json = live.json()
with open(os.path.join(_FIXTURES, f"{ep}.json"), encoding="utf-8") as fh:
fixture = json.load(fh)
if isinstance(fixture, dict):
assert set(live_json.keys()) == set(fixture.keys()), (
f"{ep}: live keys {sorted(live_json)} != fixture keys {sorted(fixture)} "
"— run scripts/capture-fixtures.sh")
else:
assert type(live_json) is type(fixture)
def test_unknown_city_404(backend_service):
assert _live(backend_service, f"/api/{API}/content/city/nope-not-a-city").status_code == 404
@pytest.mark.parametrize("path", ["/", "/climate", f"/climate/{SLUG}", f"/climate/{SLUG}/{MONTH}",
f"/climate/{SLUG}/records", "/sitemap.xml", "/robots.txt"])
def test_ssr_renders_against_live_backend(live_client, path):
"""The SSR app rendering each page end-to-end against the live backend proves every
field content.py reads is actually present in the real payload."""
r = live_client.get(f"{B}{path}")
assert r.status_code == 200, f"{path} -> {r.status_code}"
assert "__ORIGIN__" not in r.text
def test_ssr_unknown_city_404(live_client):
assert live_client.get(f"{B}/climate/nope-not-a-city").status_code == 404

View file

@ -1,442 +0,0 @@
"""Tests for the SSR content pages, ported from backend/tests/web/test_content.py
(repo-split Stage 3). The `client` fixture (conftest.py) fakes api_client by
calling the real backend payload builders against the same synthetic history
fixture backend/tests uses, so assertions here and there agree on the same
numbers. Tests that exercise backend-only concerns (indexnow submission, the
/api/v2/content/* JSON endpoints themselves, data-layer helpers like
cities.title_name) stay in backend/tests -- this file covers only what the SSR
rendering layer (content.py + format.py) does with that data.
"""
import math
import os
import re
import httpx
import pytest
import api_client
import content
import format as fmt
from conftest import B, SLUG
def test_robots_txt(client):
r = client.get(f"{B}/robots.txt")
assert r.status_code == 200
assert "Sitemap:" in r.text and "/sitemap.xml" in r.text
assert "Disallow: /thermograph/api/" in r.text
def test_sitemap_lists_city_urls(client):
r = client.get(f"{B}/sitemap.xml")
assert r.status_code == 200
assert "<urlset" in r.text
assert f"/climate/{SLUG}</loc>" in r.text
assert f"/climate/{SLUG}/july</loc>" in r.text
assert f"/climate/{SLUG}/records</loc>" in r.text
def test_city_page_renders_stats_in_html(client):
r = client.get(f"{B}/climate/{SLUG}")
assert r.status_code == 200
b = r.text
assert "London" in b and "climate" in b.lower()
assert 'rel="canonical"' in b and f"/climate/{SLUG}" in b
assert '"@type":"Dataset"' in b
assert "average temperatures by month" in b.lower()
assert re.search(r"-?\d+°C</span>", b)
def _jsonld_breadcrumbs(html: str) -> list[dict]:
import json
m = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S)
assert m, "no JSON-LD block on page"
graph = json.loads(m.group(1))["@graph"]
return [n for n in graph if n["@type"] == "BreadcrumbList"]
@pytest.mark.parametrize("path", [f"/climate/{SLUG}", f"/climate/{SLUG}/records"])
def test_breadcrumb_jsonld_items_valid_for_google(client, path):
(bc,) = _jsonld_breadcrumbs(client.get(B + path).text)
els = bc["itemListElement"]
assert [e["position"] for e in els] == list(range(1, len(els) + 1))
for e in els[:-1]:
assert e["item"].startswith("http"), f"crumb {e['name']!r} missing item"
def test_jsonld_url_uses_the_ssr_requests_own_origin(client):
# A regression guard for a real bug caught during the port: the jsonld
# "url" field must reflect the browser-facing request the SSR app
# received, not the internal backend-call's own origin.
for path in (f"/climate/{SLUG}", f"/climate/{SLUG}/records"):
html = client.get(B + path).text
m = re.search(r'"url":"(http://testserver[^"]*)"', html)
assert m, f"{path}: jsonld url missing or not on the request's own origin"
def test_city_404(client):
assert client.get(f"{B}/climate/nope-not-a-city").status_code == 404
def _unreachable(*_a, **_k):
req = httpx.Request("GET", "http://backend.invalid/x")
raise httpx.ConnectError("connection refused", request=req)
@pytest.mark.parametrize("path, attr", [
(f"/climate/{SLUG}", "city"),
(f"/climate/{SLUG}/july", "city_month"),
(f"/climate/{SLUG}/records", "city_records"),
("/climate", "hub"),
("/", "home"),
("/sitemap.xml", "sitemap"),
])
def test_backend_unreachable_maps_to_503(client, monkeypatch, path, attr):
"""A backend that's down/timing out (httpx.ConnectError, TimeoutException,
...) never raises httpx.HTTPStatusError -- it doesn't even get a response
-- so it must be caught separately from the 404/503 status-code passthrough
or it escapes as a raw framework 500 instead of a clean 503."""
monkeypatch.setattr(api_client, attr, _unreachable)
assert client.get(f"{B}{path}").status_code == 503
def test_curated_event_renders(client):
b = client.get(f"{B}/climate/new-orleans-louisiana-us").text
assert "Notable weather in New Orleans" in b
assert "Hurricane Katrina" in b
def test_uncurated_city_has_no_event_section(client):
b = client.get(f"{B}/climate/shanghai-cn").text
assert "city-event" not in b
def test_city_travel_cta_prefills_compare(client):
b = client.get(f"{B}/climate/{SLUG}").text
assert "Thinking of visiting" in b
assert "/compare#loc=" in b
def test_month_and_records(client):
assert client.get(f"{B}/climate/{SLUG}/july").status_code == 200
assert client.get(f"{B}/climate/{SLUG}/records").status_code == 200
assert client.get(f"{B}/climate/{SLUG}/notamonth").status_code == 404
def test_month_records_cover_every_metric_high_and_low(client):
b = client.get(f"{B}/climate/{SLUG}/july").text
assert "July records" in b
for label in ("High", "Low", "Precip"):
assert f'class="rc-metric">{label}<' in b
n = b.count('rc-row rc-high')
assert n >= 3 and b.count('rc-row rc-low') == n
assert "Highest" in b and "Lowest" in b
assert "Wettest" in b and "Driest" in b
def test_records_page_has_monthly_and_seasonal(client):
b = client.get(f"{B}/climate/{SLUG}/records").text
assert "Records by month" in b
for month in ("January", "July", "December"):
assert month in b
assert f"/climate/{SLUG}/january" in b
assert "Records by season" in b
assert "Northern Hemisphere" in b
assert 'Winter <span class="season-span">(DecFeb)</span>' in b
assert 'Summer <span class="season-span">(JunAug)</span>' in b
assert "All-time records" in b
assert '"@type":"Dataset"' in b
assert re.search(r"-?\d+°C</span>", b)
assert "monthly" in b.lower() and "seasonal" in b.lower()
def test_records_seasons_flip_in_southern_hemisphere(client):
b = client.get(f"{B}/climate/sao-paulo-br/records").text
assert "Southern Hemisphere" in b
assert 'Summer <span class="season-span">(DecFeb)</span>' in b
assert 'Winter <span class="season-span">(JunAug)</span>' in b
def test_climate_pages_are_colour_coded(client):
city = client.get(f"{B}/climate/{SLUG}").text
assert "t-cell t-" in city
assert "range-fill" in city and "--c1:var(--" in city
recs = client.get(f"{B}/climate/{SLUG}/records").text
assert "t-inline t-" in recs
month = client.get(f"{B}/climate/{SLUG}/july").text
assert "t-inline t-" in month
def test_records_show_both_extremes_per_metric(client):
b = client.get(f"{B}/climate/{SLUG}/records").text
assert "Daytime high" in b and "Overnight low" in b
assert "" in b and "" in b
assert b.count('class="rec-ext"') >= 48
def test_hub_glossary_about(client):
assert client.get(f"{B}/climate").status_code == 200
assert client.get(f"{B}/glossary").status_code == 200
assert client.get(f"{B}/glossary/percentile").status_code == 200
assert client.get(f"{B}/glossary/not-a-term").status_code == 404
assert client.get(f"{B}/about").status_code == 200
def test_static_page_titles_come_from_content_yaml(client):
from markupsafe import escape
for path, key in ((f"{B}/climate", "hub"), (f"{B}/glossary", "glossary_index"),
(f"{B}/about", "about"), (f"{B}/privacy", "privacy")):
html = client.get(path).text
assert f"<title>{escape(content.PAGES[key]['title'])}</title>" in html, path
def test_glossary_terms_come_from_content_yaml(client):
html = client.get(f"{B}/glossary").text
for entry in content.GLOSSARY.values():
assert entry["term"] in html
term_html = client.get(f"{B}/glossary/percentile").text
assert content.GLOSSARY["percentile"]["short"] in term_html
def test_footer_has_no_leaked_jinja_comment(client):
for path in (f"{B}/about", f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/records"):
b = client.get(path).text
footer = re.search(r"<footer.*?</footer>", b, re.S)
assert footer, f"no footer on {path}"
assert "#}" not in footer.group(0) and "{#" not in footer.group(0), path
def test_page_titles_lead_with_city_and_payload(client):
for path, must_start, payload in (
(f"{B}/climate/{SLUG}", "London, GB climate", "how unusual it is now"),
(f"{B}/climate/{SLUG}/july", "London, GB in July", "normal weather"),
(f"{B}/climate/{SLUG}/records", "London, GB weather records", "hottest & coldest"),
):
b = client.get(path).text
title = re.search(r"<title>(.*?)</title>", b, re.S).group(1)
title = title.replace("&amp;", "&")
assert title.startswith(must_start), title
assert payload in title, title
assert "England, United Kingdom" not in title
assert len(must_start) <= 40
og = re.search(r'<meta property="og:title" content="(.*?)"', b, re.S).group(1)
assert og == re.search(r"<title>(.*?)</title>", b, re.S).group(1)
def test_meta_descriptions_lead_with_real_numbers(client):
for path in (f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/july",
f"{B}/climate/{SLUG}/records"):
b = client.get(path).text
desc = re.search(r'<meta name="description" content="(.*?)"', b, re.S).group(1)
assert len(desc) <= 155, f"{len(desc)}: {desc}"
assert re.search(r"-?\d+°C", desc), desc
assert desc.endswith("years of local history."), desc
rec = client.get(f"{B}/climate/{SLUG}/records").text
desc = re.search(r'<meta name="description" content="(.*?)"', rec, re.S).group(1)
assert re.search(r"\([A-Z][a-z]{2} \d{4}\)", desc), desc
_TEMP_SPAN = re.compile(r'<span class="temp" data-temp-f="(-?[\d.]+)"([^>]*)>(-?\d+)°([CF]?)</span>')
def _temp_spans(html):
return [(float(f), int(n), letter) for f, _attrs, n, letter in _TEMP_SPAN.findall(html)]
def test_climate_pages_carry_convertible_temps(client):
for path in (f"{B}/climate/{SLUG}", f"{B}/climate/{SLUG}/july", f"{B}/climate/{SLUG}/records"):
b = client.get(path).text
spans = _temp_spans(b)
assert spans, f"no temperature spans on {path}"
assert re.search(r"\d+°F \(-?\d+°C\)", b) is None
def test_units_default_to_the_citys_country(client):
gb = client.get(f"{B}/climate/{SLUG}").text
us = client.get(f"{B}/climate/seattle-washington-us").text
gb_spans, us_spans = _temp_spans(gb), _temp_spans(us)
assert gb_spans and us_spans
assert {letter for _f, _n, letter in gb_spans if letter} == {"C"}
assert {letter for _f, _n, letter in us_spans if letter} == {"F"}
assert any(math.floor(f + 0.5) != n for f, n, _l in gb_spans), "data-temp-f was converted"
assert all(math.floor(f + 0.5) == n for f, n, _l in us_spans)
assert 'data-unit-default="C"' in gb
assert 'data-unit-default="F"' in us
_PRECIP_SPAN = re.compile(r'<span class="precip" data-precip-in="([\d.]+)">([\d.]+) (in|mm)</span>')
def test_precip_and_wind_follow_the_citys_unit(client):
gb = client.get(f"{B}/climate/{SLUG}/records").text
us = client.get(f"{B}/climate/seattle-washington-us/records").text
gb_p, us_p = _PRECIP_SPAN.findall(gb), _PRECIP_SPAN.findall(us)
assert gb_p and us_p
assert {u for _raw, _shown, u in gb_p} == {"mm"}
assert {u for _raw, _shown, u in us_p} == {"in"}
for raw, shown, _u in gb_p:
assert abs(float(shown) - float(raw) * 25.4) < 0.51, (raw, shown)
for raw, shown, _u in us_p:
assert abs(float(shown) - float(raw)) < 0.005, (raw, shown)
def test_wind_and_humidity_render_per_unit_system():
# The synthetic history carries only tmax/tmin/precip, so wind and
# humidity never reach a rendered page in these tests -- exercise
# format.py's renderers directly, same as the backend's equivalent test.
with fmt.unit_scope("F"):
assert fmt.wind_text(10) == "10 mph"
assert '<span class="wind" data-wind-mph="10.0">10 mph</span>' == fmt.wind(10)
assert fmt.fmt("humid", 7.75) == "7.8 g/m³"
with fmt.unit_scope("C"):
assert fmt.wind_text(10) == "16 km/h"
assert 'data-wind-mph="10.0"' in fmt.wind(10)
assert fmt.fmt("humid", 7.75) == "7.8 g/m³"
def test_precip_precision_differs_by_unit():
with fmt.unit_scope("F"):
assert fmt.precip_text(0.04) == "0.04 in"
with fmt.unit_scope("C"):
assert fmt.precip_text(0.04) == "1 mm"
assert fmt.precip_text(1.81) == "46 mm"
def test_measure_conversion_constants_match_the_frontend():
units_js = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"frontend", "units.js")
with open(units_js, encoding="utf-8") as f:
src = f.read()
mm = float(re.search(r"MM_PER_IN\s*=\s*([\d.]+)", src).group(1))
kmh = float(re.search(r"KMH_PER_MPH\s*=\s*([\d.]+)", src).group(1))
assert mm == fmt.MM_PER_IN
assert kmh == fmt.KMH_PER_MPH
def test_unit_default_absent_without_a_city(client):
for path in (f"{B}/", f"{B}/climate", f"{B}/about", f"{B}/glossary"):
assert "data-unit-default" not in client.get(path).text, path
def test_unit_does_not_leak_between_requests(client):
assert 'data-unit-default="C"' in client.get(f"{B}/climate/{SLUG}").text
about = client.get(f"{B}/about").text
assert "data-unit-default" not in about
assert {l for _f, _n, l in _temp_spans(about) if l} == {"F"}
us = client.get(f"{B}/climate/seattle-washington-us").text
assert {l for _f, _n, l in _temp_spans(us) if l} == {"F"}
gb_again = client.get(f"{B}/climate/{SLUG}").text
assert {l for _f, _n, l in _temp_spans(gb_again) if l} == {"C"}
def test_f_country_list_matches_the_frontend():
units_js = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"frontend", "units.js")
with open(units_js, encoding="utf-8") as f:
src = f.read()
literal = re.search(r"F_REGIONS = new Set\(\[(.*?)\]\)", src, re.S).group(1)
assert set(re.findall(r'"([A-Z]{2})"', literal)) == set(fmt.F_COUNTRIES)
def test_celsius_rounding_matches_js():
assert fmt._round_half_up(0.5) == 1
assert fmt._round_half_up(1.5) == 2
assert fmt._round_half_up(2.5) == 3
assert fmt._round_half_up(-0.5) == 0
assert fmt._c(31.1) == 0
def test_pct_ordinal_matches_backend_grading():
# format.pct_ordinal is a ported copy of backend/data/grading.py's
# pct_ordinal (the frontend can no longer import backend code) -- pin the
# two against the same cases so a future edit to either can't drift silently.
assert fmt.pct_ordinal(66.4) == "66th"
assert fmt.pct_ordinal(1.2) == "1st"
assert fmt.pct_ordinal(22) == "22nd"
assert fmt.pct_ordinal(3) == "3rd"
assert fmt.pct_ordinal(11) == "11th"
assert fmt.pct_ordinal(99.6) == "99th"
assert fmt.pct_ordinal(100) == "99th"
assert fmt.pct_ordinal(0) == "1st"
assert fmt.pct_ordinal(None) == ""
def test_city_page_percentiles_are_real_ordinals(client):
html = client.get(f"{B}/climate/{SLUG}").text
assert "th pct" in html or "st pct" in html or "nd pct" in html or "rd pct" in html
for bad in re.findall(r"\d+\.\d+(?:st|nd|rd|th)|100th|\b0th|\b\d*[123]th", html):
raise AssertionError(f"malformed percentile ordinal: {bad!r}")
def test_city_page_etag_is_stable(client):
r1 = client.get(f"{B}/climate/{SLUG}")
r2 = client.get(f"{B}/climate/{SLUG}")
assert r1.headers["etag"] == r2.headers["etag"]
r3 = client.get(f"{B}/climate/{SLUG}", headers={"If-None-Match": r1.headers["etag"]})
assert r3.status_code == 304
def test_seo_pages_load_unit_and_account_scripts(client):
b = client.get(f"{B}/climate/{SLUG}").text
for src in (f"{B}/units.js", f"{B}/account.js", f"{B}/climate.js"):
assert f'src="{src}"' in b
def test_indexnow_key_file_served(client):
from conftest import FAKE_INDEXNOW_KEY
r = client.get(f"{B}/{FAKE_INDEXNOW_KEY}.txt")
assert r.status_code == 200
assert r.text.strip() == FAKE_INDEXNOW_KEY
assert r.headers["content-type"].startswith("text/plain")
def test_sitemap_lastmod_is_stable(client):
import datetime
body = client.get(f"{B}/sitemap.xml").text
mods = set(re.findall(r"<lastmod>([^<]+)</lastmod>", body))
assert len(mods) == 1
datetime.date.fromisoformat(next(iter(mods)))
def test_search_verification_meta(client, monkeypatch):
monkeypatch.setenv("THERMOGRAPH_GOOGLE_VERIFY", "gtok123")
monkeypatch.setenv("THERMOGRAPH_BING_VERIFY", "btok456")
seo = client.get(f"{B}/climate/{SLUG}").text
home = client.get(f"{B}/").text
for b in (seo, home):
assert '<meta name="google-site-verification" content="gtok123">' in b
assert '<meta name="msvalidate.01" content="btok456">' in b
BRAND_PAGES = [B + p for p in ("/", "/climate", "/glossary", "/about", "/privacy")]
@pytest.mark.parametrize("path", BRAND_PAGES)
def test_brand_lockup_links_home(client, path):
html = client.get(path).text
brand = html.split('<div class="brand">', 1)[1].split("</header>", 1)[0]
head = brand.split("</h1>")[0] if "<h1" in brand else brand.split("</p>")[0]
assert "<a href=" in head, f"{path}: brand is not a link"
href = head.split('<a href="', 1)[1].split('"', 1)[0]
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

@ -1,176 +0,0 @@
"""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

View file

@ -1,7 +1,8 @@
"""SPA-shell pages (/calendar, /day, /score, /compare, /legend, /alerts) and
static-asset serving -- moved here from backend/tests/web/test_api.py
(repo-split Stage 7a: frontend now owns these, not backend)."""
from conftest import B
(repo-split Stage 7a: frontend now owns these, not backend). These routes are static
shells/assets that don't call the backend, so the hermetic `client` fixture serves them."""
B = "/thermograph" # matches THERMOGRAPH_BASE set in tests/conftest.py
def test_calendar_serves_with_origin_filled_in(client):

View file

@ -0,0 +1,94 @@
"""Unit tier: the SSR rendering layer (content.py + format.py) fed committed backend
fixtures via the hermetic `client` fixture (tests/conftest.py). Asserts the rendering
*contract* pages render and carry the expected structure not exact climate numbers
(those are the backend's responsibility). Ported from the old backend-coupled
test_content.py; refresh the fixtures with scripts/capture-fixtures.sh.
"""
import re
import pytest
B = "/thermograph" # matches THERMOGRAPH_BASE in tests/conftest.py
SLUG = "london-england-gb"
MONTH = "july"
def test_robots_txt(client):
r = client.get(f"{B}/robots.txt")
assert r.status_code == 200
assert "Sitemap:" in r.text and "/sitemap.xml" in r.text
assert f"Disallow: {B}/api/" in r.text
def test_sitemap_lists_city_urls(client):
r = client.get(f"{B}/sitemap.xml")
assert r.status_code == 200
assert "<urlset" in r.text
assert f"/climate/{SLUG}</loc>" in r.text
assert f"/climate/{SLUG}/{MONTH}</loc>" in r.text
assert f"/climate/{SLUG}/records</loc>" in r.text
def test_city_page_renders_structure(client):
r = client.get(f"{B}/climate/{SLUG}")
assert r.status_code == 200
b = r.text
assert "London" in b and "climate" in b.lower()
assert 'rel="canonical"' in b and f"/climate/{SLUG}" in b
assert '"@type":"Dataset"' in b # JSON-LD dataset block
assert "average temperatures by month" in b.lower()
assert re.search(r"-?\d+°C</span>", b) # GB city -> Celsius
@pytest.mark.parametrize("path", ["", f"/{MONTH}", "/records"])
def test_city_subpages_render(client, path):
r = client.get(f"{B}/climate/{SLUG}{path}")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert "__ORIGIN__" not in r.text # origin placeholder filled in
def test_homepage_renders(client):
r = client.get(f"{B}/")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
def test_hub_renders(client):
r = client.get(f"{B}/climate")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
def test_unknown_city_is_404(client):
assert client.get(f"{B}/climate/nope-not-a-city").status_code == 404
def test_unknown_month_is_404(client):
assert client.get(f"{B}/climate/{SLUG}/nonemonth").status_code == 404
# --- backend-down resilience (migrated from main's hardening of the old
# tests/test_content.py, which the unit-tier restructure deleted) ------------
def _unreachable(*_a, **_k):
import httpx
req = httpx.Request("GET", "http://backend.invalid/x")
raise httpx.ConnectError("connection refused", request=req)
@pytest.mark.parametrize("path, attr", [
(f"/climate/{SLUG}", "city"),
(f"/climate/{SLUG}/july", "city_month"),
(f"/climate/{SLUG}/records", "city_records"),
("/climate", "hub"),
("/", "home"),
("/sitemap.xml", "sitemap"),
])
def test_backend_unreachable_maps_to_503(client, monkeypatch, path, attr):
"""A backend that's down/timing out (httpx.ConnectError, TimeoutException,
...) never raises httpx.HTTPStatusError -- it doesn't even get a response
-- so it must be caught separately from the 404/503 status-code passthrough
or it escapes as a raw framework 500 instead of a clean 503."""
import api_client
monkeypatch.setattr(api_client, attr, _unreachable)
assert client.get(f"{B}{path}").status_code == 503