thermograph/tests/notifications/test_notify.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

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