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