T02 第三个子卡。命名空间按 T00/04 §1 定案 P1 挂 project-assistant 之下,
复用现有鉴权与同源代理,鉴权/代理代码零改动。
新增 backend/api/prescreen.py(7 路径 / 8 操作)+ test_prescreen_api.py(27 项)。
app.py 仅新增 1 个 include_router 与 1 个异常处理器注册。
落实:
- 404 统一口径(03 §2):不存在与无访问权一律 404,不泄露对象存在性。
既有 check_project 抛 403 且被 5 个模块共用,故不改它,由本模块 scope() 包装。
实测:无权项目返回 404 NOT_FOUND 而非 403。
- 错误信封(D2):只在新路由输出 {error:{code,message,retryable,request_id}}。
- E-09 与 E-05 精确区分:新增 store.IdempotencyConflict,不再靠字符串比对猜码。
- 客户端清单只接受 4 个字段;id/status/received_at 由服务端生成——
状态机写权不下放。manifest 校验因此从"整对象"改为"4 字段 + 服务端补全"。
**实测发现的缺口并已修复**:Input(extra="forbid") 的校验错误由 FastAPI 校验层在
进入端点前抛出,不经过 run(),导致同一命名空间出现两种错误形状。现注册
RequestValidationError 处理器,但**只在 prescreen 路径生效**,其它路径交回
FastAPI 默认实现——既有端点形状经实测确认未变(含校验错误的形状)。
验证:
- scripts/verify.py 9/9 PASS(exit 0);全量单测 225 项 OK(196 → 225)
- claude_use/T02/smoke_s1.py 在真实 app.py 上 10/10 PASS:覆盖路由注册、
真实前缀下的 manager 鉴权放行、404 统一、信封形状、以及既有端点未被污染
Co-Authored-By: Claude Code <noreply@anthropic.com>
325 lines
17 KiB
Python
325 lines
17 KiB
Python
"""S1 接口层测试:材料集合导入与事实确认的 HTTP 契约。
|
||
|
||
只挂 prescreen 路由到独立 app,数据库指向临时文件——不触碰真实库。
|
||
"""
|
||
|
||
import os
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.exceptions import RequestValidationError
|
||
from fastapi.testclient import TestClient
|
||
|
||
from api import prescreen as api
|
||
from services import assistant_store as store
|
||
from services import fact_conflict
|
||
from services.project_assistant import ProjectAssistantService
|
||
|
||
HEADERS = {"X-Xingbang-Demo-Role": "manager"}
|
||
# 演示身份可访问的项目:DEMO-A/B/C、LAB-001、XB-2026-001
|
||
PROJECT = "DEMO-B"
|
||
FOREIGN_PROJECT = "OTHER-9" # 不在演示身份的项目列表里
|
||
|
||
|
||
def manifest(name="a.csv", sha="a" * 64, size=100):
|
||
return {"relative_path": name, "sha256": sha, "size_bytes": size, "mime_type": "text/csv"}
|
||
|
||
|
||
class PrescreenApiTests(unittest.TestCase):
|
||
def setUp(self):
|
||
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||
self.path = Path(self.temp.name)
|
||
self.service = ProjectAssistantService(self.path / "assistant.sqlite3")
|
||
self.patches = [
|
||
patch.object(api, "_service", lambda: self.service),
|
||
patch.dict(os.environ, {"XINGBANG_AUTH_USERS": "", "XINGBANG_REQUIRE_AUTH": "0",
|
||
"XINGBANG_LOCAL_MODEL_URL": ""}),
|
||
]
|
||
for p in self.patches:
|
||
p.start()
|
||
app = FastAPI()
|
||
# 必须与 app.py 的注册保持一致:否则本测试的 app 不忠实于生产接线,
|
||
# 校验错误会走 FastAPI 默认形状而测不到信封。
|
||
app.add_exception_handler(RequestValidationError, api.validation_handler)
|
||
app.include_router(api.router)
|
||
self.client = TestClient(app)
|
||
self.base = "/api/xingbang/risk-agent/project-assistant/prescreen"
|
||
|
||
def tearDown(self):
|
||
self.client.close()
|
||
for p in reversed(self.patches):
|
||
p.stop()
|
||
self.temp.cleanup()
|
||
|
||
# ------------------------------------------------------------ 工具
|
||
def post(self, path, body=None, headers=None):
|
||
return self.client.post(self.base + path, json=body if body is not None else {},
|
||
headers={**HEADERS, **(headers or {})})
|
||
|
||
def get(self, path, headers=None):
|
||
return self.client.get(self.base + path, headers={**HEADERS, **(headers or {})})
|
||
|
||
def error(self, response):
|
||
body = response.json()
|
||
self.assertIn("error", body, body)
|
||
return body["error"]
|
||
|
||
def seed_conflict(self):
|
||
"""直接经服务层造一个未解决冲突(本文件测的是 HTTP 层,不重复测存储层)。
|
||
|
||
注意:演示种子项目 DEMO-B 可能**已有**已确认的 facility.amount,
|
||
所以不能假设"首次确认一定无冲突"。这里按现状自适应。
|
||
传 unit=None 使候选值不做万元→元换算,与已确认值同口径可比。
|
||
"""
|
||
|
||
def cand(cid, value, source, locator):
|
||
return {"id": cid, "field": "facility.amount", "value": value,
|
||
"unit": None, "source": source, "locator": locator}
|
||
|
||
with self.service.connect() as db:
|
||
db.execute("BEGIN IMMEDIATE")
|
||
project = store.get(db, "project", PROJECT)
|
||
existing = project.get("values", {}).get("facility.amount")
|
||
if existing is None:
|
||
payload = fact_conflict.detect(project, cand("c0", "1000", "a.csv", "第2行"),
|
||
"demo-manager", "初始确认")
|
||
store.put(db, "project", project)
|
||
self.assertIsNone(payload, "首次确认不应产生冲突")
|
||
project = store.get(db, "project", PROJECT)
|
||
existing = project["values"]["facility.amount"]
|
||
|
||
different = next(v for v in ("777", "888", "999") if str(v) != str(existing))
|
||
payload = fact_conflict.detect(project, cand("c1", different, "b.csv", "第9行"),
|
||
"demo-manager", "第二来源")
|
||
store.put(db, "project", project)
|
||
self.assertIsNotNone(payload, "与已确认值不同,应产生冲突")
|
||
return fact_conflict.create(db, PROJECT, payload, "demo-manager")
|
||
|
||
# ------------------------------------------------------------ 导入批次
|
||
def test_create_batch_returns_202_queued(self):
|
||
response = self.post(f"/projects/{PROJECT}/import-batches", {"manifest": [manifest()]})
|
||
self.assertEqual(response.status_code, 202, response.text)
|
||
batch = response.json()["batch"]
|
||
self.assertEqual(batch["status"], "queued")
|
||
self.assertEqual(batch["data_domain"], "demo")
|
||
self.assertEqual(batch["revision"], 1)
|
||
self.assertTrue(batch["items"][0]["id"].startswith("item-"))
|
||
self.assertEqual(batch["items"][0]["status"], "queued")
|
||
self.assertIsNone(batch["items"][0]["observed_at"])
|
||
|
||
def test_client_supplied_server_fields_rejected(self):
|
||
bad = dict(manifest(), status="parsed")
|
||
response = self.post(f"/projects/{PROJECT}/import-batches", {"manifest": [bad]})
|
||
self.assertEqual(response.status_code, 422, response.text)
|
||
self.assertEqual(self.error(response)["code"], "IMPORT_BATCH_INVALID")
|
||
|
||
def test_absolute_path_rejected_with_envelope(self):
|
||
response = self.post(f"/projects/{PROJECT}/import-batches",
|
||
{"manifest": [manifest(name="C:/x.csv")]})
|
||
self.assertEqual(response.status_code, 422)
|
||
error = self.error(response)
|
||
self.assertEqual(error["code"], "IMPORT_BATCH_INVALID")
|
||
self.assertFalse(error["retryable"])
|
||
self.assertTrue(error["request_id"])
|
||
|
||
def test_unknown_body_field_rejected_with_envelope(self):
|
||
"""校验层在进入端点前抛错,也必须走统一信封(否则同命名空间两种形状)。"""
|
||
response = self.post(f"/projects/{PROJECT}/import-batches",
|
||
{"manifest": [manifest()], "data_domain": "production"})
|
||
self.assertEqual(response.status_code, 422, response.text)
|
||
error = self.error(response)
|
||
self.assertEqual(error["code"], "INVALID_REQUEST")
|
||
self.assertTrue(error["request_id"])
|
||
|
||
def test_invalid_query_param_rejected_with_envelope(self):
|
||
response = self.get(f"/projects/{PROJECT}/import-batches?limit=notanumber")
|
||
self.assertEqual(response.status_code, 422, response.text)
|
||
self.assertEqual(self.error(response)["code"], "INVALID_REQUEST")
|
||
|
||
def test_empty_manifest_rejected(self):
|
||
response = self.post(f"/projects/{PROJECT}/import-batches", {"manifest": []})
|
||
self.assertEqual(response.status_code, 422, response.text)
|
||
|
||
def test_foreign_project_is_404_not_403(self):
|
||
# 03 §2:不存在与无访问权统一 404,不泄露对象是否存在
|
||
response = self.post(f"/projects/{FOREIGN_PROJECT}/import-batches",
|
||
{"manifest": [manifest()]})
|
||
self.assertEqual(response.status_code, 404, response.text)
|
||
self.assertEqual(self.error(response)["code"], "NOT_FOUND")
|
||
|
||
def test_get_batch_by_id(self):
|
||
created = self.post(f"/projects/{PROJECT}/import-batches", {"manifest": [manifest()]}).json()["batch"]
|
||
response = self.get(f"/import-batches/{created['id']}")
|
||
self.assertEqual(response.status_code, 200, response.text)
|
||
self.assertEqual(response.json()["id"], created["id"])
|
||
|
||
def test_get_unknown_batch_is_404_envelope(self):
|
||
response = self.get("/import-batches/import-does-not-exist")
|
||
self.assertEqual(response.status_code, 404)
|
||
error = self.error(response)
|
||
self.assertEqual(error["code"], "NOT_FOUND")
|
||
# 文案不暴露对象是否存在
|
||
self.assertEqual(error["message"], "对象不可访问或不存在")
|
||
|
||
def test_idempotent_replay_returns_same_batch(self):
|
||
body = {"manifest": [manifest()]}
|
||
first = self.post(f"/projects/{PROJECT}/import-batches", body,
|
||
headers={"Idempotency-Key": "k1"}).json()["batch"]
|
||
again = self.post(f"/projects/{PROJECT}/import-batches", body,
|
||
headers={"Idempotency-Key": "k1"}).json()["batch"]
|
||
self.assertEqual(first["id"], again["id"])
|
||
|
||
def test_idempotency_conflict_is_409(self):
|
||
self.post(f"/projects/{PROJECT}/import-batches", {"manifest": [manifest()]},
|
||
headers={"Idempotency-Key": "k2"})
|
||
response = self.post(f"/projects/{PROJECT}/import-batches",
|
||
{"manifest": [manifest(sha="c" * 64)]},
|
||
headers={"Idempotency-Key": "k2"})
|
||
self.assertEqual(response.status_code, 409, response.text)
|
||
self.assertEqual(self.error(response)["code"], "IDEMPOTENCY_CONFLICT")
|
||
|
||
def test_overlong_idempotency_key_rejected(self):
|
||
response = self.post(f"/projects/{PROJECT}/import-batches", {"manifest": [manifest()]},
|
||
headers={"Idempotency-Key": "x" * 200})
|
||
self.assertEqual(response.status_code, 422, response.text)
|
||
|
||
def test_list_batches_for_project(self):
|
||
self.post(f"/projects/{PROJECT}/import-batches", {"manifest": [manifest()]})
|
||
response = self.get(f"/projects/{PROJECT}/import-batches")
|
||
self.assertEqual(response.status_code, 200, response.text)
|
||
self.assertEqual(len(response.json()["items"]), 1)
|
||
|
||
def test_retry_only_failed_items(self):
|
||
batch = self.post(f"/projects/{PROJECT}/import-batches",
|
||
{"manifest": [manifest(name="ok.csv"),
|
||
manifest(name="bad.csv", sha="b" * 64)]}).json()["batch"]
|
||
ids = [item["id"] for item in batch["items"]]
|
||
with self.service.connect() as db:
|
||
from services import material_import
|
||
material_import.mark_item(db, batch["id"], ids[0], "parsed", document_id="doc-1")
|
||
batch = material_import.mark_item(db, batch["id"], ids[1], "failed",
|
||
error_code="MATERIAL_PARSE_FAILED")
|
||
response = self.post(f"/import-batches/{batch['id']}/retry",
|
||
{"item_ids": [ids[1]], "expected_revision": batch["revision"]})
|
||
# 202 表示已接受重试;但该请求是同步执行的,直接看返回体
|
||
self.assertIn(response.status_code, (200, 202), response.text)
|
||
items = {item["id"]: item["status"] for item in response.json()["items"]}
|
||
self.assertEqual(items[ids[1]], "queued")
|
||
|
||
def test_retry_with_stale_revision_is_409(self):
|
||
batch = self.post(f"/projects/{PROJECT}/import-batches", {"manifest": [manifest()]}).json()["batch"]
|
||
with self.service.connect() as db:
|
||
from services import material_import
|
||
batch = material_import.mark_item(db, batch["id"], batch["items"][0]["id"], "failed",
|
||
error_code="MATERIAL_PARSE_FAILED")
|
||
response = self.post(f"/import-batches/{batch['id']}/retry",
|
||
{"item_ids": [batch["items"][0]["id"]], "expected_revision": 1})
|
||
self.assertEqual(response.status_code, 409, response.text)
|
||
self.assertEqual(self.error(response)["code"], "REVISION_CONFLICT")
|
||
|
||
def test_cancel_then_retry_refused(self):
|
||
batch = self.post(f"/projects/{PROJECT}/import-batches", {"manifest": [manifest()]}).json()["batch"]
|
||
cancelled = self.post(f"/import-batches/{batch['id']}/cancel",
|
||
{"expected_revision": batch["revision"]})
|
||
self.assertEqual(cancelled.status_code, 200, cancelled.text)
|
||
self.assertEqual(cancelled.json()["status"], "cancelled")
|
||
again = self.post(f"/import-batches/{batch['id']}/cancel",
|
||
{"expected_revision": cancelled.json()["revision"]})
|
||
self.assertEqual(again.status_code, 409, again.text)
|
||
|
||
# ------------------------------------------------------------ 事实冲突
|
||
def test_list_conflicts_and_filter(self):
|
||
conflict = self.seed_conflict()
|
||
response = self.get(f"/projects/{PROJECT}/fact-conflicts?status=unresolved")
|
||
self.assertEqual(response.status_code, 200, response.text)
|
||
self.assertEqual([row["id"] for row in response.json()["items"]], [conflict["id"]])
|
||
empty = self.get(f"/projects/{PROJECT}/fact-conflicts?status=resolved")
|
||
self.assertEqual(empty.json()["items"], [])
|
||
|
||
def test_invalid_status_filter_rejected(self):
|
||
response = self.get(f"/projects/{PROJECT}/fact-conflicts?status=whatever")
|
||
self.assertEqual(response.status_code, 422, response.text)
|
||
self.assertEqual(self.error(response)["code"], "INVALID_REQUEST")
|
||
|
||
def test_get_conflict_by_id(self):
|
||
conflict = self.seed_conflict()
|
||
response = self.get(f"/fact-conflicts/{conflict['id']}")
|
||
self.assertEqual(response.status_code, 200, response.text)
|
||
self.assertEqual(response.json()["status"], "unresolved")
|
||
|
||
def test_resolve_happy_path_adopts_value_and_persists(self):
|
||
conflict = self.seed_conflict()
|
||
chosen = conflict["candidates"][1]["evidence_id"]
|
||
response = self.post(f"/fact-conflicts/{conflict['id']}/resolve",
|
||
{"selected_evidence_ids": [chosen],
|
||
"resolution_note": "以合同金额为准",
|
||
"expected_revision": conflict["revision"]})
|
||
self.assertEqual(response.status_code, 200, response.text)
|
||
self.assertEqual(response.json()["status"], "resolved")
|
||
# 值必须真的落到项目事实里(resolve 就地改 project,接口层负责保存)
|
||
with self.service.connect() as db:
|
||
project = store.get(db, "project", PROJECT)
|
||
self.assertEqual(str(project["values"]["facility.amount"]),
|
||
str(conflict["candidates"][1]["value"]))
|
||
self.assertEqual(project["selected_sources"]["facility.amount"], chosen)
|
||
self.assertEqual(project["facts"]["facility_amount"],
|
||
project["values"]["facility.amount"])
|
||
|
||
def test_resolve_with_foreign_evidence_is_422(self):
|
||
conflict = self.seed_conflict()
|
||
response = self.post(f"/fact-conflicts/{conflict['id']}/resolve",
|
||
{"selected_evidence_ids": ["ev-nope"],
|
||
"resolution_note": "说明",
|
||
"expected_revision": conflict["revision"]})
|
||
self.assertEqual(response.status_code, 422, response.text)
|
||
self.assertEqual(self.error(response)["code"], "INVALID_REQUEST")
|
||
|
||
def test_resolve_twice_is_409(self):
|
||
conflict = self.seed_conflict()
|
||
chosen = conflict["candidates"][1]["evidence_id"]
|
||
self.post(f"/fact-conflicts/{conflict['id']}/resolve",
|
||
{"selected_evidence_ids": [chosen], "resolution_note": "说明",
|
||
"expected_revision": conflict["revision"]})
|
||
again = self.post(f"/fact-conflicts/{conflict['id']}/resolve",
|
||
{"selected_evidence_ids": [chosen], "resolution_note": "再来",
|
||
"expected_revision": conflict["revision"]})
|
||
self.assertEqual(again.status_code, 409, again.text)
|
||
|
||
def test_resolve_unknown_conflict_is_404(self):
|
||
response = self.post("/fact-conflicts/fc-nope/resolve",
|
||
{"selected_evidence_ids": ["ev-1"], "resolution_note": "说明",
|
||
"expected_revision": 1})
|
||
self.assertEqual(response.status_code, 404, response.text)
|
||
|
||
# ------------------------------------------------------------ 边界
|
||
def test_envelope_shape_is_stable(self):
|
||
response = self.get("/import-batches/nope")
|
||
error = self.error(response)
|
||
self.assertEqual(set(error), {"code", "message", "retryable", "request_id"})
|
||
|
||
def test_existing_endpoints_still_use_detail_shape(self):
|
||
"""D2 决策的证据:本模块以外不产出 error 信封。"""
|
||
import api.project_assistant as legacy
|
||
self.assertFalse(hasattr(legacy, "_envelope"))
|
||
self.assertFalse(hasattr(legacy, "S1_ERROR_HTTP"))
|
||
|
||
def test_router_is_mounted_under_project_assistant_prefix(self):
|
||
# 命名空间定案 P1:零鉴权改动的关键前提是前缀在 project-assistant 之下
|
||
self.assertEqual(api.router.prefix,
|
||
"/api/xingbang/risk-agent/project-assistant/prescreen")
|
||
|
||
def test_storage_layer_untouched_by_api_layer(self):
|
||
"""接口层不直接写 SQL,只经服务层——避免出现第二份业务规则。"""
|
||
import api.prescreen as module
|
||
with open(module.__file__, encoding="utf-8") as handle:
|
||
source = handle.read()
|
||
for banned in ("INSERT INTO", "UPDATE ", "DELETE FROM", "SELECT "):
|
||
self.assertNotIn(banned, source)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|