- 迁移 7+8:logical_model(能力目录)、provider_endpoint(密钥只存环境变量名,永不落库)、turn、model_call(每 attempt 单独持久化:usage/TTFT/错误,Provider 未返回保持 null 不估算——ADR-003);message.run_id 关联 - OpenAI-compatible 流式适配器:httpx SSE 解析、标准化 StreamChunk(delta/usage/done)、transport 可注入(测试与故障注入);密钥经环境变量读取不进日志 - Run 执行器:后台执行完整生命周期——running → content.block.started → 真实 content.block.delta*(流式产生,delta_index 单调)→ content.block.completed → message.completed → 终态;assistant 消息为权威快照;ModelCall 记录 usage 与 TTFT;无可用端点时 Run 显式失败并留 error 事件(fail-closed) - post_message:响应提交后由框架 BackgroundTasks 执行 Run(显式预提交保证 Run 落库);SSE 改为增量轮询(重放 + 等待新事件至终态,30s 上限) - 测试 54 项全绿(+2):端到端 mock 流验证 ModelCall usage/TTFT/assistant 权威消息、无端点 Run failed + content.block.failed 事件
103 lines
3.5 KiB
Python
103 lines
3.5 KiB
Python
"""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 由客户端自动携带)。"""
|
|
resp = await client.post("/v1/auth/sessions", json={"email": email})
|
|
assert resp.status_code == 201, resp.text
|
|
return {"Hunjing-CSRF-Token": resp.json()["csrf_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)
|