"""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", "date_of_birth": "1990-01-01", }, ) assert resp.status_code == 201 data = resp.json() assert "access_token" in data assert data["token_type"] == "bearer" def test_register_rejects_under_18(self): """A DOB that computes to under 18 gets a 403 (see test_auth_age_gate for the age-math coverage).""" from datetime import date client = TestClient(app) minor_dob = date.today().replace(year=date.today().year - 14).isoformat() resp = client.post( "/api/auth/register", json={ "username": "kid", "email": "kid@example.com", "password": "secret123", "date_of_birth": minor_dob, }, ) assert resp.status_code == 403 assert "at least 18" 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", "date_of_birth": "1990-01-01", }, ) # Second registration with same username, different email resp = client.post( "/api/auth/register", json={ "username": "duplicate", "email": "dup2@example.com", "password": "secret123", "date_of_birth": "1990-01-01", }, ) 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", "date_of_birth": "1990-01-01", }, ) resp = client.post( "/api/auth/register", json={ "username": "user2", "email": "same@example.com", "password": "secret123", "date_of_birth": "1990-01-01", }, ) assert resp.status_code == 409 assert "Email already registered" in resp.json()["detail"] def test_register_missing_dob(self): """Missing the required date_of_birth field returns 422.""" 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, "date_of_birth": "1990-01-01", }, ) 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