284 lines
11 KiB
Python
284 lines
11 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 time
|
|
import types
|
|
import uuid
|
|
|
|
import numpy as np
|
|
import polars as pl
|
|
|
|
from core import audit
|
|
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
|
|
|
|
|
|
# --- send-result timeout: one hung future must not wedge the pass -----------
|
|
def test_flush_sends_treats_timeout_as_a_failed_send(monkeypatch):
|
|
monkeypatch.setattr(notify, "SEND_RESULT_TIMEOUT", 0.05)
|
|
|
|
def _hang(job):
|
|
time.sleep(0.3) # longer than the timeout above
|
|
return None
|
|
monkeypatch.setattr(notify, "_send_push_job", _hang)
|
|
|
|
# Must not raise, and a timed-out send is never reported as a "gone"
|
|
# endpoint to prune (it never actually got a definitive answer).
|
|
gone = notify._flush_sends([object()], [])
|
|
assert gone == []
|
|
|
|
|
|
def test_flush_sends_still_returns_gone_ids_within_the_timeout(monkeypatch):
|
|
monkeypatch.setattr(notify, "SEND_RESULT_TIMEOUT", 5)
|
|
monkeypatch.setattr(notify, "_send_push_job", lambda job: job) # echoes the id straight back
|
|
gone = notify._flush_sends([7, 8], [])
|
|
assert sorted(gone) == [7, 8]
|
|
|
|
|
|
# --- pass wall-clock deadline: one pathological pass can't run unbounded ----
|
|
def _seed_two_cell_subscriptions():
|
|
db.Base.metadata.create_all(db.sync_engine)
|
|
with db.sync_session_maker() as s:
|
|
s.execute(delete(User))
|
|
s.commit()
|
|
uid1, uid2 = uuid.uuid4(), uuid.uuid4()
|
|
s.add(User(id=uid1, email="deadline1@example.com", hashed_password="x", is_active=True))
|
|
s.add(User(id=uid2, email="deadline2@example.com", hashed_password="x", is_active=True))
|
|
s.commit()
|
|
s.add(Subscription(user_id=uid1, cell_id="500_600", label="A", lat=1.0, lon=2.0,
|
|
threshold=95, metrics=["tmax"], kind="observed", two_sided=False))
|
|
s.add(Subscription(user_id=uid2, cell_id="700_800", label="B", lat=3.0, lon=4.0,
|
|
threshold=95, metrics=["tmax"], kind="observed", two_sided=False))
|
|
s.commit()
|
|
return uid1, uid2
|
|
|
|
|
|
def test_run_pass_stops_at_wall_clock_deadline(monkeypatch):
|
|
uid1, uid2 = _seed_two_cell_subscriptions()
|
|
_cached_extreme(monkeypatch) # every cell would otherwise trigger a notification
|
|
monkeypatch.setattr(notify, "PASS_DEADLINE_SECONDS", 0) # trips before the first cell
|
|
|
|
logged = []
|
|
monkeypatch.setattr(audit, "log_activity",
|
|
lambda kind, data: logged.append((kind, data)))
|
|
|
|
notify.run_pass()
|
|
|
|
# A deadline of 0 must skip every cell rather than process any of them.
|
|
assert _user_notifications(uid1) == []
|
|
assert _user_notifications(uid2) == []
|
|
pass_log = next(data for kind, data in logged if kind == "notify.pass")
|
|
assert pass_log["cells_skipped_deadline"] == 2
|
|
|
|
|
|
def test_run_pass_processes_normally_within_the_deadline(monkeypatch):
|
|
uid1, uid2 = _seed_two_cell_subscriptions()
|
|
_cached_extreme(monkeypatch)
|
|
monkeypatch.setattr(notify, "PASS_DEADLINE_SECONDS", 300) # generous — shouldn't trip
|
|
|
|
notify.run_pass()
|
|
|
|
assert len(_user_notifications(uid1)) == 1
|
|
assert len(_user_notifications(uid2)) == 1
|