diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdcc835..09cc77c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,22 @@ jobs: defaults: run: working-directory: backend + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: trainable_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + TEST_DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/trainable_test steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..4abf6a6 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,18 @@ +[pytest] +# Explicit + pinned pytest-asyncio config (see conftest.py for fixture setup). +# +# asyncio_default_fixture_loop_scope / asyncio_default_test_loop_scope = +# "session" gives the whole test session ONE event loop instead of a fresh +# one per test (the pytest-asyncio 1.x default is "function"). This matters +# because db.py's `engine` (the SQLAlchemy async engine / connection pool) +# is created once at import time. Against SQLite/aiosqlite that mismatch is +# silently tolerated, but asyncpg's connections are strictly bound to the +# event loop that created them — reusing the pooled engine from a second, +# different per-test event loop raises +# "got Future attached to a different loop". Pinning both +# scopes to "session" keeps the engine and every test on the same loop, so +# the suite runs unchanged against Postgres in CI (see +# .github/workflows/ci.yml) as well as the SQLite default. +asyncio_mode = strict +asyncio_default_fixture_loop_scope = session +asyncio_default_test_loop_scope = session diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f3e953e..b417c64 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -22,8 +22,21 @@ def pytest_configure(config): ) -# Use in-memory SQLite for tests (no Postgres needed) -os.environ["DATABASE_URL"] = "sqlite+aiosqlite://" +# Use in-memory SQLite for tests by default (no Postgres needed). CI's +# backend-test job runs a Postgres service container and sets +# TEST_DATABASE_URL so the suite exercises real Postgres semantics — +# Column(JSON) storage, FK ON DELETE CASCADE, and the hand-rolled +# db._run_migrations — instead of only ever validating against an engine +# we don't ship. See .github/workflows/ci.yml. +# +# Only the explicit TEST_DATABASE_URL opt-in is honored: an ambient +# DATABASE_URL (e.g. pointing at a dev database in a developer's shell) is +# deliberately overwritten, because the setup_db fixture drops all tables +# after every test. +if os.environ.get("TEST_DATABASE_URL"): + os.environ["DATABASE_URL"] = os.environ["TEST_DATABASE_URL"] +else: + os.environ["DATABASE_URL"] = "sqlite+aiosqlite://" # Mock claude_agent_sdk if it's not installed (it's a private package) if "claude_agent_sdk" not in sys.modules: diff --git a/backend/tests/test_conftest_db_url.py b/backend/tests/test_conftest_db_url.py new file mode 100644 index 0000000..4bed76a --- /dev/null +++ b/backend/tests/test_conftest_db_url.py @@ -0,0 +1,18 @@ +"""Regression test for conftest.py's DATABASE_URL handling. + +The setup_db fixture drops all tables after every test, so the suite must +never run against an ambient DATABASE_URL from a developer's shell (which +could point at a real dev/prod database). conftest.py must force in-memory +SQLite unless the explicit TEST_DATABASE_URL opt-in is set (as CI does for +its Postgres service container). +""" + +import os + + +def test_database_url_is_test_url_or_sqlite(): + """After conftest import, DATABASE_URL is either the explicit + TEST_DATABASE_URL opt-in or the in-memory SQLite default — never an + ambient value inherited from the shell.""" + expected = os.environ.get("TEST_DATABASE_URL") or "sqlite+aiosqlite://" + assert os.environ["DATABASE_URL"] == expected