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."
This commit is contained in:
@@ -54,7 +54,7 @@ def migrated_postgres_url(postgres_url: str) -> str:
|
|||||||
return postgres_url
|
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]:
|
async def postgres_engine(migrated_postgres_url: str) -> AsyncIterator[AsyncEngine]:
|
||||||
engine = create_engine(_settings_from_url(migrated_postgres_url))
|
engine = create_engine(_settings_from_url(migrated_postgres_url))
|
||||||
try:
|
try:
|
||||||
@@ -63,18 +63,36 @@ async def postgres_engine(migrated_postgres_url: str) -> AsyncIterator[AsyncEngi
|
|||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture(loop_scope="session")
|
||||||
async def db_session(postgres_engine: AsyncEngine) -> AsyncIterator[AsyncSession]:
|
async def db_sessionmaker(
|
||||||
"""One session per test, bound to a rolled-back outer transaction.
|
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
|
Every session it produces shares one connection/outer transaction, so
|
||||||
needing a fresh container or unique keys per test.
|
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:
|
async with postgres_engine.connect() as connection:
|
||||||
outer_transaction = await connection.begin()
|
outer_transaction = await connection.begin()
|
||||||
sessionmaker = async_sessionmaker(
|
sessionmaker = async_sessionmaker(
|
||||||
bind=connection, expire_on_commit=False, join_transaction_mode="create_savepoint"
|
bind=connection, expire_on_commit=False, join_transaction_mode="create_savepoint"
|
||||||
)
|
)
|
||||||
async with sessionmaker() as session:
|
yield sessionmaker
|
||||||
yield session
|
|
||||||
await outer_transaction.rollback()
|
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
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import pytest
|
|||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
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 = {
|
EXPECTED_TABLES = {
|
||||||
"tenants",
|
"tenants",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user