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>
This commit is contained in:
0
server/tests/__init__.py
Normal file
0
server/tests/__init__.py
Normal file
152
server/tests/conftest.py
Normal file
152
server/tests/conftest.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""接口集成测试:跑在真实的 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")
|
||||
48
server/tests/fixtures/blank.pdf
vendored
Normal file
48
server/tests/fixtures/blank.pdf
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
%PDF-1.4
|
||||
%<25><><EFBFBD><EFBFBD>
|
||||
1 0 obj
|
||||
<</Title (33b5296d-b2aa-4b28-9a7a-d5e45e3b4f92.html)
|
||||
/Creator (Mozilla/5.0 \(X11; Linux x86_64\) AppleWebKit/537.36 \(KHTML, like Gecko\) HeadlessChrome/156.0.0.0 Safari/537.36)
|
||||
/Producer (Skia/PDF m156)
|
||||
/CreationDate (D:20261103101211+00'00')
|
||||
/ModDate (D:20261103101211+00'00')>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<</Length 0>> stream
|
||||
|
||||
endstream
|
||||
endobj
|
||||
2 0 obj
|
||||
<</Type /Page
|
||||
/Resources <</ProcSet [/PDF /Text /ImageB /ImageC /ImageI]>>
|
||||
/MediaBox [0 0 612 792]
|
||||
/Contents 3 0 R
|
||||
/Tabs /S
|
||||
/Parent 4 0 R>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<</Type /Pages
|
||||
/Count 1
|
||||
/Kids [2 0 R]>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<</Type /Catalog
|
||||
/Pages 4 0 R
|
||||
/ViewerPreferences <</Type /ViewerPreferences
|
||||
/DisplayDocTitle true>>>>
|
||||
endobj
|
||||
xref
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n
|
||||
0000000358 00000 n
|
||||
0000000311 00000 n
|
||||
0000000513 00000 n
|
||||
0000000568 00000 n
|
||||
trailer
|
||||
<</Size 6
|
||||
/Root 5 0 R
|
||||
/Info 1 0 R>>
|
||||
startxref
|
||||
685
|
||||
%%EOF
|
||||
BIN
server/tests/fixtures/tender-mini.pdf
vendored
Normal file
BIN
server/tests/fixtures/tender-mini.pdf
vendored
Normal file
Binary file not shown.
113
server/tests/test_auth.py
Normal file
113
server/tests/test_auth.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from tests.conftest import User, register
|
||||
|
||||
|
||||
async def test_register_creates_tenant_plan_and_quota(user: User) -> None:
|
||||
me = (await user.get("/me")).json()
|
||||
assert me["tenant"]["name"] == "测试集成公司"
|
||||
assert me["role"] == "owner" and "project.create" in me["permissions"]
|
||||
assert me["plan"]["code"] == "free"
|
||||
assert me["user"]["phone_hint"] == f"{user.phone[:3]}****{user.phone[-4:]}"
|
||||
assert [(a["unit"], a["balance"]) for a in (await user.get("/quota")).json()["accounts"]] == [("parse", 3)]
|
||||
|
||||
|
||||
async def test_phone_is_not_stored_in_plaintext(user: User) -> None:
|
||||
from sqlalchemy import text
|
||||
|
||||
from bid_doctor.core.db import anonymous_tx
|
||||
|
||||
async with anonymous_tx() as s:
|
||||
rows = (await s.execute(text("SELECT external_user_id, contact_hint FROM login_identity"))).all()
|
||||
assert all(user.phone not in (ext, hint) for ext, hint in rows)
|
||||
|
||||
|
||||
async def test_login_unregistered_keeps_code_for_register(app_client: httpx.AsyncClient) -> None:
|
||||
phone = "13700000001"
|
||||
await app_client.post("/auth/sms-codes", json={"phone": phone, "purpose": "login"})
|
||||
r = await app_client.post("/auth/login", json={"phone": phone, "sms_code": "000000", "client_kind": "web"})
|
||||
assert r.status_code == 404 and r.json()["code"] == "phone_not_registered"
|
||||
r = await app_client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"phone": phone,
|
||||
"sms_code": "000000",
|
||||
"tenant_name": "乙公司",
|
||||
"display_name": "李工",
|
||||
"terms_version": "v1",
|
||||
"client_kind": "web",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
|
||||
async def test_wrong_code_is_rejected(app_client: httpx.AsyncClient) -> None:
|
||||
phone = "13700000002"
|
||||
await app_client.post("/auth/sms-codes", json={"phone": phone, "purpose": "register"})
|
||||
r = await app_client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"phone": phone,
|
||||
"sms_code": "123456",
|
||||
"tenant_name": "丙公司",
|
||||
"display_name": "王工",
|
||||
"terms_version": "v1",
|
||||
"client_kind": "web",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 422 and r.json()["code"] == "sms_code_invalid"
|
||||
|
||||
|
||||
async def test_concurrent_login_needs_confirmation_then_kicks_old_device(app_client: httpx.AsyncClient) -> None:
|
||||
u = await register(app_client)
|
||||
from bid_doctor.core.valkey import valkey
|
||||
|
||||
await valkey().flushdb() # 跳过 60 秒重发间隔
|
||||
await app_client.post("/auth/sms-codes", json={"phone": u.phone, "purpose": "login"})
|
||||
body = {"phone": u.phone, "sms_code": "000000", "client_kind": "desktop"}
|
||||
r = await app_client.post("/auth/login", json=body)
|
||||
assert r.status_code == 409 and r.json()["code"] == "concurrent_session"
|
||||
assert len(r.json()["sessions"]) == 1
|
||||
r = await app_client.post("/auth/login", json={**body, "force": True})
|
||||
assert r.status_code == 200
|
||||
old = await u.get("/me") # 旧设备被挤下线(API3),而且知道是为什么
|
||||
assert old.status_code == 401
|
||||
assert old.json()["code"] == "session_revoked"
|
||||
assert old.json()["reason"] == "concurrent_login"
|
||||
new = {"Authorization": f"Bearer {r.json()['token']}"}
|
||||
assert (await app_client.get("/me", headers=new)).status_code == 200
|
||||
|
||||
|
||||
async def test_web_cookie_requires_csrf_header_on_writes(app_client: httpx.AsyncClient) -> None:
|
||||
phone = "13700000003"
|
||||
await app_client.post("/auth/sms-codes", json={"phone": phone, "purpose": "register"})
|
||||
r = await app_client.post(
|
||||
"/auth/register",
|
||||
json={
|
||||
"phone": phone,
|
||||
"sms_code": "000000",
|
||||
"tenant_name": "丁公司",
|
||||
"display_name": "赵工",
|
||||
"terms_version": "v1",
|
||||
"client_kind": "web",
|
||||
},
|
||||
)
|
||||
assert "token" not in r.json()
|
||||
cookies = {"bd_session": r.cookies["bd_session"], "bd_csrf": r.cookies["bd_csrf"]}
|
||||
app_client.cookies.clear() # 共用的测试客户端不留 cookie,后面的用例要看到「未登录」
|
||||
jar = httpx.Cookies(cookies)
|
||||
assert (await app_client.get("/me", cookies=jar)).status_code == 200
|
||||
r = await app_client.post("/projects", json={}, cookies=jar)
|
||||
assert r.status_code == 403 and r.json()["code"] == "csrf_failed"
|
||||
r = await app_client.post("/projects", json={}, cookies=jar, headers={"X-CSRF-Token": cookies["bd_csrf"]})
|
||||
assert r.status_code == 201
|
||||
|
||||
|
||||
async def test_unauthenticated_and_problem_format(app_client: httpx.AsyncClient) -> None:
|
||||
r = await app_client.get("/me")
|
||||
assert r.status_code == 401
|
||||
assert r.headers["content-type"].startswith("application/problem+json")
|
||||
body = r.json()
|
||||
assert body["code"] == "unauthenticated" and body["request_id"] == r.headers["x-request-id"]
|
||||
27
server/tests/test_contract.py
Normal file
27
server/tests/test_contract.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""实现不能跑出契约之外(API1):后端暴露的每个接口,openapi.yaml 里都要有同样的路径和方法。
|
||||
|
||||
骨架阶段契约里的接口还没全部实现;没实现的列在 api-design §10 的进度里,这里不要求。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
CONTRACT = Path(__file__).resolve().parents[2] / "docs" / "architecture" / "openapi.yaml"
|
||||
PREFIX = "/api/v1"
|
||||
|
||||
|
||||
def test_implemented_endpoints_are_in_contract() -> None:
|
||||
from bid_doctor.app import create_app
|
||||
|
||||
contract = yaml.safe_load(CONTRACT.read_text(encoding="utf-8"))["paths"]
|
||||
generated = create_app().openapi()["paths"]
|
||||
missing = []
|
||||
for path, ops in generated.items():
|
||||
rel = path.removeprefix(PREFIX)
|
||||
for method in ops:
|
||||
if method not in contract.get(rel, {}):
|
||||
missing.append(f"{method.upper()} {rel}")
|
||||
assert not missing, f"这些接口不在 openapi.yaml 里:{missing}"
|
||||
161
server/tests/test_parse_flow.py
Normal file
161
server/tests/test_parse_flow.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""最小链路:上传 → 解析 → 清单;以及额度的预扣、结算、退回(红线 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)
|
||||
45
server/tests/test_tenant_isolation.py
Normal file
45
server/tests/test_tenant_isolation.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""用 A 企业的令牌访问 B 企业的资源,一律 404(架构 §3.3 第 3 层)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from tests.conftest import project_with_tender, register, run_parse
|
||||
|
||||
|
||||
async def test_other_tenant_resources_are_not_found(app_client: httpx.AsyncClient) -> None:
|
||||
a = await register(app_client, "甲公司")
|
||||
b = await register(app_client, "乙公司")
|
||||
project_id, doc_id = await project_with_tender(a)
|
||||
job = await run_parse(a, doc_id)
|
||||
assert job["status"] == "succeeded"
|
||||
|
||||
for path in (
|
||||
f"/projects/{project_id}",
|
||||
f"/projects/{project_id}/checklist",
|
||||
f"/projects/{project_id}/checklist/items",
|
||||
f"/documents/{doc_id}",
|
||||
f"/documents/{doc_id}/pages",
|
||||
f"/documents/{doc_id}/file?variant=normalized",
|
||||
f"/parse-jobs/{job['id']}",
|
||||
):
|
||||
r = await b.get(path)
|
||||
assert r.status_code == 404, (path, r.status_code, r.text)
|
||||
r = await b.post(f"/documents/{doc_id}/parse-jobs", json={"expected_charge": 1})
|
||||
assert r.status_code == 404
|
||||
r = await b.post(f"/parse-jobs/{job['id']}/cancel")
|
||||
assert r.status_code == 404
|
||||
|
||||
assert (await b.get("/projects")).json()["items"] == []
|
||||
assert [p["id"] for p in (await a.get("/projects")).json()["items"]] == [project_id]
|
||||
|
||||
|
||||
async def test_database_blocks_queries_without_tenant() -> None:
|
||||
"""代码漏了租户条件时,行级安全策略兜底:没设租户什么都查不到。"""
|
||||
from sqlalchemy import text
|
||||
|
||||
from bid_doctor.core.db import anonymous_tx
|
||||
|
||||
async with anonymous_tx() as s:
|
||||
for table in ("project", "source_document", "checklist_item", "quota_entry", "citation"):
|
||||
assert (await s.execute(text(f"SELECT count(*) FROM {table}"))).scalar_one() == 0
|
||||
75
server/tests/test_units.py
Normal file
75
server/tests/test_units.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""不需要数据库的单元测试:证据校验、格式识别、文字层。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from bid_doctor.checklist.extract import check_basis, check_evidence, is_marked
|
||||
from bid_doctor.core.errors import AppError
|
||||
from bid_doctor.core.events import _sid # pyright: ignore[reportPrivateUsage]
|
||||
from bid_doctor.document.formats import detect
|
||||
from bid_doctor.document.pipeline.text_layer import Sentence, extract
|
||||
from tests.conftest import FIXTURES
|
||||
|
||||
|
||||
def sent(text: str, runs: list[tuple[int, int, str]] | None = None, seq: int = 1) -> Sentence:
|
||||
return Sentence(key=f"d:p1:s{seq}", page_no=1, seq=seq, text=text, quads=[[0, 0, 1, 1]], fmt_runs=runs or [])
|
||||
|
||||
|
||||
def test_evidence_checks() -> None:
|
||||
s = sent("★投标人须具备有效的信息系统集成服务资质证书。")
|
||||
by_id = {"p1s1": s}
|
||||
assert check_evidence({"sid": "p1s1", "quote": "投标人须具备有效的信息"}, by_id) is None
|
||||
assert check_evidence({"sid": "p9s9", "quote": "投标人须具备有效的信息"}, by_id) == "id"
|
||||
assert check_evidence({"sid": "p1s1", "quote": "投标人须具备"}, by_id) == "quote" # 少于 8 个字
|
||||
assert check_evidence({"sid": "p1s1", "quote": "这句话是模型改写的"}, by_id) == "quote"
|
||||
|
||||
|
||||
def test_basis_check() -> None:
|
||||
star = sent("★服务器内存不低于32GB。")
|
||||
plain = sent("投标人应按要求提供售后服务方案。")
|
||||
iu = sent("投标人须提供原厂授权书。", [(0, 12, "iu")])
|
||||
assert check_basis("star", [star], [star], set())
|
||||
assert not check_basis("star", [plain], [plain], set())
|
||||
# 斜体下划线只在文件声明过时才算(07 F1)
|
||||
assert not check_basis("italic_underline", [iu], [iu], set())
|
||||
assert check_basis("italic_underline", [iu], [iu], {"italic_underline"})
|
||||
invalid = sent("未提供的,其投标无效。", seq=2)
|
||||
assert check_basis("explicit_invalid", [plain], [plain, invalid], set())
|
||||
assert not check_basis("unknown", [star], [star], set())
|
||||
|
||||
|
||||
def test_marker_fallback() -> None:
|
||||
assert is_marked(sent("★采用国产化CPU芯片。"), set()) == "star"
|
||||
iu = sent("投标人须提供原厂授权书。", [(0, 12, "iu")])
|
||||
assert is_marked(iu, set()) is None
|
||||
assert is_marked(iu, {"italic_underline"}) == "italic_underline"
|
||||
|
||||
|
||||
def test_marked_text_for_model() -> None:
|
||||
s = sent("须提供授权书,否则无效", [(3, 6, "iu"), (9, 11, "u")])
|
||||
assert s.marked == "须提供<iu>授权书</iu>,否则<u>无效</u>"
|
||||
|
||||
|
||||
def test_detect_formats() -> None:
|
||||
pdf = (FIXTURES / "tender-mini.pdf").read_bytes()
|
||||
d = detect(pdf, "采购文件.pdf")
|
||||
assert (d.format, d.page_count, d.extension_mismatch) == ("pdf", 1, False)
|
||||
assert detect(pdf, "采购文件.doc").extension_mismatch
|
||||
with pytest.raises(AppError) as e:
|
||||
detect(b"hello", "a.doc")
|
||||
assert e.value.code == "file_format_unsupported"
|
||||
|
||||
|
||||
def test_text_layer_normalizes_radicals_and_keys() -> None:
|
||||
pages = extract("doc1", (FIXTURES / "tender-mini.pdf").read_bytes())
|
||||
texts = [s.text for s in pages[0].sentences]
|
||||
assert any("项目" in t for t in texts) and not any("⽬" in t for t in texts) # 「目」不是康熙部首
|
||||
assert any("彩页" in t for t in texts)
|
||||
keys = [s.key for s in pages[0].sentences]
|
||||
assert keys[0] == "doc1:p1:s1" and len(set(keys)) == len(keys)
|
||||
|
||||
|
||||
def test_stream_id_compare() -> None:
|
||||
assert _sid("1700000000000-1") > _sid("999-5")
|
||||
assert _sid("bad") == (0, 0)
|
||||
Reference in New Issue
Block a user