Testcontainers reports the container host as `localhost`, which resolves to ::1 before 127.0.0.1 on this machine. Docker publishes the mapped port on IPv4 only, and the IPv6 SYN is dropped rather than refused, so asyncpg blocked on the first address until its connect timeout instead of falling back to the second -- the migration fixture hung rather than failing. 127.0.0.1 connects in 0.07s where localhost timed out at 15s; the integration suite now runs in 3.6s, inside the 10s pytest-timeout.
81 lines
3.0 KiB
Python
81 lines
3.0 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")
|
|
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
|
|
async def db_session(postgres_engine: AsyncEngine) -> 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 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
|
|
await outer_transaction.rollback()
|