166 lines
10 KiB
Python
166 lines
10 KiB
Python
import json
|
|||
|
|
import tempfile
|
||
|
|
import unittest
|
||
|
|
from pathlib import Path
|
||
|
|
from unittest.mock import patch
|
||
|
|
|
||
|
|
from fastapi import FastAPI
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
from api import risk_agent_config as risk
|
||
|
|
from api.coordination_agent import router
|
||
|
|
|
||
|
|
|
||
|
|
class CoordinationTests(unittest.TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
self.temp = tempfile.TemporaryDirectory()
|
||
|
|
self.dbpatch = patch.object(risk, "DB_PATH", Path(self.temp.name) / "test.sqlite")
|
||
|
|
self.dbpatch.start()
|
||
|
|
self.env = patch.dict("os.environ", {"XINGBANG_AUTH_USERS": "", "XINGBANG_REQUIRE_AUTH": "0", "XINGBANG_LOCAL_MODEL_URL": ""})
|
||
|
|
self.env.start()
|
||
|
|
app = FastAPI()
|
||
|
|
app.include_router(risk.router)
|
||
|
|
app.include_router(router)
|
||
|
|
self.client = TestClient(app)
|
||
|
|
self.base = "/api/xingbang/risk-agent"
|
||
|
|
self.root = self.base + "/coordination"
|
||
|
|
self.run = self.client.post(self.base + "/plans/plan-default/run", json={"project_id": "XB-2026-001"}).json()
|
||
|
|
result = self.client.post(self.root + "/projects", json={"run_id": self.run["id"], "industry": "制造业", "complexity": 2})
|
||
|
|
self.assertEqual(result.status_code, 200, result.text)
|
||
|
|
self.project = result.json()
|
||
|
|
|
||
|
|
def tearDown(self):
|
||
|
|
self.client.close()
|
||
|
|
self.dbpatch.stop()
|
||
|
|
self.env.stop()
|
||
|
|
self.temp.cleanup()
|
||
|
|
|
||
|
|
def act(self, kind, **extra):
|
||
|
|
response = self.client.post(f"{self.root}/projects/{self.project['id']}/actions", json={"version": self.project["version"], "kind": kind, "note": "已核查,人工确认", **extra})
|
||
|
|
if response.status_code == 200:
|
||
|
|
self.project = response.json()
|
||
|
|
return response
|
||
|
|
|
||
|
|
def person(self, **extra):
|
||
|
|
return self.client.post(self.root + "/people", json={"name": "测试主审员", "industries": ["制造业"], "capacity": 5, **extra}).json()
|
||
|
|
|
||
|
|
def plan(self, **extra):
|
||
|
|
response = self.client.post(f"{self.root}/projects/{self.project['id']}/plans", json={"version": self.project["version"], "amount": "5000", "term": 36, "rate": "5.5", "deposit": "10", "guarantee": "担保待核实", "note": "记录当前方案", **extra})
|
||
|
|
if response.status_code == 200:
|
||
|
|
self.project = response.json()
|
||
|
|
return response
|
||
|
|
|
||
|
|
def test_duplicate_active_project_and_version_conflict(self):
|
||
|
|
self.assertEqual(self.client.post(self.root + "/projects", json={"run_id": self.run["id"], "industry": "制造业"}).status_code, 409)
|
||
|
|
version = self.project["version"]
|
||
|
|
self.assertEqual(self.act("assign", owner_id=self.person()["id"]).status_code, 200)
|
||
|
|
self.assertEqual(self.act("stage", stage="待补件/待现场核查", version=version).status_code, 409)
|
||
|
|
|
||
|
|
def test_recommendation_excludes_unavailable_conflicts_capacity(self):
|
||
|
|
valid = self.person()
|
||
|
|
invalid = [self.person(available=False), self.person(conflicts=[self.run["project_id"]]), self.person(capacity=1)]
|
||
|
|
response = self.client.get(f"{self.root}/projects/{self.project['id']}/candidates")
|
||
|
|
self.assertEqual(response.json()[0]["id"], valid["id"])
|
||
|
|
for person in invalid:
|
||
|
|
self.assertEqual(self.act("assign", owner_id=person["id"]).status_code, 422)
|
||
|
|
self.assertEqual(self.act("assign", owner_id=valid["id"]).status_code, 200)
|
||
|
|
assigned = next(p for p in self.client.get(f"{self.root}/projects/{self.project['id']}/candidates").json() if p["id"] == valid["id"])
|
||
|
|
self.assertEqual(assigned["load"], 2)
|
||
|
|
self.assertEqual(assigned["projected_load"], 2)
|
||
|
|
self.assertEqual(self.client.delete(self.root + "/people/" + valid["id"]).status_code, 409)
|
||
|
|
|
||
|
|
def test_task_cannot_skip_checks(self):
|
||
|
|
self.assertEqual(self.act("task", title="补充现金流", assignee="客户经理", due="2026-10-01", finding_index=0).status_code, 200)
|
||
|
|
task_id = self.project["tasks"][0]["id"]
|
||
|
|
self.assertEqual(self.act("task_status", task_id=task_id, status="已解决").status_code, 409)
|
||
|
|
self.assertEqual(self.act("submit", task_id=task_id, reference="ERP-附件-001").status_code, 200)
|
||
|
|
self.assertEqual(self.project["tasks"][0]["status"], "已提交待检查")
|
||
|
|
self.assertEqual(self.act("task_status", task_id=task_id, status="已解决").status_code, 409)
|
||
|
|
self.assertEqual(self.act("task_status", task_id=task_id, status="待主审员确认").status_code, 200)
|
||
|
|
self.assertEqual(self.act("task_status", task_id=task_id, status="已解决").status_code, 200)
|
||
|
|
self.assertEqual(len(self.project["tasks"][0]["history"]), 3)
|
||
|
|
|
||
|
|
def test_plan_change_detects_decimal_equality_and_creates_impacts(self):
|
||
|
|
self.assertEqual(self.plan().status_code, 200)
|
||
|
|
self.assertEqual(self.plan(amount="5000.00").status_code, 422)
|
||
|
|
self.assertEqual(self.plan(term=48).status_code, 200)
|
||
|
|
self.assertEqual(list(self.project["plans"][-1]["changes"]), ["term"])
|
||
|
|
self.assertTrue(any(i["title"] == "复核保险及担保期限" and i["plan_version"] == 2 for i in self.project["impacts"]))
|
||
|
|
|
||
|
|
def test_stage_gates_and_change_reopens_review(self):
|
||
|
|
self.act("assign", owner_id=self.person()["id"])
|
||
|
|
for stage in ("待补件/待现场核查", "待财务分析/测算", "待主审员意见"):
|
||
|
|
self.assertEqual(self.act("stage", stage=stage).status_code, 200)
|
||
|
|
self.assertEqual(self.act("stage", stage="待上会").status_code, 422)
|
||
|
|
self.assertEqual(self.act("stage", stage="待部门复核").status_code, 409)
|
||
|
|
self.plan()
|
||
|
|
self.assertEqual(self.act("stage", stage="待部门复核").status_code, 409)
|
||
|
|
for index in range(len(self.run["findings"])):
|
||
|
|
self.act("risk_review", finding_index=index)
|
||
|
|
for impact in list(self.project["impacts"]):
|
||
|
|
self.act("impact", impact_id=impact["id"])
|
||
|
|
self.assertEqual(self.act("stage", stage="待部门复核").status_code, 200)
|
||
|
|
self.assertEqual(self.act("stage", stage="待上会").status_code, 200)
|
||
|
|
self.plan(term=48)
|
||
|
|
self.assertEqual(self.project["stage"], "待财务分析/测算")
|
||
|
|
|
||
|
|
def test_manager_scope_and_mutation_boundaries(self):
|
||
|
|
self.act("task", title="补充现金流", assignee="客户经理", due="2026-10-01")
|
||
|
|
task_id = self.project["tasks"][0]["id"]
|
||
|
|
users = [{"id": "manager", "role": "manager", "token": "manager-token", "projects": []}]
|
||
|
|
self.client.headers["authorization"] = "Bearer manager-token"
|
||
|
|
with patch.dict("os.environ", {"XINGBANG_AUTH_USERS": json.dumps(users)}):
|
||
|
|
self.assertEqual(self.client.get(self.root + "/workspace").json()["projects"], [])
|
||
|
|
self.assertEqual(self.act("submit", task_id=task_id, reference="ERP-001").status_code, 403)
|
||
|
|
users[0]["projects"] = [self.run["project_id"]]
|
||
|
|
with patch.dict("os.environ", {"XINGBANG_AUTH_USERS": json.dumps(users)}):
|
||
|
|
self.assertEqual(self.act("submit", task_id=task_id, reference="ERP-001").status_code, 200)
|
||
|
|
self.assertNotIn("configuration", self.project["source"])
|
||
|
|
self.assertEqual(self.act("risk_review", finding_index=0).status_code, 403)
|
||
|
|
self.assertEqual(self.act("assign", owner_id="anything").status_code, 403)
|
||
|
|
|
||
|
|
def test_admin_cannot_close_risk_as_reviewer(self):
|
||
|
|
users = [{"id": "admin", "role": "admin", "token": "admin-token"}]
|
||
|
|
self.client.headers["authorization"] = "Bearer admin-token"
|
||
|
|
with patch.dict("os.environ", {"XINGBANG_AUTH_USERS": json.dumps(users)}):
|
||
|
|
self.assertEqual(self.act("risk_review", finding_index=0).status_code, 403)
|
||
|
|
|
||
|
|
def test_source_refresh_preserves_history_and_invalidates_risk_confirmation(self):
|
||
|
|
self.act("risk_review", finding_index=0)
|
||
|
|
run = self.client.post(self.base + "/plans/plan-default/run", json={"project_id": self.run["project_id"]}).json()
|
||
|
|
# Use an explicit later timestamp since the risk store timestamps to seconds.
|
||
|
|
with risk._db() as db:
|
||
|
|
run["time"] = "2099-01-01T00:00:00+00:00"
|
||
|
|
db.execute("UPDATE runs SET body=? WHERE id=?", (json.dumps(run), run["id"]))
|
||
|
|
self.assertEqual(self.act("refresh_source", run_id=run["id"]).status_code, 200)
|
||
|
|
self.assertEqual(self.project["source_history"][0]["id"], self.run["id"])
|
||
|
|
self.assertNotIn(f"{run['id']}:0", self.project["risk_reviews"])
|
||
|
|
self.assertEqual(len(self.project["impacts"]), 3)
|
||
|
|
|
||
|
|
def test_model_summary_uses_core_adapter_without_mutating_tasks(self):
|
||
|
|
with patch.object(risk, "local_model", return_value={"text": "需复核现金流材料", "engine": "mini-openclaw/AgentManager", "messages": []}) as model:
|
||
|
|
response = self.client.post(f"{self.root}/projects/{self.project['id']}/summary", json={"version": self.project["version"], "kind": "summary"})
|
||
|
|
self.assertEqual(response.status_code, 200, response.text)
|
||
|
|
self.assertEqual(response.json()["tasks"], [])
|
||
|
|
self.assertEqual(response.json()["summary"]["trace"]["engine"], "mini-openclaw/AgentManager")
|
||
|
|
self.assertEqual(model.call_args.args[0]["native_skills"], ["review_coordination/SKILL.md"])
|
||
|
|
|
||
|
|
def test_model_failure_preserves_project(self):
|
||
|
|
response = self.client.post(f"{self.root}/projects/{self.project['id']}/summary", json={"version": self.project["version"], "kind": "summary"})
|
||
|
|
self.assertEqual(response.status_code, 503)
|
||
|
|
current = self.client.get(self.root + "/workspace").json()["projects"][0]
|
||
|
|
self.assertEqual(current["version"], self.project["version"])
|
||
|
|
self.assertIsNone(current["summary"])
|
||
|
|
|
||
|
|
def test_summary_runs_actual_agent_tool_loop(self):
|
||
|
|
from test_risk_core import ToolModel
|
||
|
|
with patch.dict("os.environ", {"XINGBANG_LOCAL_MODEL_URL": "http://127.0.0.1:1234/v1"}), patch("graph.risk_task.ChatOpenAI", return_value=ToolModel(skill_id="review_coordination/SKILL.md")):
|
||
|
|
response = self.client.post(f"{self.root}/projects/{self.project['id']}/summary", json={"version": self.project["version"], "kind": "summary"})
|
||
|
|
self.assertEqual(response.status_code, 200, response.text)
|
||
|
|
trace = response.json()["summary"]["trace"]
|
||
|
|
self.assertEqual(trace["engine"], "mini-openclaw/AgentManager")
|
||
|
|
self.assertEqual(len([m for m in trace["messages"] if m["type"] == "tool"]), 4)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|