Fix mobile alerts dropdown overflow; add account API tests (#93)

The notification dropdown is anchored to the bell button, which on mobile sits
left of the account button rather than at the screen edge, so the wide panel
overflowed off the left of the viewport (title/text cut off). On narrow screens,
drop .notif's positioning context so the dropdown anchors to the .acct cluster at
the header's right edge, keeping it fully on-screen.

Also add route-level tests for the accounts feature (auth flow, subscription CRUD,
duplicate/validation, ownership 404s, notifications), plus a
THERMOGRAPH_ACCOUNTS_DB override so the suite writes to a throwaway DB and stays
hermetic.
This commit is contained in:
Emi Griffith 2026-07-15 15:00:47 -07:00 committed by GitHub
parent d58b732480
commit d426fadad1
3 changed files with 109 additions and 1 deletions

4
db.py
View file

@ -23,7 +23,9 @@ from sqlalchemy import create_engine, event
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker from sqlalchemy.orm import DeclarativeBase, sessionmaker
DB_PATH = os.path.abspath( # Default to data/accounts.sqlite; THERMOGRAPH_ACCOUNTS_DB overrides it (tests point
# this at a throwaway temp file to stay hermetic).
DB_PATH = os.environ.get("THERMOGRAPH_ACCOUNTS_DB") or os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "data", "accounts.sqlite") os.path.join(os.path.dirname(__file__), "..", "data", "accounts.sqlite")
) )
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)

View file

@ -24,6 +24,11 @@ places._load_started = True
_TMP = tempfile.mkdtemp(prefix="thermograph-tests-") _TMP = tempfile.mkdtemp(prefix="thermograph-tests-")
# Keep the authoritative accounts DB out of the repo's data/ folder, and don't
# start the background notifier thread during tests. Set before db is imported.
os.environ.setdefault("THERMOGRAPH_ACCOUNTS_DB", os.path.join(_TMP, "accounts.sqlite"))
os.environ.setdefault("THERMOGRAPH_ENABLE_NOTIFIER", "0")
import store # noqa: E402 import store # noqa: E402
# Keep derived-store writes out of the repo's data/ folder. Individual store # Keep derived-store writes out of the repo's data/ folder. Individual store

101
tests/test_api_accounts.py Normal file
View file

@ -0,0 +1,101 @@
"""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