"""Integration tests for the auth endpoints (register + login). These tests hit the real FastAPI app against the real configured database (DATABASE_URL -- Postgres via Alembic migrations in CI/dev, not an isolated in-memory DB), so they exercise the full stack: Pydantic validation → DB → JWT → response. Requires migrations already applied (alembic upgrade head) before running. """ import pytest from fastapi.testclient import TestClient from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.pool import NullPool from app.config import settings from app.main import app from app.database import get_db from app.routers.auth import pwd_context # --------------------------------------------------------------------------- # Test fixtures # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def _override_db(): """Override get_db so the app uses a fresh session per test. Deliberately does NOT reuse app.database's shared engine: FastAPI's (sync) TestClient runs each request through anyio's blocking-portal machinery, which can hand different requests different event loops. asyncpg connections are bound to the loop they were created on, so reusing one from the shared engine's connection pool across requests raises "cannot perform operation: another operation is in progress" the moment a test makes more than one request (register-then-login, register-twice, etc.) -- confirmed this is the actual cause, not a bug in the endpoints themselves. NullPool sidesteps it: every checkout is a brand-new connection, never handed to a different loop than the one that opened it. """ test_engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool) test_session_factory = async_sessionmaker(test_engine, expire_on_commit=False) async def _get_db(): async_session = test_session_factory() try: yield async_session await async_session.commit() except Exception: await async_session.rollback() raise finally: await async_session.close() app.dependency_overrides[get_db] = _get_db yield app.dependency_overrides.clear() # --------------------------------------------------------------------------- # Register endpoint # --------------------------------------------------------------------------- class TestRegister: """Tests for POST /api/auth/register.""" def test_register_success(self): """A valid registration returns 201 with a token.""" client = TestClient(app) resp = client.post( "/api/auth/register", json={ "username": "newuser", "email": "new@example.com", "password": "secret123", "over_18": True, }, ) assert resp.status_code == 201 data = resp.json() assert "access_token" in data assert data["token_type"] == "bearer" def test_register_rejects_not_over_18(self): """over_18=false gets a 400 with a detail message. The interim design (2026-08-20) trusts a client-side boolean confirmation instead of collecting/validating an actual date of birth -- this only checks that the flag is respected, not real age math (there's no date to verify against anymore). Real DOB collection/verification may come back in a future pass.""" client = TestClient(app) resp = client.post( "/api/auth/register", json={ "username": "kid", "email": "kid@example.com", "password": "secret123", "over_18": False, }, ) assert resp.status_code == 400 assert "18 or older" in resp.json()["detail"] def test_register_rejects_duplicate_username(self): """Registering the same username twice returns 409.""" client = TestClient(app) # First registration client.post( "/api/auth/register", json={ "username": "duplicate", "email": "dup1@example.com", "password": "secret123", "over_18": True, }, ) # Second registration with same username, different email resp = client.post( "/api/auth/register", json={ "username": "duplicate", "email": "dup2@example.com", "password": "secret123", "over_18": True, }, ) assert resp.status_code == 409 assert "Username already registered" in resp.json()["detail"] def test_register_rejects_duplicate_email(self): """Registering the same email twice returns 409.""" client = TestClient(app) client.post( "/api/auth/register", json={ "username": "user1", "email": "same@example.com", "password": "secret123", "over_18": True, }, ) resp = client.post( "/api/auth/register", json={ "username": "user2", "email": "same@example.com", "password": "secret123", "over_18": True, }, ) assert resp.status_code == 409 assert "Email already registered" in resp.json()["detail"] def test_register_missing_over_18(self): """Missing the required over_18 field returns 422. Replaces the old test_register_invalid_date_format -- there's no date format to get wrong anymore (no date field at all), so the closest equivalent "malformed request" case is the required over_18 boolean being absent entirely.""" client = TestClient(app) resp = client.post( "/api/auth/register", json={ "username": "user", "email": "user@example.com", "password": "secret123", }, ) assert resp.status_code == 422 # --------------------------------------------------------------------------- # Login endpoint # --------------------------------------------------------------------------- class TestLogin: """Tests for POST /api/auth/login.""" def _create_user(self, username: str, password: str, email: str = None): """Helper to create a user via register.""" client = TestClient(app) if email is None: email = f"{username}@example.com" resp = client.post( "/api/auth/register", json={ "username": username, "email": email, "password": password, "over_18": True, }, ) assert resp.status_code == 201 return resp.json()["access_token"] def test_login_success(self): """Correct credentials return a token.""" self._create_user("loginuser", "correctpass") client = TestClient(app) resp = client.post( "/api/auth/login", data={"username": "loginuser", "password": "correctpass"}, ) assert resp.status_code == 200 data = resp.json() assert "access_token" in data assert data["token_type"] == "bearer" def test_login_wrong_password(self): """Wrong password returns 401.""" # Distinct username from test_login_success -- there's no per-test # DB rollback/isolation here (real Postgres via the app's own # dependency-overridden session), so reusing "loginuser" collided # with the user that test already created (409 on register instead # of the intended 401-on-login assertion this test is actually for). self._create_user("loginuser_wrongpass", "correctpass") client = TestClient(app) resp = client.post( "/api/auth/login", data={"username": "loginuser_wrongpass", "password": "wrongpass"}, ) assert resp.status_code == 401 assert "Incorrect username or password" in resp.json()["detail"] def test_login_nonexistent_user(self): """Non-existent user returns 401 (not 404).""" client = TestClient(app) resp = client.post( "/api/auth/login", data={"username": "nobody", "password": "any"}, ) assert resp.status_code == 401 assert "Incorrect username or password" in resp.json()["detail"] def test_login_accepts_form_encoded(self): """Login accepts application/x-www-form-urlencoded (OAuth2).""" self._create_user("formuser", "formpass") client = TestClient(app) resp = client.post( "/api/auth/login", data={"username": "formuser", "password": "formpass"}, ) assert resp.status_code == 200 assert "access_token" in resp.json() def test_login_token_is_valid_jwt(self): """The returned token is a valid JWT that can be decoded.""" self._create_user("jwttoken", "jwt123") client = TestClient(app) resp = client.post( "/api/auth/login", data={"username": "jwttoken", "password": "jwt123"}, ) token = resp.json()["access_token"] # Verify it's a 3-part JWT parts = token.split(".") assert len(parts) == 3