diff --git a/backend/tests/test_auth_endpoints.py b/backend/tests/test_auth_endpoints.py index cd0d463..127494a 100644 --- a/backend/tests/test_auth_endpoints.py +++ b/backend/tests/test_auth_endpoints.py @@ -1,14 +1,20 @@ """Integration tests for the auth endpoints (register + login). -These tests hit the real FastAPI app with an in-memory SQLite database, -so they exercise the full stack: Pydantic validation → DB → JWT → response. +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, override_get_db +from app.database import get_db from app.routers.auth import pwd_context @@ -18,14 +24,25 @@ from app.routers.auth import pwd_context @pytest.fixture(autouse=True) def _override_db(): - """Use an in-memory SQLite DB for every test.""" - from app.database import engine, Base, async_session_factory + """Override get_db so the app uses a fresh session per test. - Base.metadata.create_all(bind=engine) - from app.database import SessionLocal + 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 = async_session_factory() + async_session = test_session_factory() try: yield async_session await async_session.commit() @@ -197,11 +214,16 @@ class TestLogin: def test_login_wrong_password(self): """Wrong password returns 401.""" - self._create_user("loginuser", "correctpass") + # 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", "password": "wrongpass"}, + data={"username": "loginuser_wrongpass", "password": "wrongpass"}, ) assert resp.status_code == 401 assert "Incorrect username or password" in resp.json()["detail"]