Files
ss-tools/backend/tests/conftest.py

188 lines
6.7 KiB
Python

# #region Test.Conftest.TestSessionConfig [C:2] [TYPE Module] [SEMANTICS test, conftest, db, postgres, testcontainers]
# @BRIEF Shared pytest fixtures and session configuration for backend tests.
# @RELATION BINDS_TO -> [Core.Database.InitDb]
# @PRE All test modules use sys.path patching to import from src/.
# @POST Database tables exist before test session executes.
# @RATIONALE
# Architecture:
# - Global DATABASE_URL → temp-file SQLite (for modules importing src.core.database)
# - Per-module engines → sqlite:///:memory: (for modules with local create_engine)
# - FK enforcement on ALL engines via PRAGMA foreign_keys=ON
#
# Why:
# - In-process SQLite is 5-10x faster than Docker PostgreSQL
# - FK enforcement catches constraint violations (unlike plain SQLite)
# - Per-module engines prevent 10GB+ shared-cache memory leak
# - Temp file for global engine also prevents memory leak
#
# PostgreSQL mode (TEST_DB=postgres) → testcontainers or explicit DATABASE_URL.
#
# @REJECTED
# sqlite:///file::memory:...?cache=shared — 10GB+ memory leak (all modules share one DB).
# Plain SQLite without FK — silently ignores FK violations, masks bugs.
# Always-on PostgresContainer — 5-10x slower, Docker dependency.
import os
from pathlib import Path
import subprocess
import sys
import tempfile
from sqlalchemy import create_engine, event
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import pytest
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
# ── Global engine: temp-file SQLite (not in-memory, not shared cache) ──
# This prevents the 10GB memory leak from shared in-memory databases.
_TEST_DB_FILE = tempfile.NamedTemporaryFile(suffix="_ss_tools.db", delete=False)
_TEST_DB_PATH = _TEST_DB_FILE.name
_TEST_DB_FILE.close()
os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TEST_DB_PATH}")
os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TEST_DB_PATH}")
# ── FK enforcement on the global engine ──
from src.core.database import engine
if os.environ["DATABASE_URL"].startswith("sqlite"):
event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON"))
# ── Helper: FK-enabled SQLite engine for per-module isolation ──
def make_fk_engine():
"""Create a per-module SQLite in-memory engine with FK enforcement.
Each test module calls this once at module level to get its own
isolated database. No shared state → no memory leak.
"""
eng = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
event.listen(eng, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON"))
return eng
# ── CLI options ──
def pytest_addoption(parser):
parser.addoption(
"--run-integration",
action="store_true",
default=False,
help="Run integration tests (require Docker, Testcontainers, Superset container)",
)
# ── Monkey-patch pathlib.Path.mkdir for /app paths that don't exist in test env ──
# Tests run without /app directory (production path). When a test triggers
# GitService init, _ensure_base_path_exists calls mkdir('/app/storage/...')
# which fails. Intercept silently for /app paths only.
import pathlib as _pathlib
_ORIG_MKDIR = _pathlib.Path.mkdir
def _safe_mkdir(self, mode=0o777, parents=False, exist_ok=False):
p = str(self)
if p.startswith("/app"):
return # /app doesn't exist in test env — silently accept
return _ORIG_MKDIR(self, mode, parents=parents, exist_ok=exist_ok)
_pathlib.Path.mkdir = _safe_mkdir
# ── Test session lifecycle ──
def pytest_configure(config):
database_kind = "PostgreSQL" if os.environ["DATABASE_URL"].startswith("postgres") else "SQLite"
print(f"\n[conftest] {database_kind} (global: {os.environ['DATABASE_URL']})", file=sys.stderr)
if config.getoption("--run-integration"):
print(" Integration tests enabled (--run-integration).\n", file=sys.stderr)
else:
print(
" Integration tests skipped by default. Use --run-integration to enable.\n",
file=sys.stderr,
)
def pytest_collection_modifyitems(config, items):
"""Expose tests under tests/integration to the integration marker.
Integration tests are discovered by directory today, while pytest's
``-m integration`` selector operates on markers. Applying the marker at
collection time keeps both invocation styles equivalent:
``pytest tests/integration --run-integration`` and
``pytest -m integration --run-integration``.
"""
integration_marker = pytest.mark.integration
for item in items:
if "/tests/integration/" in f"/{item.nodeid}":
item.add_marker(integration_marker)
def pytest_unconfigure(config):
try:
from src.core.database import engine as _global_engine
from src.core.database import tasks_engine as _tasks_engine
from src.core.database import auth_engine as _auth_engine
_global_engine.dispose()
_tasks_engine.dispose()
_auth_engine.dispose()
except Exception:
pass
try:
os.unlink(_TEST_DB_PATH)
except Exception:
pass
@pytest.fixture(scope="session", autouse=True)
def ensure_db_tables():
"""Migrate the SQLite test database; integration is migrated by pytest_early."""
if os.environ["DATABASE_URL"].startswith("postgres"):
return
subprocess.run(
[sys.executable, "-m", "alembic", "upgrade", "head"],
cwd=Path(__file__).parent.parent,
env=os.environ.copy(),
check=True,
)
@pytest.fixture(scope="session", autouse=True)
def ensure_git_storage_dirs(ensure_db_tables):
"""Patch StorageConfig default to a writable temp dir for tests.
The global pathlib.Path.mkdir monkey-patch (above) handles /app paths
that don't exist in the test environment. This fixture only patches the
Pydantic default so ConfigManager uses the database record when no config exists.
"""
import os
import tempfile
from src.models.storage import StorageConfig
test_root = tempfile.mkdtemp(prefix="ss_tools_test_storage_")
test_repos = os.path.join(test_root, "repositories")
os.makedirs(test_repos, exist_ok=True)
# Keep the model field internally consistent: root_path is declared with a
# default_factory, and changing only .default breaks FastAPI OpenAPI
# reconstruction with "cannot specify both default and default_factory".
StorageConfig.model_fields["root_path"].default_factory = lambda: test_root
StorageConfig.model_fields["repo_path"].default = "repositories"
print(f"\n[conftest] Storage root_path default={test_root}", file=sys.stderr)
# #endregion Test.Conftest.TestSessionConfig