218 lines
9.8 KiB
Python
218 lines
9.8 KiB
Python
"""T09 沙盘接口层测试:运行只读视图与情景端点(AC-11/12/30/31)。
|
|||
|
|
|
||
|
|
与 test_prescreen_api.py 同一套路:只挂 prescreen 路由到独立 app,库指向临时文件,
|
||
|
|
不触碰真实库、不启动服务。
|
||
|
|
"""
|
||
|
|
|
||
|
|
import copy
|
||
|
|
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 judgment_provider as jp
|
||
|
|
from contracts.prescreen import ScreeningRun
|
||
|
|
from services import prescreen, prescreen_policy, prescreen_store
|
||
|
|
from services.project_assistant import ProjectAssistantService
|
||
|
|
|
||
|
|
HEADERS = {"X-Xingbang-Demo-Role": "manager"}
|
||
|
|
PROJECT = "DEMO-B"
|
||
|
|
|
||
|
|
DIMENSIONS = [
|
||
|
|
{"id": "DC-CASH", "component_refs": [{"id": "corporate.cashflow"}]},
|
||
|
|
{"id": "DC-TITLE", "component_refs": [{"id": "corporate.title"}]},
|
||
|
|
]
|
||
|
|
FINDINGS = [
|
||
|
|
{"code": "corporate.cashflow", "result": "unknown", "stage": 1,
|
||
|
|
"evidence_ids": ["EV-1"]},
|
||
|
|
{"code": "corporate.title", "result": "unknown", "stage": 1,
|
||
|
|
"evidence_ids": ["EV-2"]},
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def screening(data_domain="demo"):
|
||
|
|
plan = {
|
||
|
|
"plan": {"id": "corporate.default", "version": "1.0.0"},
|
||
|
|
"skills": [{"id": "corporate.cashflow", "version": "1.0.0"}],
|
||
|
|
"rules": [], "template": {"id": "template.ai-pre-review", "version": "1.0.0"},
|
||
|
|
"content_hash": "abc123", "dimensions": DIMENSIONS,
|
||
|
|
}
|
||
|
|
decision = prescreen_policy.decide(FINDINGS)
|
||
|
|
decision["findings"] = FINDINGS
|
||
|
|
decision["missing_inputs"] = ["近六个月银行流水", "设备权属材料"]
|
||
|
|
return prescreen.assemble(
|
||
|
|
{"id": "run-1", "project_id": PROJECT, "created_at": store.now(),
|
||
|
|
"input_snapshot_id": "snap-1", "revision": 3},
|
||
|
|
plan, decision, jp.resolve_config(False), data_domain=data_domain)
|
||
|
|
|
||
|
|
|
||
|
|
class SandboxApiTests(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.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"
|
||
|
|
self.store_run()
|
||
|
|
|
||
|
|
def tearDown(self):
|
||
|
|
self.client.close()
|
||
|
|
for p in reversed(self.patches):
|
||
|
|
p.stop()
|
||
|
|
self.temp.cleanup()
|
||
|
|
|
||
|
|
def store_run(self, data_domain="demo"):
|
||
|
|
self.view = screening(data_domain=data_domain)
|
||
|
|
with store.transaction(self.service.db_path) as db:
|
||
|
|
prescreen_store.save(db, self.view)
|
||
|
|
return self.view
|
||
|
|
|
||
|
|
def post(self, path, body=None):
|
||
|
|
return self.client.post(self.base + path, json=body if body is not None else {},
|
||
|
|
headers=HEADERS)
|
||
|
|
|
||
|
|
def get(self, path):
|
||
|
|
return self.client.get(self.base + path, headers=HEADERS)
|
||
|
|
|
||
|
|
def error(self, response):
|
||
|
|
body = response.json()
|
||
|
|
self.assertIn("error", body, body)
|
||
|
|
return body["error"]
|
||
|
|
|
||
|
|
# ------------------------------------------------------------ 运行只读视图
|
||
|
|
def test_run_view_is_readable(self):
|
||
|
|
response = self.get(f"/projects/{PROJECT}/runs/run-1")
|
||
|
|
self.assertEqual(response.status_code, 200)
|
||
|
|
body = response.json()
|
||
|
|
self.assertEqual(body["id"], "run-1")
|
||
|
|
self.assertIsNone(body["probability"])
|
||
|
|
self.assertIsNone(body["probability_view"]["real_estimate"])
|
||
|
|
self.assertIsNone(body["probability_view"]["demo_estimate"],
|
||
|
|
"真实运行视图不得携带演示值")
|
||
|
|
|
||
|
|
def test_unknown_run_is_404_without_leaking_existence(self):
|
||
|
|
response = self.get(f"/projects/{PROJECT}/runs/run-missing")
|
||
|
|
self.assertEqual(response.status_code, 404)
|
||
|
|
self.assertEqual(self.error(response)["code"], "NOT_FOUND")
|
||
|
|
|
||
|
|
def test_run_of_another_project_is_404(self):
|
||
|
|
response = self.get("/projects/DEMO-C/runs/run-1")
|
||
|
|
self.assertEqual(response.status_code, 404)
|
||
|
|
|
||
|
|
def test_foreign_project_is_404(self):
|
||
|
|
response = self.get("/projects/OTHER-9/runs/run-1")
|
||
|
|
self.assertEqual(response.status_code, 404)
|
||
|
|
|
||
|
|
def test_run_listing_is_newest_first(self):
|
||
|
|
with store.transaction(self.service.db_path) as db:
|
||
|
|
older = dict(screening(), id="run-old", revision=1,
|
||
|
|
created_at="2026-09-29T01:00:00+00:00")
|
||
|
|
prescreen_store.save(db, ScreeningRun(**older).model_dump(mode="json"))
|
||
|
|
response = self.get(f"/projects/{PROJECT}/runs")
|
||
|
|
self.assertEqual(response.status_code, 200)
|
||
|
|
self.assertEqual([item["id"] for item in response.json()["items"]],
|
||
|
|
["run-1", "run-old"])
|
||
|
|
|
||
|
|
def test_available_assumptions_only_reference_existing_dimensions(self):
|
||
|
|
response = self.get(f"/projects/{PROJECT}/runs/run-1/assumptions")
|
||
|
|
self.assertEqual(response.status_code, 200)
|
||
|
|
body = response.json()
|
||
|
|
self.assertEqual(sorted(item["key"] for item in body["items"]),
|
||
|
|
["assume_bank_flow", "assume_title"])
|
||
|
|
self.assertEqual(body["scenario_table_version"], "DEMO-TABLE-v1")
|
||
|
|
self.assertIn("人工设定", body["label"])
|
||
|
|
|
||
|
|
trimmed = dict(screening())
|
||
|
|
trimmed["dimension_results"] = [item for item in trimmed["dimension_results"]
|
||
|
|
if item["id"] != "DC-TITLE"]
|
||
|
|
with store.transaction(self.service.db_path) as db:
|
||
|
|
prescreen_store.save(db, ScreeningRun(**trimmed).model_dump(mode="json"))
|
||
|
|
trimmed_response = self.get(f"/projects/{PROJECT}/runs/run-1/assumptions")
|
||
|
|
self.assertEqual([item["key"] for item in trimmed_response.json()["items"]],
|
||
|
|
["assume_bank_flow"])
|
||
|
|
|
||
|
|
# ------------------------------------------------------------ 情景
|
||
|
|
def test_simulation_returns_demo_estimate_only(self):
|
||
|
|
response = self.post(f"/projects/{PROJECT}/runs/run-1/simulations",
|
||
|
|
{"assumptions": ["assume_bank_flow"]})
|
||
|
|
self.assertEqual(response.status_code, 201)
|
||
|
|
body = response.json()
|
||
|
|
demo = body["probability_view"]["demo_estimate"]
|
||
|
|
self.assertEqual(demo["value"], 0.58)
|
||
|
|
self.assertEqual(demo["scenario_table_version"], "DEMO-TABLE-v1")
|
||
|
|
self.assertEqual(demo["scenario_key"], "10")
|
||
|
|
self.assertIn("人工设定", demo["label"])
|
||
|
|
self.assertIsNone(body["probability_view"]["real_estimate"])
|
||
|
|
self.assertEqual(body["baseline_probability_view"]["demo_estimate"]["value"], 0.42)
|
||
|
|
rows = {item["id"]: item for item in body["dimensions"]}
|
||
|
|
self.assertEqual(rows["DC-CASH"]["scenario_stage"], 3)
|
||
|
|
self.assertEqual(rows["DC-TITLE"]["scenario_stage"],
|
||
|
|
rows["DC-TITLE"]["baseline_stage"])
|
||
|
|
|
||
|
|
def test_combination_uses_the_table_not_the_sum(self):
|
||
|
|
response = self.post(f"/projects/{PROJECT}/runs/run-1/simulations",
|
||
|
|
{"assumptions": ["assume_bank_flow", "assume_title"]})
|
||
|
|
self.assertEqual(response.json()["probability_view"]["demo_estimate"]["value"], 0.64)
|
||
|
|
|
||
|
|
def test_empty_assumptions_return_the_baseline_scenario(self):
|
||
|
|
response = self.post(f"/projects/{PROJECT}/runs/run-1/simulations", {})
|
||
|
|
self.assertEqual(response.status_code, 201)
|
||
|
|
demo = response.json()["probability_view"]["demo_estimate"]
|
||
|
|
self.assertEqual(demo["scenario_key"], "00")
|
||
|
|
self.assertEqual(demo["value"], 0.42)
|
||
|
|
|
||
|
|
def test_simulation_does_not_touch_the_real_run(self):
|
||
|
|
before = copy.deepcopy(self.get(f"/projects/{PROJECT}/runs/run-1").json())
|
||
|
|
self.post(f"/projects/{PROJECT}/runs/run-1/simulations",
|
||
|
|
{"assumptions": ["assume_title"]})
|
||
|
|
after = self.get(f"/projects/{PROJECT}/runs/run-1").json()
|
||
|
|
self.assertEqual(before, after, "情景推演不得改动真实运行视图")
|
||
|
|
self.assertIsNone(after["probability"])
|
||
|
|
self.assertIsNone(after["probability_view"]["demo_estimate"])
|
||
|
|
|
||
|
|
def test_conflicting_assumption_is_rejected(self):
|
||
|
|
response = self.post(f"/projects/{PROJECT}/runs/run-1/simulations",
|
||
|
|
{"assumptions": ["assume_everything"]})
|
||
|
|
self.assertEqual(response.status_code, 422)
|
||
|
|
self.assertEqual(self.error(response)["code"], "SCENARIO_ASSUMPTION_CONFLICT")
|
||
|
|
|
||
|
|
def test_production_domain_cannot_use_demo_estimates(self):
|
||
|
|
self.store_run(data_domain="production")
|
||
|
|
response = self.post(f"/projects/{PROJECT}/runs/run-1/simulations",
|
||
|
|
{"assumptions": ["assume_bank_flow"]})
|
||
|
|
self.assertEqual(response.status_code, 422)
|
||
|
|
self.assertEqual(self.error(response)["code"], "DEMO_ESTIMATE_FORBIDDEN")
|
||
|
|
|
||
|
|
def test_stale_revision_is_a_conflict(self):
|
||
|
|
response = self.post(f"/projects/{PROJECT}/runs/run-1/simulations",
|
||
|
|
{"assumptions": [], "expected_revision": 2})
|
||
|
|
self.assertEqual(response.status_code, 409)
|
||
|
|
self.assertEqual(self.error(response)["code"], "REVISION_CONFLICT")
|
||
|
|
|
||
|
|
def test_unknown_field_in_request_is_rejected(self):
|
||
|
|
response = self.post(f"/projects/{PROJECT}/runs/run-1/simulations",
|
||
|
|
{"assumptions": [], "probability": 0.9})
|
||
|
|
self.assertEqual(response.status_code, 422)
|
||
|
|
self.assertEqual(self.error(response)["code"], "INVALID_REQUEST")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|