diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a904e6e5..a1a9894b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -38,6 +38,7 @@ dev = [ [tool.pytest.ini_options] markers = [ "no_auto_user: disable the conftest autouse contextvar fixture for this test", + "postgres: requires a Postgres testcontainer (Docker daemon); skipped if unavailable", ] [tool.uv] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a357a396..a04036b0 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -15,6 +15,13 @@ import pytest # Make 'app' and 'deerflow' importable from any working directory sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts")) +# Make 'fixtures.*' importable as plugin modules from this conftest. +sys.path.insert(0, str(Path(__file__).parent)) + +# Register fixture plugin modules so tests can request fixtures by name +# without ad-hoc imports. ``fixtures.postgres`` provides +# ``postgres_container`` (session-scoped) and ``postgres_url`` (per-test). +pytest_plugins = ["fixtures.postgres"] # Break the circular import chain that exists in production code: # deerflow.subagents.__init__ diff --git a/backend/tests/fixtures/__init__.py b/backend/tests/fixtures/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/fixtures/postgres.py b/backend/tests/fixtures/postgres.py new file mode 100644 index 00000000..fe516463 --- /dev/null +++ b/backend/tests/fixtures/postgres.py @@ -0,0 +1,80 @@ +"""Postgres testcontainer fixtures for Stage 0 PR1. + +Provides per-test ephemeral database isolation atop a single session-scoped +container. Tests marked ``@pytest.mark.postgres`` request the ``postgres_url`` +fixture, which yields an asyncpg connection URL pointing at a freshly-created +database. The database is force-dropped after the test (any leaked connections +get pg_terminate_backend'd first). + +Why per-database rather than per-schema: + asyncpg (the SQLAlchemy async driver we use) doesn't honor URL-embedded + search_path the way psycopg does. Per-database isolation is one extra + CREATE/DROP per test (~50ms), but lets test app code use its full schema + unchanged. +""" + +from __future__ import annotations + +import secrets +from collections.abc import Iterator + +import pytest + + +@pytest.fixture(scope="session") +def postgres_container(): + """Session-scoped Postgres 16 container shared across all postgres tests. + + Started once per pytest session. Subsequent tests piggy-back on the same + container; each gets its own database via the ``postgres_url`` fixture. + + Skipped (and the test marked skip) if Docker is unavailable on the host — + testcontainers raises ``DockerException`` when it can't reach the daemon. + """ + try: + from testcontainers.postgres import PostgresContainer + except ImportError as exc: # pragma: no cover - install boundary + pytest.skip(f"testcontainers[postgres] not installed: {exc}") + + try: + with PostgresContainer("postgres:16-alpine") as pg: + yield pg + except Exception as exc: # pragma: no cover - environment-dependent + # DockerException, ConnectionError, etc. — surface a skip rather than + # an error so devs without Docker can still run the rest of the suite. + pytest.skip(f"could not start Postgres container ({exc})") + + +@pytest.fixture +def postgres_url(postgres_container) -> Iterator[str]: + """Per-test ephemeral database URL (asyncpg dialect). + + Each invocation creates a unique database on the shared container and + yields its URL. Teardown force-drops the database, terminating any + backend connections the test forgot to close. + """ + import psycopg + from psycopg import sql + + db_name = f"test_{secrets.token_hex(8)}" + raw = postgres_container.get_connection_url() # postgresql+psycopg2://... + # Strip the SQLAlchemy dialect prefix so plain psycopg can connect. + base = raw.replace("postgresql+psycopg2://", "postgresql://") + parent_url = base.rsplit("/", 1)[0] + "/postgres" + + # CREATE DATABASE must run outside a transaction; psycopg autocommit=True. + with psycopg.connect(parent_url, autocommit=True) as conn: + conn.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(db_name))) + + asyncpg_url = raw.replace("postgresql+psycopg2://", "postgresql+asyncpg://").rsplit("/", 1)[0] + f"/{db_name}" + + try: + yield asyncpg_url + finally: + with psycopg.connect(parent_url, autocommit=True) as conn: + # Kick any leaked connections so DROP DATABASE doesn't block. + conn.execute( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = %s AND pid <> pg_backend_pid()", + (db_name,), + ) + conn.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(db_name)))