"""pytest 夹具:统一连接 PostgreSQL 测试库(ADR-024,不用 SQLite 替代)。""" import os import pytest from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from app import models # noqa: F401 注册模型 from app.db.base import Base TEST_DATABASE_URL = os.environ.get( "TEST_DATABASE_URL", "postgresql+asyncpg://hunjing:hunjing_dev_password@127.0.0.1:5432/hunjing_test", ) @pytest.fixture async def db() -> async_sessionmaker[AsyncSession]: """每个测试重建 schema:隔离且可重复。""" engine = create_async_engine(TEST_DATABASE_URL) async with engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.create_all) factory = async_sessionmaker(engine, expire_on_commit=False) yield factory await engine.dispose() @pytest.fixture async def client(db): """连接测试库的应用客户端(覆盖 get_db)。""" from httpx import ASGITransport, AsyncClient from app.db import session as db_session from app.main import create_app app = create_app() async def override(): async with db() as session: try: yield session await session.commit() except Exception: await session.rollback() raise app.dependency_overrides[db_session.get_db] = override # 后台 Run 任务使用测试库会话工厂与 mock 流(生产为独立工厂与真实传输) app.state.session_factory = db app.state.gw_transport = _mock_openai_stream() import os as _os _os.environ.setdefault("HJ_MOCK_KEY", "test-key") from app.models import LogicalModel, ProviderEndpoint async with db() as session: session.add(LogicalModel(id="default-chat", capabilities=["chat"])) session.add( ProviderEndpoint( logical_model_id="default-chat", provider="openai_compatible", name="mock-endpoint", base_url="http://mock.internal", model_id="mock-chat", api_key_env="HJ_MOCK_KEY", priority=1, ) ) await session.commit() transport = ASGITransport(app=app, raise_app_exceptions=False) async with AsyncClient(transport=transport, base_url="https://test") as c: yield c async def login(client, email: str) -> dict[str, str]: """开发档登录:返回自包含请求头(显式 Cookie 支持多用户交替)。 同时保留 cookie jar 自动存储:不带 headers 的旧式请求仍走 jar。 显式 Cookie header 优先于 jar(httpx 行为),交替测试不串会话。 """ resp = await client.post("/v1/auth/sessions", json={"email": email}) assert resp.status_code == 201, resp.text from app.auth.service import SESSION_COOKIE token = resp.cookies.get(SESSION_COOKIE) assert token, "登录未签发会话 Cookie" return { "Hunjing-CSRF-Token": resp.json()["csrf_token"], "Cookie": f"{SESSION_COOKIE}={token}", } def _mock_openai_stream(): """OpenAI-compatible 流式 mock:真实 SSE 字节流(data: {...} 分块 + usage)。""" import httpx sse = ( b'data: {"choices":[{"delta":{"content":"\\u4f60\\u597d"}}]}\n\n' b'data: {"choices":[{"delta":{"content":","}}]}\n\n' b'data: {"choices":[{"delta":{"content":"\\u9b42\\u9559"}}]}\n\n' b'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n' b'data: {"choices":[],"usage":{"prompt_tokens":12,' b'"completion_tokens":6,"total_tokens":18}}\n\n' b'data: [DONE]\n\n' ) def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, headers={"content-type": "text/event-stream"}, content=sse ) return httpx.MockTransport(handler)