From e70ad13b10e45f66a03241c3aeb874d0a3156e2e Mon Sep 17 00:00:00 2001 From: Ali Zarinkolah Date: Wed, 19 Aug 2026 15:00:50 +0330 Subject: [PATCH] test(postgres): support multi-session integration tests Why: - Code under test (auth resolution, the two-phase upload) opens more than one session per operation; the existing fixture only exposed one rolled-back session. Changes: - Add a db_sessionmaker fixture sharing one outer transaction. - Pin loop_scope="session" -- without it, a second async test against the session-scoped Postgres container fails with "Event loop is closed." --- tests/integration/postgres/conftest.py | 34 ++++++++++--- tests/integration/postgres/test_migrations.py | 6 ++- tests/support/factories.py | 50 ++++++++++++++++++- 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/tests/integration/postgres/conftest.py b/tests/integration/postgres/conftest.py index 49155d1..e8fb795 100644 --- a/tests/integration/postgres/conftest.py +++ b/tests/integration/postgres/conftest.py @@ -54,7 +54,7 @@ def migrated_postgres_url(postgres_url: str) -> str: return postgres_url -@pytest_asyncio.fixture(scope="session") +@pytest_asyncio.fixture(scope="session", loop_scope="session") async def postgres_engine(migrated_postgres_url: str) -> AsyncIterator[AsyncEngine]: engine = create_engine(_settings_from_url(migrated_postgres_url)) try: @@ -63,18 +63,36 @@ async def postgres_engine(migrated_postgres_url: str) -> AsyncIterator[AsyncEngi await engine.dispose() -@pytest_asyncio.fixture -async def db_session(postgres_engine: AsyncEngine) -> AsyncIterator[AsyncSession]: - """One session per test, bound to a rolled-back outer transaction. +@pytest_asyncio.fixture(loop_scope="session") +async def db_sessionmaker( + postgres_engine: AsyncEngine, +) -> AsyncIterator[async_sessionmaker[AsyncSession]]: + """A session *factory* per test, bound to a rolled-back outer transaction. - Isolates each test's writes (ADR-0016: isolate data per test) without - needing a fresh container or unique keys per test. + Every session it produces shares one connection/outer transaction, so + writes `commit()`ed by one session are visible to the next -- needed for + code under test that opens more than one session per operation (auth + resolution, the ADR-0017 two-phase upload) -- while the whole test's + writes still roll back together at teardown (ADR-0016: isolate data per + test). """ async with postgres_engine.connect() as connection: outer_transaction = await connection.begin() sessionmaker = async_sessionmaker( bind=connection, expire_on_commit=False, join_transaction_mode="create_savepoint" ) - async with sessionmaker() as session: - yield session + yield sessionmaker await outer_transaction.rollback() + + +@pytest_asyncio.fixture(loop_scope="session") +async def db_session( + db_sessionmaker: async_sessionmaker[AsyncSession], +) -> AsyncIterator[AsyncSession]: + """One session per test, bound to a rolled-back outer transaction. + + Isolates each test's writes (ADR-0016: isolate data per test) without + needing a fresh container or unique keys per test. + """ + async with db_sessionmaker() as session: + yield session diff --git a/tests/integration/postgres/test_migrations.py b/tests/integration/postgres/test_migrations.py index 7f605d5..5eea6ab 100644 --- a/tests/integration/postgres/test_migrations.py +++ b/tests/integration/postgres/test_migrations.py @@ -2,7 +2,11 @@ import pytest from sqlalchemy import inspect from sqlalchemy.ext.asyncio import AsyncEngine -pytestmark = [pytest.mark.integration, pytest.mark.postgres, pytest.mark.asyncio] +pytestmark = [ + pytest.mark.integration, + pytest.mark.postgres, + pytest.mark.asyncio(loop_scope="session"), +] EXPECTED_TABLES = { "tenants", diff --git a/tests/support/factories.py b/tests/support/factories.py index e2792ed..d4cad2c 100644 --- a/tests/support/factories.py +++ b/tests/support/factories.py @@ -1 +1,49 @@ -"""Object builders for test fixtures, populated as later phases need them.""" +"""Object builders for test fixtures (ADR-0016). + +Insert rows via a caller-supplied `AsyncSession` without committing — callers +decide their own transaction boundary (the `db_session` fixture rolls back +after each test). +""" + +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.application.auth.keys import generate_api_key, hash_secret +from src.infrastructure.postgres.models.api_key import ApiKey +from src.infrastructure.postgres.models.tenant import Tenant + + +async def create_tenant( + session: AsyncSession, *, slug: str | None = None, status: str = "active" +) -> Tenant: + slug = slug or f"tenant-{uuid.uuid4().hex[:8]}" + tenant = Tenant(id=uuid.uuid4(), slug=slug, name=slug, status=status) + session.add(tenant) + await session.flush() + return tenant + + +async def create_api_key( + session: AsyncSession, + *, + tenant: Tenant, + scopes: list[str] | None = None, + status: str = "active", +) -> tuple[ApiKey, str]: + """Returns `(api_key, full_key)`. `full_key` is the bearer token to send; + only its hash is persisted. + """ + key_prefix, secret, full_key = generate_api_key() + api_key = ApiKey( + id=uuid.uuid4(), + tenant_id=tenant.id, + name="test-key", + key_prefix=key_prefix, + key_hash=hash_secret(secret), + scopes=scopes or ["files:write"], + status=status, + ) + session.add(api_key) + await session.flush() + return api_key, full_key