thermograph/tests/test_notify.py

81 lines
2.9 KiB
Python
Raw Normal View History

Account system with weather-notification subscriptions (#89) * Add account system foundation: email/password auth with cookie sessions Introduce the app's first authoritative, user-owned data in a separate data/accounts.sqlite (SQLAlchemy), kept apart from the disposable derived-cache DB. Wire fastapi-users for email/password signup, cookie-based login/logout, and a session-check endpoint, backed by a database session strategy so logins survive restarts and are revocable. - db.py: async (aiosqlite) + sync SQLAlchemy engines over accounts.sqlite, WAL + foreign keys, create_db_and_tables(). - models.py: User, AccessToken, Subscription, Notification tables. - users.py: pwdlib hashing, HttpOnly cookie transport (path-scoped, SameSite=Lax, Secure via env), DatabaseStrategy sessions, current-user dependencies. - schemas.py: user + subscription + notification Pydantic models. - app.py: mount auth/register/users routers on v2, create tables at startup. - Pin fastapi-users[sqlalchemy]/aiosqlite; ignore data/accounts.sqlite*. * Add account header entry and auth modal (frontend) account.js self-injects a header entry (following the units.js pattern) that shows a Sign in button when logged out and an account menu when logged in, plus an auth modal reusing the existing .mp-overlay/.mp-modal chrome for email/password sign-in and account creation. A shared apiFetch helper sends the same-origin cookie for authed calls; exported getUser/openAuth/onAuthChange back later phases. Imported by every page entry module. On narrow screens the entry collapses to an icon-only button so it doesn't crowd the title. Enforce an 8-character minimum password in the user manager. * Add subscription CRUD API and the alerts management page Backend api_accounts.py adds user-scoped, cookie-authenticated endpoints to create/list/update/delete subscriptions (and the notification reads used next): POST snaps lat/lon to a grid cell, resolves a label, and rejects a duplicate location+kind with 409; PATCH/DELETE are ownership-checked (404 on mismatch). Mounted on the v2 prefix. Frontend subscriptions.js + subscriptions.html serve the /alerts page: a sign-in gate when logged out, an add flow that reuses the shared map picker and an editor modal (kind, watched metrics, 95-99 percentile, two-sided), and a card list with inline threshold/active edits and remove. Reachable from the account menu. * Add background subscription evaluation engine notify.py runs a daemon thread that periodically evaluates every active subscription: it groups them by grid cell, reads history from the parquet cache only (never spends archive quota) plus the hourly recent/forecast bundle, and grades candidate days with the existing grading.grade_day. A watched metric that lands at or beyond the threshold percentile fires a 'high' alert; a two-sided subscription also fires 'low' for the symmetric cold/calm/dry tail (precip stays one-directional). Observed subscriptions look at the last few recorded days, forecast subscriptions at the coming week. Two guards keep it quiet: a UNIQUE(subscription, event_date, metric, direction, kind) constraint dedups repeat events, and a per-subscription weekly cap (last_notified_at) limits each alert to one notification per 7 days. The loop tolerates a bad cell or an upstream rate limit without aborting the pass. Started and stopped from the app lifespan; gated by THERMOGRAPH_ENABLE_NOTIFIER. * Add in-app notification center (header bell) Extend account.js with a notification bell beside the account menu: an unread badge, a dropdown listing recent notifications (title, body, relative time), a per-item mark-read on click, and a Mark all read action, all through the cookie-authed notifications API. Unread state refreshes on open and polls every two minutes while signed in; polling stops on sign-out. Styled to match the app, responsive down to mobile. * Harden accounts: expired-session cleanup, engine tests, ops docs - notify.py sweeps expired login sessions (access tokens past their lifetime) once per evaluation pass. - Add hermetic unit tests for the evaluation engine's trigger detection (high/low tails, precip one-directional, normal = no trigger) and notification wording. - Document accounts.sqlite (authoritative, back it up), the single-worker requirement for the in-process evaluator, and the new env vars in DEPLOY.md.
2026-07-15 18:46:46 +00:00
"""Unit tests for the subscription evaluation engine's pure logic (no DB, no
network): trigger detection against a synthetic climatology and message wording."""
import types
import numpy as np
import pandas as pd
import notify
def _history() -> pd.DataFrame:
dates = pd.date_range("1990-01-01", "2020-12-31", freq="D")
rng = np.random.default_rng(1)
n = len(dates)
df = pd.DataFrame({"date": dates})
df["doy"] = df["date"].dt.dayofyear
df["tmax"] = 70 + rng.normal(0, 8, n)
df["tmin"] = 48 + rng.normal(0, 7, n)
df["precip"] = rng.exponential(0.05, n)
return df
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": pd.Timestamp(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")