thermograph/backend/tests/accounts/test_api_accounts.py

167 lines
6.6 KiB
Python
Raw Normal View History

"""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
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
from web import app as appmod
from accounts 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):
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
from notifications 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):
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
from notifications 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.
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
from notifications 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}