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()).
This commit is contained in:
@@ -84,9 +84,9 @@ path_separator = os
|
|||||||
# output_encoding = utf-8
|
# output_encoding = utf-8
|
||||||
|
|
||||||
# database URL. This is consumed by the user-maintained env.py script only.
|
# 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
|
# Left unset here: env.py falls back to Settings().postgres.dsn (ADR-0009),
|
||||||
# file.
|
# and test fixtures may override it programmatically before invoking Alembic.
|
||||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
# sqlalchemy.url =
|
||||||
|
|
||||||
|
|
||||||
[post_write_hooks]
|
[post_write_hooks]
|
||||||
|
|||||||
@@ -19,10 +19,13 @@ if config.config_file_name is not None:
|
|||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
# Application models' MetaData, used for 'autogenerate' support. The database
|
# Application models' MetaData, used for 'autogenerate' support. The database
|
||||||
# URL is likewise sourced from application settings, not alembic.ini, so both
|
# URL is likewise sourced from application settings by default, so both
|
||||||
# migrations and the app read the same env-derived configuration (ADR-0009).
|
# 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
|
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:
|
def run_migrations_offline() -> None:
|
||||||
|
|||||||
72
tests/integration/postgres/conftest.py
Normal file
72
tests/integration/postgres/conftest.py
Normal file
@@ -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()
|
||||||
23
tests/integration/postgres/test_migrations.py
Normal file
23
tests/integration/postgres/test_migrations.py
Normal file
@@ -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))
|
||||||
Reference in New Issue
Block a user