Skip to content
This repository was archived by the owner on Jun 4, 2026. It is now read-only.

Commit 3d52b5a

Browse files
committed
fix(tests): resolve loop issues with per-test engine and clean up typing (formatted)
1 parent 1444f9a commit 3d52b5a

4 files changed

Lines changed: 49 additions & 78 deletions

File tree

‎backend/app/core/limiter.py‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import functools
22
import ipaddress
3-
import os
43

54
import structlog
65
from fastapi import Request
@@ -53,6 +52,4 @@ def _get_user_or_ip(request: Request) -> str:
5352
return get_trusted_client_ip(request) or "unknown"
5453

5554

56-
# In CI/Test environments, we disable the limiter entirely to prevent 429 errors.
57-
_enabled = not (os.getenv("CI") or os.getenv("PYTEST_CURRENT_TEST"))
58-
limiter = Limiter(key_func=_get_user_or_ip, enabled=_enabled)
55+
limiter = Limiter(key_func=_get_user_or_ip)

‎backend/app/db/session.py‎

Lines changed: 6 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,23 @@
11
from collections.abc import AsyncGenerator, AsyncIterator
22
from contextlib import asynccontextmanager
3-
from typing import Any
43

5-
from sqlalchemy.ext.asyncio import (
6-
AsyncEngine,
7-
AsyncSession,
8-
async_sessionmaker,
9-
create_async_engine,
10-
)
4+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
115

126
from app.core.config import settings
137

14-
# Private globals for lazy initialization
15-
_engine: AsyncEngine | None = None
16-
_sessionmaker: async_sessionmaker[AsyncSession] | None = None
17-
18-
19-
def _get_engine() -> AsyncEngine:
20-
"""Internal helper to get or create the engine. Facilitates testing."""
21-
global _engine
22-
if _engine is None:
23-
_engine = create_async_engine(settings.database_url, echo=False)
24-
return _engine
25-
26-
27-
def _get_sessionmaker() -> async_sessionmaker[AsyncSession]:
28-
"""Internal helper to get or create the sessionmaker. Facilitates testing."""
29-
global _sessionmaker
30-
if _sessionmaker is None:
31-
_sessionmaker = async_sessionmaker(_get_engine(), expire_on_commit=False)
32-
return _sessionmaker
33-
34-
35-
def __getattr__(name: str) -> Any:
36-
"""Module-level getattr to support legacy engine and session access."""
37-
if name == "engine":
38-
return _get_engine()
39-
if name == "AsyncSessionLocal":
40-
return _get_sessionmaker()
41-
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
8+
engine = create_async_engine(settings.database_url, echo=False)
9+
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
4210

4311

4412
async def get_db() -> AsyncGenerator[AsyncSession]:
45-
session_factory = _get_sessionmaker()
46-
async with session_factory() as session:
13+
async with AsyncSessionLocal() as session:
4714
yield session
4815

4916

5017
@asynccontextmanager
5118
async def isolated_session() -> AsyncIterator[AsyncSession]:
52-
"""Isolated session manager for background tasks."""
53-
session_factory = _get_sessionmaker()
54-
async with session_factory() as session:
19+
"""Context manager for a one-off DB session."""
20+
async with AsyncSessionLocal() as session:
5521
try:
5622
yield session
5723
await session.commit()

‎backend/app/worker.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ async def cleanup_old_executions(ctx: dict) -> None:
283283
await db.commit()
284284
logger.info(
285285
"job_executions_cleanup",
286-
deleted=result.rowcount,
286+
deleted=result.rowcount, # type: ignore[attr-defined]
287287
retention_days=settings.cron_execution_retention_days,
288288
)
289289

@@ -299,7 +299,7 @@ async def cleanup_old_deliveries(ctx: dict) -> None:
299299
await db.commit()
300300
logger.info(
301301
"webhook_deliveries_cleanup",
302-
deleted=result.rowcount,
302+
deleted=result.rowcount, # type: ignore[attr-defined]
303303
retention_days=retention,
304304
)
305305

‎backend/tests/conftest.py‎

Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,33 @@
66
import pytest
77
from sqlalchemy.pool import NullPool
88

9-
# --- GLOBAL MOCKS ---
9+
# --- GLOBAL INFRA MOCKS ---
1010

11-
patch("slowapi.Limiter.limit", lambda *args, **kw: lambda f: f).start()
1211
patch("app.infra.qdrant.get_qdrant_client", AsyncMock()).start()
1312
patch("app.infra.minio.get_minio_client", MagicMock()).start()
1413
patch("redis.asyncio.Redis.from_url", return_value=AsyncMock()).start()
1514
patch("arq.create_pool", return_value=AsyncMock()).start()
1615
patch("app.scheduler.runner.start_scheduler", AsyncMock()).start()
1716
patch("app.scheduler.runner.stop_scheduler", AsyncMock()).start()
1817

19-
# --- APP AND DB FIXTURES ---
18+
# --- APP FIXTURES ---
2019

20+
from app.core.limiter import limiter
2121
from app.db.session import get_db
2222
from app.main import app
2323

24-
# Disable lifespan
24+
# Disable app lifespan
2525
app.router.lifespan_context = MagicMock()
2626

2727

28+
@pytest.fixture(autouse=True)
29+
def disable_rate_limiting():
30+
"""测试期间禁用频率限制。"""
31+
limiter.enabled = False
32+
yield
33+
limiter.enabled = True
34+
35+
2836
@pytest.fixture(scope="session")
2937
def anyio_backend():
3038
return "asyncio"
@@ -56,41 +64,41 @@ def setup_tables():
5664
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
5765

5866

59-
@pytest.fixture(scope="session")
60-
async def engine(setup_tables):
67+
@pytest.fixture
68+
async def db_session(setup_tables):
69+
"""FRESH engine per test to eliminate loop issues."""
6170
try:
6271
_pw = os.environ["POSTGRES_PASSWORD"]
6372
except KeyError:
6473
raise RuntimeError("POSTGRES_PASSWORD is required") from None
6574

6675
test_url = f"postgresql+asyncpg://jarvis:{_pw}@localhost:5432/jarvis_test"
6776
engine = create_async_engine(test_url, echo=False, poolclass=NullPool)
68-
yield engine
69-
await engine.dispose()
70-
71-
72-
@pytest.fixture
73-
async def db_session(engine):
74-
async with engine.connect() as conn:
75-
await conn.begin()
76-
session = AsyncSession(
77-
bind=conn, expire_on_commit=False, join_transaction_mode="create_savepoint"
78-
)
79-
80-
m_iso = MagicMock()
81-
m_iso.__aenter__ = AsyncMock(return_value=session)
82-
m_iso.__aexit__ = AsyncMock(return_value=None)
83-
84-
# The key: Inject the CURRENT loop's session into the lazy getters
85-
with (
86-
patch("app.db.session._get_sessionmaker", return_value=lambda: session),
87-
patch("app.db.session.isolated_session", return_value=m_iso),
88-
):
89-
try:
90-
yield session
91-
finally:
92-
await session.close()
93-
await conn.rollback()
77+
try:
78+
async with engine.connect() as conn:
79+
await conn.begin()
80+
session = AsyncSession(
81+
bind=conn,
82+
expire_on_commit=False,
83+
join_transaction_mode="create_savepoint",
84+
)
85+
86+
m_iso = MagicMock()
87+
m_iso.__aenter__ = AsyncMock(return_value=session)
88+
m_iso.__aexit__ = AsyncMock(return_value=None)
89+
90+
# Inject this session into all possible targets
91+
with (
92+
patch("app.db.session.AsyncSessionLocal", return_value=session),
93+
patch("app.db.session.isolated_session", return_value=m_iso),
94+
):
95+
try:
96+
yield session
97+
finally:
98+
await session.close()
99+
await conn.rollback()
100+
finally:
101+
await engine.dispose()
94102

95103

96104
@pytest.fixture

0 commit comments

Comments
 (0)