diff --git a/db.py b/db.py index 660fcc7..8f6390c 100644 --- a/db.py +++ b/db.py @@ -23,7 +23,9 @@ from sqlalchemy import create_engine, event from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine 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.makedirs(os.path.dirname(DB_PATH), exist_ok=True) diff --git a/tests/conftest.py b/tests/conftest.py index 83c5412..01acaae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,11 @@ places._load_started = True _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 # Keep derived-store writes out of the repo's data/ folder. Individual store diff --git a/tests/test_api_accounts.py b/tests/test_api_accounts.py new file mode 100644 index 0000000..69ade16 --- /dev/null +++ b/tests/test_api_accounts.py @@ -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