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()
|