thermograph/tests/conftest.py
Emi Griffith d17ac794fd Split the backend into domain packages (#217)
* Centralize filesystem paths in a single module

Add paths.py, which resolves the repo root once and derives the cache,
accounts DB, logs, templates, frontend and bundled-city-data locations
from it. Replace the 13 per-module `dirname(__file__)/..` anchors with
references to it, so a module's location no longer determines where the
app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are
unchanged; every resolved path is byte-identical to before.

Groundwork for moving modules into packages without re-pointing paths.

Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62

* Split the backend into domain packages

Group the flat backend modules into packages that mirror their concerns:
  data/          climate, grading, scoring, grid, places, cities,
                 city_events, store
  web/           app, views, homepage, content, schemas
  notifications/ notify, digest, push, mailer, discord,
                 discord_interactions, discord_link
  accounts/      models, users, api_accounts, db
  core/          metrics, singleton, audit

Intra-project imports are rewritten to the package-qualified form. The
entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor)
and paths.py stay at the backend/ root, and backend/app.py becomes a
shim re-exporting web.app:app so the launch target stays `app:app` —
run.sh, the systemd units, and CI need no change.

Verified: full suite (318) passes, `uvicorn app:app` boots and serves
the home/SEO/static/API surfaces, and every root script imports clean.

Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00

119 lines
4.6 KiB
Python

"""Shared test setup: import path, offline guards, and synthetic weather data.
Everything here keeps the suite hermetic — no Open-Meteo, no Nominatim, no
GeoNames download, and no writes into the repo's data/ or logs/ folders.
"""
import datetime
import os
import sys
import tempfile
import threading
import numpy as np
import polars as pl
import pytest
BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BACKEND)
from data import places # noqa: E402
# app.py calls places.start_loading() at import; pretend it already ran so no
# background GeoNames download starts. Tests build their own tiny index.
places._load_started = True
_TMP = tempfile.mkdtemp(prefix="thermograph-tests-")
# Keep the authoritative accounts DB out of the repo's data/ folder, and don't
# start the background notifier thread during tests. Set before db is imported.
os.environ.setdefault("THERMOGRAPH_ACCOUNTS_DB", os.path.join(_TMP, "accounts.sqlite"))
os.environ.setdefault("THERMOGRAPH_ENABLE_NOTIFIER", "0")
# Pin the IndexNow key so it isn't generated + written into the repo's data/ dir.
os.environ.setdefault("THERMOGRAPH_INDEXNOW_KEY", "test0000indexnow0000key000000000")
from data import store # noqa: E402
# Keep derived-store writes out of the repo's data/ folder. Individual store
# tests re-point this per test; everything else shares one throwaway DB.
store.DB_PATH = os.path.join(_TMP, "store.sqlite")
from core import audit # noqa: E402
audit.AUDIT_DIR = os.path.join(_TMP, "logs", "audit")
audit.ERROR_DIR = os.path.join(_TMP, "logs", "errors")
# The request-logging middleware runs under TestClient too; keep its access log
# out of the repo's logs/ so self-test traffic never pollutes the live monitor.
audit.ACCESS_DIR = os.path.join(_TMP, "logs", "access")
def _years_before(d: datetime.date, years: int) -> datetime.date:
"""`d` shifted back `years` years, same month/day (Feb 29 → Feb 28)."""
try:
return d.replace(year=d.year - years)
except ValueError:
return d.replace(year=d.year - years, day=28)
def _daily(start: datetime.date, end: datetime.date) -> list[datetime.date]:
"""Inclusive daily date list from `start` to `end`."""
return [start + datetime.timedelta(days=i) for i in range((end - start).days + 1)]
def _with_doy(df: pl.DataFrame) -> pl.DataFrame:
return df.with_columns(pl.col("date").dt.ordinal_day().cast(pl.Int16).alias("doy"))
def make_history(years: int = 20, end: str | None = None, seed: int = 7) -> pl.DataFrame:
"""A plausible daily record (tmax/tmin/precip + doy), matching the columns
and dtypes climate.get_history returns for an older cache."""
end_ts = (datetime.date.fromisoformat(end) if end
else datetime.date.today() - datetime.timedelta(days=6))
dates = _daily(_years_before(end_ts, years), end_ts)
rng = np.random.default_rng(seed)
doy = np.array([d.timetuple().tm_yday for d in dates])
seasonal = 55 + 30 * np.sin((doy - 100) / 366.0 * 2 * np.pi)
tmax = seasonal + rng.normal(0, 8, len(dates))
tmin = tmax - 15 + rng.normal(0, 3, len(dates))
precip = np.where(rng.random(len(dates)) < 0.3, rng.gamma(1.5, 0.2, len(dates)), 0.0)
return _with_doy(pl.DataFrame({
"date": dates,
"tmax": np.round(tmax, 1),
"tmin": np.round(tmin, 1),
"precip": np.round(precip, 2),
}))
def make_recent(history: pl.DataFrame, future_days: int = 7, seed: int = 11) -> pl.DataFrame:
"""A recent+forecast bundle: from a couple of weeks before the archive's end
through `future_days` past today — the shape climate.get_recent_forecast returns."""
today = datetime.date.today()
start = history["date"].max() - datetime.timedelta(days=14)
dates = _daily(start, today + datetime.timedelta(days=future_days))
rng = np.random.default_rng(seed)
doy = np.array([d.timetuple().tm_yday for d in dates])
seasonal = 55 + 30 * np.sin((doy - 100) / 366.0 * 2 * np.pi)
tmax = seasonal + rng.normal(0, 8, len(dates))
return _with_doy(pl.DataFrame({
"date": dates,
"tmax": np.round(tmax, 1),
"tmin": np.round(tmax - 15, 1),
"precip": np.where(rng.random(len(dates)) < 0.3, 0.15, 0.0),
}))
@pytest.fixture(scope="session")
def history():
return make_history()
@pytest.fixture(scope="session")
def recent(history):
return make_recent(history)
@pytest.fixture
def tmp_store(tmp_path, monkeypatch):
"""store.py against a fresh database file, isolated per test."""
monkeypatch.setattr(store, "DB_PATH", str(tmp_path / "store.sqlite"))
monkeypatch.setattr(store, "_local", threading.local())
return store