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."
99 lines
3.7 KiB
Python
99 lines
3.7 KiB
Python
from collections.abc import AsyncIterator, Iterator
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from alembic.command import upgrade
|
|
from alembic.config import Config
|
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
|
from testcontainers.community.postgres import PostgresContainer
|
|
|
|
from src.config import PostgresSettings
|
|
from src.infrastructure.postgres.database import create_engine
|
|
|
|
|
|
def _alembic_config(database_url: str) -> Config:
|
|
config = Config("alembic.ini")
|
|
config.set_main_option("sqlalchemy.url", database_url)
|
|
return config
|
|
|
|
|
|
def _settings_from_url(url: str) -> PostgresSettings:
|
|
# testcontainers returns postgresql+asyncpg://user:pass@host:port/db ;
|
|
# PostgresSettings builds its own dsn from parts, so parse the parts back out.
|
|
without_scheme = url.split("://", 1)[1]
|
|
creds, hostpart = without_scheme.split("@", 1)
|
|
user, password = creds.split(":", 1)
|
|
hostport, db = hostpart.split("/", 1)
|
|
host, port = hostport.split(":", 1)
|
|
return PostgresSettings(host=host, port=int(port), user=user, password=password, db=db)
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def postgres_container() -> Iterator[PostgresContainer]:
|
|
with PostgresContainer("postgres:17", driver="asyncpg") as container:
|
|
yield container
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def postgres_url(postgres_container: PostgresContainer) -> str:
|
|
"""The container's URL, pinned to IPv4.
|
|
|
|
Testcontainers reports the host as `localhost`, which resolves to `::1`
|
|
before `127.0.0.1`. Docker publishes the mapped port on IPv4 only, and the
|
|
IPv6 SYN is dropped rather than refused, so asyncpg blocks on the first
|
|
address until its connect timeout instead of falling back to the second --
|
|
the connection does not fail, it hangs.
|
|
"""
|
|
return postgres_container.get_connection_url().replace("@localhost:", "@127.0.0.1:")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def migrated_postgres_url(postgres_url: str) -> str:
|
|
"""The container's URL, after Alembic has created the schema on it once."""
|
|
upgrade(_alembic_config(postgres_url), "head")
|
|
return postgres_url
|
|
|
|
|
|
@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:
|
|
yield engine
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@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.
|
|
|
|
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"
|
|
)
|
|
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
|