From 80ed5b1577ed25a7b52f5096a9548833bb3d815e Mon Sep 17 00:00:00 2001 From: Ali Zarinkolah Date: Tue, 18 Aug 2026 10:21:56 +0330 Subject: [PATCH] test(postgres): allow Testcontainers to override the migration sqlalchemy.url alembic/env.py previously always overwrote sqlalchemy.url from Settings().postgres.dsn, which made it impossible for a test fixture to point Alembic at a Testcontainers-managed database. Now env.py only sets it when unset, and a new integration suite runs `alembic upgrade head` against a real Postgres container per ADR-0016 (no create_all()). --- alembic.ini | 6 +- alembic/env.py | 9 ++- tests/integration/postgres/conftest.py | 72 +++++++++++++++++++ tests/integration/postgres/test_migrations.py | 23 ++++++ 4 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 tests/integration/postgres/conftest.py create mode 100644 tests/integration/postgres/test_migrations.py diff --git a/alembic.ini b/alembic.ini index df80d65..b38d05a 100644 --- a/alembic.ini +++ b/alembic.ini @@ -84,9 +84,9 @@ path_separator = os # output_encoding = utf-8 # database URL. This is consumed by the user-maintained env.py script only. -# other means of configuring database URLs may be customized within the env.py -# file. -sqlalchemy.url = driver://user:pass@localhost/dbname +# Left unset here: env.py falls back to Settings().postgres.dsn (ADR-0009), +# and test fixtures may override it programmatically before invoking Alembic. +# sqlalchemy.url = [post_write_hooks] diff --git a/alembic/env.py b/alembic/env.py index ad9a7c9..77ca97e 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -19,10 +19,13 @@ if config.config_file_name is not None: fileConfig(config.config_file_name) # Application models' MetaData, used for 'autogenerate' support. The database -# URL is likewise sourced from application settings, not alembic.ini, so both -# migrations and the app read the same env-derived configuration (ADR-0009). +# URL is likewise sourced from application settings by default, so both +# migrations and the app read the same env-derived configuration (ADR-0009) — +# unless a caller (e.g. a test fixture pointing at a Testcontainers database) +# has already set sqlalchemy.url on this Config before invoking Alembic. target_metadata = Base.metadata -config.set_main_option("sqlalchemy.url", Settings().postgres.dsn) +if not config.get_main_option("sqlalchemy.url"): + config.set_main_option("sqlalchemy.url", Settings().postgres.dsn) def run_migrations_offline() -> None: diff --git a/tests/integration/postgres/conftest.py b/tests/integration/postgres/conftest.py new file mode 100644 index 0000000..850260f --- /dev/null +++ b/tests/integration/postgres/conftest.py @@ -0,0 +1,72 @@ +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: + return postgres_container.get_connection_url() + + +@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() diff --git a/tests/integration/postgres/test_migrations.py b/tests/integration/postgres/test_migrations.py new file mode 100644 index 0000000..7f605d5 --- /dev/null +++ b/tests/integration/postgres/test_migrations.py @@ -0,0 +1,23 @@ +import pytest +from sqlalchemy import inspect +from sqlalchemy.ext.asyncio import AsyncEngine + +pytestmark = [pytest.mark.integration, pytest.mark.postgres, pytest.mark.asyncio] + +EXPECTED_TABLES = { + "tenants", + "api_keys", + "source_files", + "ingestion_jobs", + "ingestion_job_events", + "alembic_version", +} + + +async def test_migrations_create_schema_from_empty_database(postgres_engine: AsyncEngine) -> None: + async with postgres_engine.connect() as connection: + table_names = await connection.run_sync( + lambda sync_conn: inspect(sync_conn).get_table_names() + ) + + assert EXPECTED_TABLES.issubset(set(table_names))