Files
chatbot_v3/tests/integration/postgres/conftest.py
Ali Zarinkolah e70ad13b10 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."
2026-08-19 15:00:50 +03:30

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