"""Route-level tests for the accounts feature: auth (fastapi-users), subscription CRUD, and the notifications endpoints. Uses a throwaway accounts DB (conftest points THERMOGRAPH_ACCOUNTS_DB at a temp file) so nothing is written into the repo.""" import pytest from fastapi.testclient import TestClient import app as appmod import db V2 = "/thermograph/api/v2" PW = "supersecret123" @pytest.fixture(scope="module", autouse=True) def _tables(): # TestClient(app) (no context manager) skips the lifespan, so create the # account tables directly against the temp DB. db.Base.metadata.create_all(db.sync_engine) def _client(): return TestClient(appmod.app) def _login(client, email): r = client.post(f"{V2}/auth/register", json={"email": email, "password": PW}) assert r.status_code in (201, 400) # 400 only if the email already exists r = client.post(f"{V2}/auth/login", data={"username": email, "password": PW}) assert r.status_code == 204 # sets the session cookie on the client def test_auth_flow(): c = _client() _login(c, "auth-flow@example.com") me = c.get(f"{V2}/users/me") assert me.status_code == 200 and me.json()["email"] == "auth-flow@example.com" assert c.post(f"{V2}/auth/logout").status_code == 204 assert c.get(f"{V2}/users/me").status_code == 401 def test_requires_auth(): c = _client() assert c.get(f"{V2}/subscriptions").status_code == 401 assert c.get(f"{V2}/notifications").status_code == 401 def test_subscription_crud(): c = _client() _login(c, "crud@example.com") r = c.post(f"{V2}/subscriptions", json={ "lat": 47.6, "lon": -122.3, "label": "Seattle", "threshold": 96, "metrics": ["tmax", "feels"], "kind": "observed", "two_sided": True, }) assert r.status_code == 201 sub = r.json() assert sub["cell_id"] and sub["threshold"] == 96 and sub["metrics"] == ["tmax", "feels"] assert len(c.get(f"{V2}/subscriptions").json()) == 1 patched = c.patch(f"{V2}/subscriptions/{sub['id']}", json={"threshold": 99, "active": False}) assert patched.status_code == 200 assert patched.json()["threshold"] == 99 and patched.json()["active"] is False assert c.delete(f"{V2}/subscriptions/{sub['id']}").status_code == 204 assert c.get(f"{V2}/subscriptions").json() == [] def test_duplicate_and_validation(): c = _client() _login(c, "dup@example.com") body = {"lat": 33.4, "lon": -112.0, "threshold": 97, "metrics": ["tmax"], "kind": "observed"} assert c.post(f"{V2}/subscriptions", json=body).status_code == 201 assert c.post(f"{V2}/subscriptions", json=body).status_code == 409 # same cell + kind # a forecast subscription on the same cell is allowed (different kind) assert c.post(f"{V2}/subscriptions", json={**body, "kind": "forecast"}).status_code == 201 # threshold must be 95..99 assert c.post(f"{V2}/subscriptions", json={**body, "threshold": 90}).status_code == 422 # at least one metric required assert c.post(f"{V2}/subscriptions", json={**body, "metrics": []}).status_code == 422 def test_ownership_is_404(): a, b = _client(), _client() _login(a, "owner-a@example.com") _login(b, "owner-b@example.com") sub_id = a.post(f"{V2}/subscriptions", json={ "lat": 40.7, "lon": -74.0, "threshold": 95, "metrics": ["tmax"], "kind": "observed", }).json()["id"] # B cannot see, patch, or delete A's subscription — 404, not 403 (no existence leak) assert b.patch(f"{V2}/subscriptions/{sub_id}", json={"threshold": 98}).status_code == 404 assert b.delete(f"{V2}/subscriptions/{sub_id}").status_code == 404 assert b.get(f"{V2}/subscriptions").json() == [] def test_notifications_empty_and_read_all(): c = _client() _login(c, "notif@example.com") r = c.get(f"{V2}/notifications") assert r.status_code == 200 assert r.json() == {"notifications": [], "unread_count": 0} assert c.post(f"{V2}/notifications/read-all").status_code == 204 # --- web push --------------------------------------------------------------- _SUB = {"endpoint": "https://push.example.com/ep-1", "keys": {"p256dh": "BKEY", "auth": "YXV0aA"}} def test_push_vapid_key_is_open(): # The public key is not a secret and the browser needs it before login, so # this route is unauthenticated. r = _client().get(f"{V2}/push/vapid-key") assert r.status_code == 200 assert isinstance(r.json()["key"], str) and r.json()["key"] def test_push_requires_auth(): c = _client() assert c.post(f"{V2}/push/subscribe", json=_SUB).status_code == 401 assert c.post(f"{V2}/push/test").status_code == 401 def test_push_subscribe_upsert_test_unsubscribe(monkeypatch): import push sent = [] monkeypatch.setattr(push, "send", lambda info, payload: sent.append(info["endpoint"]) or "ok") c = _client() _login(c, "push@example.com") assert c.post(f"{V2}/push/subscribe", json=_SUB).status_code == 201 # Re-subscribe from the same device (same endpoint, new keys) upserts — one device. assert c.post(f"{V2}/push/subscribe", json={**_SUB, "keys": {"p256dh": "BNEW", "auth": "bmV3"}}).status_code == 201 r = c.post(f"{V2}/push/test") assert r.status_code == 202 assert r.json() == {"devices": 1, "sent": 1, "pruned": 0, "failed": 0} assert sent == [_SUB["endpoint"]] assert c.request("DELETE", f"{V2}/push/subscribe", json={"endpoint": _SUB["endpoint"]}).status_code == 204 assert c.post(f"{V2}/push/test").json()["devices"] == 0 def test_push_test_prunes_gone_endpoint(monkeypatch): import push monkeypatch.setattr(push, "send", lambda info, payload: "gone") c = _client() _login(c, "push-gone@example.com") assert c.post(f"{V2}/push/subscribe", json=_SUB).status_code == 201 r = c.post(f"{V2}/push/test") assert r.json() == {"devices": 1, "sent": 0, "pruned": 1, "failed": 0} # the gone endpoint was removed, so a second pass sees no devices assert c.post(f"{V2}/push/test").json()["devices"] == 0 def test_push_test_reports_failed_delivery(monkeypatch): # A rejected delivery (e.g. VAPID mismatch) must be reported, not silently dropped: # the endpoint keeps returning 202 but with sent:0 / failed:N so the client can tell. import push monkeypatch.setattr(push, "send", lambda info, payload: "error") c = _client() _login(c, "push-fail@example.com") assert c.post(f"{V2}/push/subscribe", json=_SUB).status_code == 201 r = c.post(f"{V2}/push/test") assert r.status_code == 202 assert r.json() == {"devices": 1, "sent": 0, "pruned": 0, "failed": 1}