Some checks failed
secrets-guard / encrypted (push) Successful in 6s
shell-lint / shellcheck (push) Successful in 13s
secrets-guard / encrypted (pull_request) Successful in 14s
PR build (required check) / changes (pull_request) Successful in 16s
PR build (required check) / validate-observability (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 28s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m58s
Deploy frontend to LAN dev server / build (push) Successful in 1m59s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 2m5s
Deploy backend to LAN dev server / build (push) Successful in 2m20s
PR build (required check) / build-frontend (pull_request) Successful in 1m44s
Deploy frontend to LAN dev server / deploy (push) Successful in 30s
PR build (required check) / build-backend (pull_request) Successful in 2m0s
PR build (required check) / gate (pull_request) Successful in 2s
Deploy backend to LAN dev server / deploy (push) Failing after 1m58s
139 lines
5.6 KiB
Python
139 lines
5.6 KiB
Python
"""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
|
|
|
|
|
|
# The public site is HTTPS-only behind Caddy, which terminates TLS and proxies
|
|
# plain HTTP -- so x-forwarded-proto / request.url.scheme read "http". A request
|
|
# arriving on the configured public host (THERMOGRAPH_BASE_URL defaults to
|
|
# https://thermograph.org in the test env) must still emit https:// in every
|
|
# frontend-built absolute URL, or canonicals self-conflict and the sitemap lists
|
|
# redirecting URLs.
|
|
_PUBLIC = {"host": "thermograph.org", "x-forwarded-proto": "http"}
|
|
|
|
|
|
def test_public_host_canonical_and_og_are_https(client):
|
|
b = client.get(f"{B}/climate/{SLUG}", headers=_PUBLIC).text
|
|
assert f'<link rel="canonical" href="https://thermograph.org{B}/climate/{SLUG}"' in b
|
|
assert f'<meta property="og:url" content="https://thermograph.org{B}/climate/{SLUG}"' in b
|
|
assert 'property="og:image" content="https://thermograph.org' in b
|
|
|
|
|
|
def test_public_host_sitemap_locs_are_https(client):
|
|
body = client.get(f"{B}/sitemap.xml", headers=_PUBLIC).text
|
|
assert f"<loc>https://thermograph.org{B}/climate/{SLUG}</loc>" in body
|
|
assert "<loc>http://thermograph.org" not in body
|
|
|
|
|
|
def test_public_host_robots_sitemap_is_https(client):
|
|
body = client.get(f"{B}/robots.txt", headers=_PUBLIC).text
|
|
assert f"Sitemap: https://thermograph.org{B}/sitemap.xml" in body
|
|
|
|
|
|
def test_non_public_host_keeps_forwarded_scheme(client):
|
|
# A LAN/dev host never matches the configured public host, so the observed
|
|
# (plain-http) scheme is preserved -- http development is unaffected.
|
|
b = client.get(f"{B}/climate/{SLUG}",
|
|
headers={"host": "192.168.1.10:8000", "x-forwarded-proto": "http"}).text
|
|
assert f'<link rel="canonical" href="http://192.168.1.10:8000{B}/climate/{SLUG}"' in b
|
|
|
|
|
|
def test_proxied_forwarded_host_still_https(client):
|
|
# Reached via backend's proxy fallback: Host is the internal hop, the real
|
|
# browser host arrives in X-Forwarded-Host -- which still resolves to the
|
|
# public host and must go https.
|
|
b = client.get(f"{B}/climate/{SLUG}",
|
|
headers={"host": "frontend:8080", "x-forwarded-host": "thermograph.org",
|
|
"x-forwarded-proto": "http"}).text
|
|
assert f'<link rel="canonical" href="https://thermograph.org{B}/climate/{SLUG}"' in b
|
|
|
|
|
|
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
|