Files
bid-doctor/server/tests/conftest.py
YANG JIANKUAN ad7fba6021 feat(server): 工程骨架后端——上传 → 解析 → 清单最小链路
- core:配置(prod 拒绝默认密钥 / dev 短信 / 假模型)、租户事务 + 行级安全策略、
  problem+json 错误码、信封加密与签名下载、对象存储、事务内入队、Valkey stream 事件流、权限点
- identity:手机号 HMAC 存储、注册、登录(挤下线需确认,API3)、服务端会话
- billing:额度流水只追加,预扣 / 结算 / 退回,一笔预扣只有一个结局
- document:分片上传、按文件头识别格式、Gotenberg 转换、文字层与稳定句子编号
- checklist:标注规则识别、模型抽取、三道校验、标注类条款规则兜底、重跑对齐
- ai:提示词注册表(extract.substantive v1 来自 S4 v2)、假模型、OpenAI 兼容供应商、ai_call 流水
- 迁移:data-model 基线、套餐、Procrastinate、会话状态函数
- 测试 30 个:租户隔离、额度、失败退回、对象加密、契约一致性
- Compose(含 migrate)、镜像、CI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:48:10 +08:00

153 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""接口集成测试:跑在真实的 PostgreSQL 上(行级安全策略生效),Valkey 用第 1 号库。
需要先起依赖:docker compose -f deploy/compose/compose.yaml up -d postgres valkey gotenberg
每次测试会话重建 bid_doctor_test 库并跑全部迁移。
"""
from __future__ import annotations
import itertools
import os
import tempfile
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
PG = os.environ.get("BD_TEST_PG", "127.0.0.1:54329")
os.environ.update(
{
"BD_ENV": "test",
"BD_DATABASE_URL": f"postgresql+psycopg://bd_app:dev-app@{PG}/bid_doctor_test",
"BD_MIGRATE_DATABASE_URL": f"postgresql+psycopg://bd_owner:dev-owner@{PG}/bid_doctor_test",
"BD_VALKEY_URL": os.environ.get("BD_TEST_VALKEY", "redis://127.0.0.1:63799/1"),
"BD_STORAGE_DIR": tempfile.mkdtemp(prefix="bd-test-objects-"),
"BD_AI_PROVIDER": "fake",
"BD_SMS_PROVIDER": "dev",
}
)
import httpx # noqa: E402
import psycopg # noqa: E402
import pytest # noqa: E402
FIXTURES = Path(__file__).parent / "fixtures"
_phones = itertools.count(13900000001)
def _recreate_database() -> None:
with psycopg.connect(f"postgresql://bd_owner:dev-owner@{PG}/postgres", autocommit=True) as conn:
conn.execute("DROP DATABASE IF EXISTS bid_doctor_test WITH (FORCE)")
conn.execute("CREATE DATABASE bid_doctor_test")
conn.execute("GRANT CONNECT ON DATABASE bid_doctor_test TO bd_app")
@pytest.fixture(scope="session", autouse=True)
def database() -> None:
_recreate_database()
from alembic import command
from alembic.config import Config
command.upgrade(Config(str(Path(__file__).resolve().parents[1] / "alembic.ini")), "head")
@pytest.fixture(scope="session", autouse=True)
async def _valkey() -> AsyncIterator[None]:
from bid_doctor.core.valkey import valkey
await valkey().flushdb()
yield
await valkey().flushdb()
@pytest.fixture(scope="session")
async def app_client() -> AsyncIterator[httpx.AsyncClient]:
from bid_doctor.app import create_app
transport = httpx.ASGITransport(app=create_app())
async with httpx.AsyncClient(transport=transport, base_url="http://test/api/v1") as c:
yield c
class User:
"""一个注册好的企业用户(桌面版 Bearer 令牌)。"""
def __init__(self, client: httpx.AsyncClient, token: str, me: dict[str, Any], phone: str) -> None:
self.client, self.token, self.me, self.phone = client, token, me, phone
self.headers = {"Authorization": f"Bearer {token}"}
async def get(self, path: str, **kw: Any) -> httpx.Response:
return await self.client.get(path, headers=self.headers, **kw)
async def post(self, path: str, **kw: Any) -> httpx.Response:
return await self.client.post(path, headers=self.headers, **kw)
async def put(self, path: str, **kw: Any) -> httpx.Response:
return await self.client.put(path, headers=self.headers, **kw)
@property
def tenant_id(self) -> str:
return str(self.me["tenant"]["id"])
async def register(client: httpx.AsyncClient, name: str = "测试集成公司") -> User:
phone = str(next(_phones))
assert (await client.post("/auth/sms-codes", json={"phone": phone, "purpose": "register"})).status_code == 202
r = await client.post(
"/auth/register",
json={
"phone": phone,
"sms_code": "000000",
"tenant_name": name,
"display_name": "张工",
"terms_version": "v1",
"client_kind": "desktop",
},
)
assert r.status_code == 201, r.text
body = r.json()
return User(client, body["token"], body["me"], phone)
@pytest.fixture
async def user(app_client: httpx.AsyncClient) -> User:
return await register(app_client)
async def upload(u: User, filename: str, data: bytes) -> httpx.Response:
r = await u.post("/uploads", json={"filename": filename, "size_bytes": len(data), "purpose": "project_document"})
assert r.status_code == 201, r.text
up = r.json()
size = up["part_size"]
for n in range(1, up["part_count"] + 1):
assert (
await u.put(f"/uploads/{up['id']}/parts/{n}", content=data[(n - 1) * size : n * size])
).status_code == 204
return await u.post(f"/uploads/{up['id']}/complete")
async def project_with_tender(u: User, fixture: str = "tender-mini.pdf") -> tuple[str, str]:
"""建项目并挂上一份招标文件,返回 (项目编号, 文档编号)。"""
project = (await u.post("/projects", json={})).json()
done = await upload(u, fixture, (FIXTURES / fixture).read_bytes())
assert done.status_code == 200, done.text
r = await u.post(f"/projects/{project['id']}/documents", json={"upload_id": done.json()["id"], "role": "tender"})
assert r.status_code == 201, r.text
return project["id"], r.json()["id"]
async def run_parse(u: User, document_id: str) -> dict[str, Any]:
"""发起解析,并在测试进程里直接跑流水线(不经过 worker)。"""
from uuid import UUID
from bid_doctor.document.pipeline import runner
r = await u.post(f"/documents/{document_id}/parse-jobs", json={"expected_charge": 1})
assert r.status_code == 201, r.text
await runner.run(UUID(u.tenant_id), UUID(r.json()["id"]))
return (await u.get(f"/parse-jobs/{r.json()['id']}")).json()
async def balance(u: User) -> int:
accounts = (await u.get("/quota")).json()["accounts"]
return next(a["balance"] for a in accounts if a["unit"] == "parse")