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