"""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