Make the app installable and deliver existing alert notifications as OS push, alongside the in-app bell. Backend: - PushSubscription model (per-device endpoint + keys, owned by a user) and register/unregister/test endpoints under /api/v2/push, cookie-auth scoped to the user like the subscription routes. - push.py: VAPID key management (env -> data/vapid.json -> generated) and a pywebpush send helper that reports gone endpoints for pruning. No DB coupling. - notify.py: after creating an in-app Notification, dispatch Web Push to the user's devices (guarded — a push failure never affects the in-app write; endpoints reported gone are pruned). - Serve the .webmanifest with the correct media type. Frontend: - manifest.webmanifest + 192/maskable icons; <link rel="manifest"> on all pages. - sw.js: push + notificationclick handlers (push-only; no fetch caching, so it doesn't fight the existing IndexedDB cache). Registered globally in nav.js in secure contexts. - push-client.js + a "Notifications on this device" toggle and test-send on the /alerts page, subscribing through the existing cookie-aware apiFetch. Push and service workers require a secure context, so this is active over HTTPS (or http://localhost) and cleanly no-ops on a plain-HTTP LAN origin.
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
|
|
|
|
import climate
|
|
import db
|
|
import notify
|
|
import push
|
|
from 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
|