"""Shared SSR test setup — self-contained, NO backend checkout. 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 json import os import subprocess import sys import httpx import pytest _HERE = os.path.dirname(os.path.abspath(__file__)) 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 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") 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: req = httpx.Request("GET", "http://backend.invalid/x") resp = httpx.Response(404, request=req, json={"detail": detail}) return httpx.HTTPStatusError(detail, request=req, response=resp) # --- unit tier --------------------------------------------------------------- @pytest.fixture 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): if slug != SLUG: raise _not_found("Unknown city.") return fx["city"] def _month(slug, month): if slug != SLUG or month != MONTH: raise _not_found("Unknown month.") return fx["month"] def _records(slug, *, origin=None): if slug != SLUG: raise _not_found("Unknown city.") return fx["records"] monkeypatch.setattr(api_client, "city", _city) monkeypatch.setattr(api_client, "city_month", _month) monkeypatch.setattr(api_client, "city_records", _records) return TestClient(ssr_app.app) # --- 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 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)