* 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
213 lines
8.1 KiB
Python
213 lines
8.1 KiB
Python
"""Tests for the subscription evaluation engine: trigger detection and message
|
|
wording (pure logic), plus an integration check that a full pass fetches a MISSING
|
|
archive once but never re-fetches a cached one (against conftest's throwaway DB)."""
|
|
import datetime
|
|
import types
|
|
import uuid
|
|
|
|
import numpy as np
|
|
import polars as pl
|
|
|
|
from data import climate
|
|
from accounts import db
|
|
from notifications import notify
|
|
from notifications import push
|
|
from accounts.models import Notification, PushSubscription, Subscription, User
|
|
from sqlalchemy import delete, func, select
|
|
|
|
|
|
def _history() -> pl.DataFrame:
|
|
start, end = datetime.date(1990, 1, 1), datetime.date(2020, 12, 31)
|
|
dates = [start + datetime.timedelta(days=i) for i in range((end - start).days + 1)]
|
|
rng = np.random.default_rng(1)
|
|
n = len(dates)
|
|
return pl.DataFrame({
|
|
"date": dates,
|
|
"doy": np.array([d.timetuple().tm_yday for d in dates], dtype="int16"),
|
|
"tmax": 70 + rng.normal(0, 8, n),
|
|
"tmin": 48 + rng.normal(0, 7, n),
|
|
"precip": rng.exponential(0.05, n),
|
|
})
|
|
|
|
|
|
def _sub(**kw):
|
|
d = dict(metrics=["tmax"], threshold=95, two_sided=False, kind="observed",
|
|
label="Testville", lat=1.0, lon=2.0)
|
|
d.update(kw)
|
|
return types.SimpleNamespace(**d)
|
|
|
|
|
|
def _row(date, **vals):
|
|
r = {"date": datetime.date.fromisoformat(date)}
|
|
r.update(vals)
|
|
return r
|
|
|
|
|
|
def test_high_trigger():
|
|
row = _row("2021-07-15", tmax=115.0, tmin=50.0, precip=0.0)
|
|
hit = notify._first_trigger(_sub(metrics=["tmax"], threshold=95), [row], _history(), {})
|
|
assert hit is not None
|
|
_, metric, direction, g = hit
|
|
assert metric == "tmax" and direction == "high" and g["percentile"] >= 95
|
|
|
|
|
|
def test_no_trigger_when_normal():
|
|
row = _row("2021-07-15", tmax=70.0, tmin=48.0, precip=0.0)
|
|
assert notify._first_trigger(_sub(metrics=["tmax"], threshold=95), [row], _history(), {}) is None
|
|
|
|
|
|
def test_low_trigger_only_when_two_sided():
|
|
row = _row("2021-07-15", tmax=70.0, tmin=10.0, precip=0.0) # extreme low overnight
|
|
assert notify._first_trigger(
|
|
_sub(metrics=["tmin"], threshold=95, two_sided=False), [row], _history(), {}) is None
|
|
hit = notify._first_trigger(
|
|
_sub(metrics=["tmin"], threshold=95, two_sided=True), [row], _history(), {})
|
|
assert hit is not None and hit[1] == "tmin" and hit[2] == "low"
|
|
|
|
|
|
def test_precip_never_low_side():
|
|
# A dry day has no rain-percentile, so a precip subscription never fires low
|
|
# even when two-sided (rain is one-directional).
|
|
row = _row("2021-07-15", tmax=70.0, precip=0.0)
|
|
assert notify._first_trigger(
|
|
_sub(metrics=["precip"], threshold=95, two_sided=True), [row], _history(), {}) is None
|
|
|
|
|
|
def test_compose_observed_wording():
|
|
g = {"percentile": 99.0, "value": 115.0, "grade": "Near Record"}
|
|
title, body = notify._compose(_sub(kind="observed", label="Phoenix"),
|
|
"2026-07-14", "tmax", "high", g)
|
|
assert title == "Phoenix: unusually hot day"
|
|
assert body.startswith("On 2026-07-14")
|
|
assert "99th percentile" in body and "Near Record" in body
|
|
|
|
|
|
def test_compose_forecast_wording():
|
|
g = {"percentile": 2.0, "value": 5.0, "grade": "Near Record"}
|
|
title, body = notify._compose(_sub(kind="forecast", label="Nome"),
|
|
"2026-01-02", "tmin", "low", g)
|
|
assert title == "Nome: unusually cold night"
|
|
assert body.startswith("Forecast for 2026-01-02")
|
|
|
|
|
|
# --- integration: on-demand archive fetch during a full pass -----------------
|
|
def _recent_extreme(today):
|
|
dates = [today - datetime.timedelta(days=1), today]
|
|
return pl.DataFrame({
|
|
"date": dates,
|
|
"doy": np.array([d.timetuple().tm_yday for d in dates], dtype="int16"),
|
|
"tmax": [72.0, 115.0], # today's high is an extreme -> should trigger
|
|
"tmin": [48.0, 48.0],
|
|
"precip": [0.0, 0.0],
|
|
})
|
|
|
|
|
|
def _seed_single_subscription(cell_id="100_200"):
|
|
db.Base.metadata.create_all(db.sync_engine)
|
|
uid = uuid.uuid4()
|
|
with db.sync_session_maker() as s:
|
|
s.execute(delete(User)) # clean slate in the throwaway DB
|
|
s.commit()
|
|
s.add(User(id=uid, email="pass@example.com", hashed_password="x", is_active=True))
|
|
s.commit()
|
|
s.add(Subscription(user_id=uid, cell_id=cell_id, label="X", lat=1.0, lon=2.0,
|
|
threshold=95, metrics=["tmax"], kind="observed", two_sided=False))
|
|
s.commit()
|
|
return uid
|
|
|
|
|
|
def _user_notifications(uid):
|
|
with db.sync_session_maker() as s:
|
|
return s.execute(
|
|
select(Notification).where(Notification.user_id == uid)
|
|
).scalars().all()
|
|
|
|
|
|
def test_missing_archive_is_fetched_once(monkeypatch):
|
|
uid = _seed_single_subscription()
|
|
fetched = []
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: None)
|
|
monkeypatch.setattr(climate, "get_history",
|
|
lambda cell: (fetched.append(cell["id"]) or (_history(), {})))
|
|
monkeypatch.setattr(climate, "get_recent_forecast",
|
|
lambda cell: _recent_extreme(datetime.date.today()))
|
|
notify.run_pass()
|
|
assert fetched, "a subscribed cell with no cached archive should be fetched once"
|
|
assert len(_user_notifications(uid)) == 1
|
|
|
|
|
|
def test_cached_archive_is_never_refetched(monkeypatch):
|
|
uid = _seed_single_subscription()
|
|
fetched = []
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: _history())
|
|
monkeypatch.setattr(climate, "get_history",
|
|
lambda cell: (fetched.append(cell["id"]) or (_history(), {})))
|
|
monkeypatch.setattr(climate, "get_recent_forecast",
|
|
lambda cell: _recent_extreme(datetime.date.today()))
|
|
notify.run_pass()
|
|
assert not fetched, "a cached archive must never be re-fetched by the evaluator"
|
|
assert len(_user_notifications(uid)) == 1
|
|
|
|
|
|
# --- web push delivery from a pass -------------------------------------------
|
|
def _add_push(uid, endpoint="https://push.example.com/ep"):
|
|
with db.sync_session_maker() as s:
|
|
s.add(PushSubscription(user_id=uid, endpoint=endpoint, p256dh="BKEY", auth="YXV0aA"))
|
|
s.commit()
|
|
|
|
|
|
def _push_count():
|
|
with db.sync_session_maker() as s:
|
|
return s.execute(select(func.count()).select_from(PushSubscription)).scalar_one()
|
|
|
|
|
|
def _cached_extreme(monkeypatch):
|
|
monkeypatch.setattr(climate, "load_cached_history", lambda cell: _history())
|
|
monkeypatch.setattr(climate, "get_recent_forecast",
|
|
lambda cell: _recent_extreme(datetime.date.today()))
|
|
|
|
|
|
def test_push_dispatched_on_new_notification(monkeypatch):
|
|
uid = _seed_single_subscription()
|
|
_add_push(uid, "https://push.example.com/ep-a")
|
|
_cached_extreme(monkeypatch)
|
|
calls = []
|
|
monkeypatch.setattr(push, "send", lambda info, payload: calls.append((info, payload)) or "ok")
|
|
|
|
notify.run_pass()
|
|
|
|
assert len(_user_notifications(uid)) == 1 # in-app row still created
|
|
assert len(calls) == 1 # and one push dispatched
|
|
info, payload = calls[0]
|
|
assert info["endpoint"] == "https://push.example.com/ep-a"
|
|
assert payload["title"] and payload["body"]
|
|
today = datetime.date.today().isoformat()
|
|
assert payload["url"] == f"/thermograph/day#lat=1.00000&lon=2.00000&date={today}"
|
|
|
|
|
|
def test_push_gone_endpoint_is_pruned(monkeypatch):
|
|
uid = _seed_single_subscription()
|
|
_add_push(uid, "https://push.example.com/ep-gone")
|
|
_cached_extreme(monkeypatch)
|
|
monkeypatch.setattr(push, "send", lambda info, payload: "gone")
|
|
|
|
notify.run_pass()
|
|
|
|
assert len(_user_notifications(uid)) == 1 # notification still delivered in-app
|
|
assert _push_count() == 0 # the dead endpoint was pruned
|
|
|
|
|
|
def test_inapp_notification_survives_push_error(monkeypatch):
|
|
uid = _seed_single_subscription()
|
|
_add_push(uid, "https://push.example.com/ep-boom")
|
|
_cached_extreme(monkeypatch)
|
|
|
|
def _boom(info, payload):
|
|
raise RuntimeError("push service exploded")
|
|
|
|
monkeypatch.setattr(push, "send", _boom)
|
|
|
|
notify.run_pass() # must not raise
|
|
|
|
assert len(_user_notifications(uid)) == 1 # the in-app write is unaffected
|
|
assert _push_count() == 1 # a mere error doesn't prune
|