Files
bid-doctor/server/tests/test_parse_flow.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

162 lines
7.3 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.

"""最小链路:上传 → 解析 → 清单;以及额度的预扣、结算、退回(红线 9)。"""
from __future__ import annotations
import httpx
from tests.conftest import FIXTURES, User, balance, project_with_tender, register, run_parse, upload
async def test_upload_parse_and_checklist(user: User) -> None:
project_id, doc_id = await project_with_tender(user)
assert (await user.get(f"/projects/{project_id}")).json()["next_action"] == "wait_parse"
job = await run_parse(user, doc_id)
assert job["status"] == "succeeded", job
assert job["result"]["items_added"] == 3
items = (await user.get(f"/projects/{project_id}/checklist/items")).json()["items"]
formal = [i for i in items if i["zone"] == "formal"]
assert len(formal) == 3
for it in formal:
assert it["importance"] == "substantive" and "invalidity" in it["categories"]
(c,) = it["citations"]
assert c["page_no"] == 1 and c["quads"] and c["quote"].startswith("★")
assert set(c["sentence_keys"][0].split(":")[:1]) == {doc_id}
assert any("32GB" in i["citations"][0]["quote"] for i in formal)
checklist = (await user.get(f"/projects/{project_id}/checklist")).json()
assert [r["marker"] for r in checklist["marking_rules"]] == ["star"]
project = (await user.get(f"/projects/{project_id}")).json()
assert project["status"] == "drafting" and project["next_action"] == "confirm_checklist"
doc = (await user.get(f"/documents/{doc_id}")).json()
assert doc["status"] == "ready" and doc["page_count"] == 1 and doc["normalized_sha256"]
sentences = (await user.get(f"/documents/{doc_id}/pages/1/sentences")).json()["items"]
assert any(s["text"].startswith("★交换机") for s in sentences)
notes = (await user.get("/notifications")).json()
assert notes["unread_count"] == 1 and notes["items"][0]["kind"] == "parse_succeeded"
async def test_quota_hold_then_settle(user: User) -> None:
_, doc_id = await project_with_tender(user)
assert await balance(user) == 3
await run_parse(user, doc_id)
assert await balance(user) == 2
accounts = (await user.get("/quota")).json()["accounts"]
assert accounts[0]["period_used"] == 1 and accounts[0]["held"] == 0
async def test_failed_parse_refunds_quota(user: User) -> None:
"""没有文字层的 PDF(扫描件):现在报「需要 OCR」,额度退回,净扣点为 0(FR-M6-03)。"""
_, doc_id = await project_with_tender(user, "blank.pdf")
job = await run_parse(user, doc_id)
assert job["status"] == "failed"
assert job["error"] == {"code": "ocr_required", "message": job["error"]["message"], "refunded": True}
assert await balance(user) == 3
from sqlalchemy import text
from bid_doctor.core.db import TenantContext, tenant_tx
ctx = TenantContext(tenant_id=user.me["tenant"]["id"], user_id=user.me["user"]["id"])
async with tenant_tx(ctx) as s:
kinds = (
await s.execute(text("SELECT kind, amount FROM quota_entry WHERE kind <> 'grant' ORDER BY created_at"))
).all()
assert [tuple(k) for k in kinds] == [("hold", -1), ("release", 1)]
async def test_retry_after_failure_holds_again(user: User) -> None:
_, doc_id = await project_with_tender(user, "blank.pdf")
first = await run_parse(user, doc_id)
second = await run_parse(user, doc_id)
assert (first["attempt_no"], second["attempt_no"]) == (1, 2)
assert await balance(user) == 3
async def test_duplicate_parse_is_rejected(user: User) -> None:
_, doc_id = await project_with_tender(user)
assert (await user.post(f"/documents/{doc_id}/parse-jobs", json={"expected_charge": 1})).status_code == 201
r = await user.post(f"/documents/{doc_id}/parse-jobs", json={"expected_charge": 1})
assert r.status_code == 409 and r.json()["code"] == "job_already_running" and r.json()["job_id"]
assert await balance(user) == 2 # 只预扣了一次
async def test_billing_mismatch_is_rejected_without_charge(user: User) -> None:
_, doc_id = await project_with_tender(user)
r = await user.post(f"/documents/{doc_id}/parse-jobs", json={"expected_charge": 0})
assert r.status_code == 409 and r.json()["code"] == "billing_changed"
assert await balance(user) == 3
async def test_quota_exhausted(app_client: httpx.AsyncClient) -> None:
u = await register(app_client)
_, doc_id = await project_with_tender(u)
for _ in range(3):
await run_parse(u, doc_id)
r = await u.post(f"/documents/{doc_id}/parse-jobs", json={"expected_charge": 1})
assert r.status_code == 409 and r.json()["code"] == "quota_exhausted"
assert await balance(u) == 0
async def test_reparse_keeps_item_ids(user: User) -> None:
"""重新解析时清单项编号不变(架构 §3.8)。"""
project_id, doc_id = await project_with_tender(user)
await run_parse(user, doc_id)
before = {i["id"] for i in (await user.get(f"/projects/{project_id}/checklist/items")).json()["items"]}
job = await run_parse(user, doc_id)
assert job["result"]["items_updated"] == 3 and job["result"]["items_added"] == 0
after = {i["id"] for i in (await user.get(f"/projects/{project_id}/checklist/items")).json()["items"]}
assert before == after
async def test_free_plan_allows_one_project(user: User) -> None:
assert (await user.post("/projects", json={})).status_code == 201
r = await user.post("/projects", json={})
assert r.status_code == 409 and r.json()["code"] == "plan_limit_exceeded"
async def test_upload_rejects_unsupported_and_corrupt_files(user: User) -> None:
r = await upload(user, "a.txt", "这不是招标文件".encode())
assert r.status_code == 422 and r.json()["code"] == "file_format_unsupported"
r = await upload(user, "a.pdf", b"%PDF-1.7\nnot really a pdf")
assert r.status_code == 422 and r.json()["code"] == "file_corrupted"
r = await upload(user, "看起来是.doc", (FIXTURES / "tender-mini.pdf").read_bytes())
assert r.status_code == 200
assert r.json()["detected_format"] == "pdf" and r.json()["extension_mismatch"] is True
async def test_upload_size_limit(user: User) -> None:
r = await user.post(
"/uploads", json={"filename": "big.pdf", "size_bytes": 51 * 1024 * 1024, "purpose": "project_document"}
)
assert r.status_code == 409 and r.json()["code"] == "plan_limit_exceeded"
async def test_signed_download(user: User, app_client: httpx.AsyncClient) -> None:
_, doc_id = await project_with_tender(user)
await run_parse(user, doc_id)
url = (await user.get(f"/documents/{doc_id}/file", params={"variant": "normalized"})).json()["url"]
path = url.split("/api/v1", 1)[1]
r = await app_client.get(path)
assert r.status_code == 200 and r.content.startswith(b"%PDF")
tampered = path.replace("sig=", "sig=x")
assert (await app_client.get(tampered)).status_code == 404
async def test_objects_are_encrypted_at_rest(user: User) -> None:
import os
from pathlib import Path
_, doc_id = await project_with_tender(user)
await run_parse(user, doc_id)
root = Path(os.environ["BD_STORAGE_DIR"]) / "t" / user.tenant_id
files = [p for p in root.rglob("*") if p.is_file()]
assert files and not any(p.read_bytes().startswith(b"%PDF") for p in files)
plain = (FIXTURES / "tender-mini.pdf").read_bytes()[:64]
assert not any(plain in p.read_bytes() for p in files)