diff --git a/src/sandcastle/models/db.py b/src/sandcastle/models/db.py index d541bfb..3e2e822 100644 --- a/src/sandcastle/models/db.py +++ b/src/sandcastle/models/db.py @@ -875,6 +875,23 @@ def _build_engine_url() -> str: return f"sqlite+aiosqlite:///{data_path}/sandcastle.db" +def _is_in_memory_sqlite(url: str) -> bool: + """Detect SQLite URLs that resolve to an in-memory database. + + Three shapes count as in-memory: + - ``sqlite+aiosqlite://`` (no path - used by the test suite via + DATABASE_URL env var) + - ``sqlite+aiosqlite:///:memory:`` + - ``sqlite:///:memory:`` + """ + if not url.startswith("sqlite"): + return False + if ":memory:" in url: + return True + # "sqlite+aiosqlite://" with nothing after the scheme separator + return url.rstrip("/").endswith(":") + + def _build_engine_kwargs(url: str | None = None) -> dict: """Build engine kwargs based on database type.""" if url is None: @@ -882,6 +899,15 @@ def _build_engine_kwargs(url: str | None = None) -> dict: kwargs: dict = {"echo": False} if url.startswith("sqlite"): kwargs["connect_args"] = {"check_same_thread": False, "timeout": 30} + if _is_in_memory_sqlite(url): + # In-memory SQLite needs StaticPool: every session must share + # the same connection, otherwise each new aiosqlite connection + # sees an empty in-memory database. Without this, the test + # suite hits "no such table" / "data not visible" race + # conditions that masquerade as test pollution. + from sqlalchemy.pool import StaticPool + + kwargs["poolclass"] = StaticPool else: # PostgreSQL pool tuning for production multi-tenant workloads kwargs["pool_size"] = 20 diff --git a/tests/conftest.py b/tests/conftest.py index bdf63c7..61de0b5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,16 +13,57 @@ import pytest # noqa: E402 +def _run_async(coro): + """Run a coroutine on a fresh event loop. + + Used by autouse session/module fixtures that run outside any + pytest-asyncio test - they must not borrow the asyncio.get_event_loop() + that pytest-asyncio uses for individual tests. + """ + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + @pytest.fixture(scope="session", autouse=True) def _create_test_tables(): """Create all DB tables in the in-memory SQLite database.""" from sandcastle.models.db import Base, engine - loop = asyncio.new_event_loop() - async def _create(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) - loop.run_until_complete(_create()) - loop.close() + _run_async(_create()) + + +@pytest.fixture(autouse=True) +def _reset_module_globals(): + """Clear in-memory module caches between tests. + + Several routes.py module-level dicts (_hub_cache, _batch_store, + _stats_cache) plus the rate-limiter window store accumulate state + that bleeds across unrelated tests. Cheap to reset (microseconds), + eliminates a whole class of order-dependent failures. + """ + try: + from sandcastle.api import rate_limit as _rl + from sandcastle.api import routes as _routes + + for name in ("_hub_cache", "_batch_store", "_stats_cache"): + cache = getattr(_routes, name, None) + if isinstance(cache, dict): + cache.clear() + + backend = getattr(_rl.execution_limiter, "_backend", None) + windows = getattr(backend, "_windows", None) + if hasattr(windows, "clear"): + windows.clear() + if hasattr(backend, "_call_count"): + backend._call_count = 0 + except Exception: + # Never let cleanup itself break tests + pass + yield