"""Shared test setup for the SSR service. Stage-3-transitional: this package still lives in the same repo as the backend, so a `client` fixture can 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. This means a shape drift between content_payloads.py and how content.py consumes it fails here, not just in backend/tests/web/test_content.py -- and the two suites assert on identical numbers, so they can never quietly diverge. """ import importlib.util import os 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) os.environ.setdefault("THERMOGRAPH_API_BASE_INTERNAL", "http://backend.invalid") os.environ.setdefault("THERMOGRAPH_BASE", "/thermograph") 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 FAKE_INDEXNOW_KEY = "test0000indexnow0000key000000000" @pytest.fixture(scope="session") def history(): return make_history() @pytest.fixture(scope="session") def recent(history): return make_recent(history) 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) @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 _city(slug, *, origin=None): c = cities.get(slug) if c is None: raise _not_found("Unknown city.") return content_payloads.city_payload(origin or "http://testserver", B, c, history, recent) 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: raise _not_found("Unknown month.") return content_payloads.month_payload(B, c, history, content_payloads.MONTH_INDEX[month]) def _city_records(slug, *, origin=None): c = cities.get(slug) if c is None: 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()] 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() import app as appmod from fastapi.testclient import TestClient return TestClient(appmod.app)