- 迁移 15:upload(三态)、file_asset(sha256/存储键/pending-ready-quarantined)、media_job(四类五态+进度+取消)、voice_consent(用途/期限/撤回) - 上传三步流(契约第 9 节开发档):POST uploads(MIME 白名单+大小约束)→ PUT data(真实写 storage/ 落盘、sha256 校验)→ completions 转 ready——未完成文件不可被业务引用 - 文件内容取回(开发档本地;正式部署换短期签名 URL,存储键不变) - 媒体任务:ASR 输入必须 ready 音频;后台执行经注入 Provider(测试 mock 确定性转写);无 Provider 显式失败(fail-closed);取消幂等 - 声契:TTS/克隆需有效声契(未撤回未过期);撤回后新任务 403(ADR 媒体域“撤回停止新生成”) - CI(WP-14 起步):三任务流水线——backend(pgvector 服务容器 + ruff/mypy/pytest)、engine(CMake 警告即错误 + ctest)、openapi(Redocly lint) - 测试 91 项全绿(+5)
128 lines
4.4 KiB
Python
128 lines
4.4 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()
|
||
app.state.media_provider = _mock_media_provider()
|
||
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)
|
||
|
||
|
||
def _mock_media_provider():
|
||
"""媒体 Provider mock:ASR 返回确定性转写(测试注入;生产经配置)。"""
|
||
|
||
async def provider(kind: str, input_: dict) -> dict:
|
||
if kind == "asr":
|
||
return {"transcript": "(开发档转写)你好,魂镜", "language": "zh"}
|
||
if kind == "image_gen":
|
||
return {"file_id": None, "description": "generated"}
|
||
raise RuntimeError(f"unsupported kind {kind}")
|
||
|
||
return provider
|