Files
mini-openclaw/backend/test_coordination_agent.py

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