Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions backend/pytest.ini
Original file line number Diff line number Diff line change
@@ -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 <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
17 changes: 15 additions & 2 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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://"
Comment thread
greptile-apps[bot] marked this conversation as resolved.

# Mock claude_agent_sdk if it's not installed (it's a private package)
if "claude_agent_sdk" not in sys.modules:
Expand Down
18 changes: 18 additions & 0 deletions backend/tests/test_conftest_db_url.py
Original file line number Diff line number Diff line change
@@ -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