Files
mini-openclaw-v2/backend/test_project_assistant.py
qiaoqichaoandClaude Code d56c699ce6 chore(v2): 从兴邦预审v2白名单抽取独立可运行基座
按 docs/v2/05-implementation-plan.md §1 候选迁移表与 claude_use/T00 白名单,
从 55e2cdb 抽取 224 个文件(2.48 MB)到独立目录 F:/mini-OpenClaw-v2。

- 排除:凭据(.env/server-deploy.conf/旧DELIVERY.md)、运行数据(xingbang_data 等
  4 个 SQLite)、依赖(venv/node_modules 符号链接)、归档(_backup*/test-evidence/
  ori_file)、Office 临时文件
- 目标目录独立 git 仓库,初始恢复点;Gitea 远程与分支待 OQ-06 Q2 定案
- 未部署、未启动服务、未调用模型

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 12:58:26 +08:00

457 lines
30 KiB
Python

import json
import os
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 project_assistant as api
from api import risk_agent_config as catalog
from services.project_assistant import ProjectAssistantService
from services.assistant_worker import DurableWorker
from services.assistant_store import get, put, listing
class ProjectAssistantTests(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.object(catalog, "DB_PATH", self.path / "catalog.sqlite3"),
patch.dict(os.environ, {"XINGBANG_AUTH_USERS": "", "XINGBANG_REQUIRE_AUTH": "0", "XINGBANG_LOCAL_MODEL_URL": ""})]
for p in self.patches:
p.start()
self.worker = DurableWorker(self.service)
app = FastAPI()
app.include_router(api.router)
self.client = TestClient(app)
self.base = "/api/xingbang/risk-agent/project-assistant"
def tearDown(self):
self.worker.stop()
self.client.close()
for p in reversed(self.patches):
p.stop()
self.temp.cleanup()
def post(self, path, body=None, **kwargs):
return self.client.post(self.base + path, json=body or {}, **kwargs)
def project(self, project="DEMO-B"):
response = self.client.get(self.base + "/projects/" + project)
self.assertEqual(response.status_code, 200, response.text)
return response.json()
def run_project(self, project="DEMO-B", requirement="重点核对现金流"):
response = self.post(f"/projects/{project}/runs", {"requirement": requirement})
self.assertEqual(response.status_code, 202, response.text)
self.assertEqual(response.json()["status"], "queued")
self.worker.process_next()
result = self.client.get(self.base + f"/projects/{project}/runs/" + response.json()["id"]).json()
self.assertIn(result["status"], ("completed", "partial"), result)
return result
def supplement(self, changes, project="DEMO-B"):
return self.post(f"/projects/{project}/supplements", {"changes": changes, "revision": self.project(project)["revision"], "reason": "已核对补充来源"})
def test_demo_b_end_to_end_keeps_missing_cashflow(self):
run = self.run_project()
self.assertEqual(run["summary"]["coverage"], "1.0800")
self.assertEqual(run["summary"]["open"], 4)
self.assertEqual(self.supplement({"due_diligence_liabilities": "13200", "duplicate_asset_count": 0}).status_code, 201)
after = self.run_project()
self.assertEqual(len(after["change_summary"]["resolved"]), 2)
cash = next(f for f in self.project()["findings"] if f["code"] == "financial.cashflow.missing")
self.assertNotEqual(cash["status"], "已解决")
scenario = self.post("/projects/DEMO-B/scenarios", {"facility_amount": "4000"}).json()
self.assertEqual(scenario["scenario"]["coverage"], "1.3500")
self.assertIn(cash["id"], scenario["unresolved"])
self.assertEqual(self.project()["facts"]["facility_amount"], "5000")
def test_model_unavailable_is_not_completed(self):
run = self.run_project("DEMO-A")
self.assertEqual(run["status"], "partial")
model = next(s for s in run["steps"] if s["step_id"] == "corporate.analysis")
self.assertEqual(model["status"], "failed")
self.assertIn("未配置", model["errors"][0]["message"])
def test_idempotency_and_cancel(self):
headers = {"Idempotency-Key": "repeat"}
first = self.post("/projects/DEMO-B/runs", headers=headers).json()
self.assertEqual(first["id"], self.post("/projects/DEMO-B/runs", headers=headers).json()["id"])
self.assertEqual(self.post("/projects/DEMO-A/runs", headers=headers).status_code, 409)
self.assertEqual(self.post("/projects/DEMO-B/runs/" + first["id"] + "/cancel").status_code, 200)
self.assertFalse(self.worker.process_next())
def test_snapshots_immutable_and_no_cross_run_report(self):
first = self.run_project()
self.supplement({"operating_cashflow": "900"})
second = self.run_project()
self.assertEqual(first["summary"]["open"], 4)
report = self.post("/projects/DEMO-B/reports", {"run_id": first["id"]}).json()
self.assertEqual(report["status"], "stale")
self.assertIn("经营现金流资料缺失", report["sections"][1]["content"])
self.assertNotEqual(first["snapshot_id"], second["snapshot_id"])
self.assertEqual(self.post("/projects/DEMO-A/reports", {"run_id": first["id"]}).status_code, 404)
def test_invalid_decimal_and_concurrency(self):
for amount in ("0", "-1", "NaN", "Infinity", "1e1000"):
self.assertEqual(self.post("/projects/DEMO-B/scenarios", {"facility_amount": amount}).status_code, 422)
body = {"changes": {"operating_cashflow": "900"}, "revision": 99, "reason": "补件"}
self.assertEqual(self.post("/projects/DEMO-B/supplements", body).status_code, 409)
def test_real_upload_candidates_and_provenance(self):
content = "due_diligence.liabilities,13200,CNY,万元,2025,合并\n".encode()
response = self.client.post(self.base + "/projects/DEMO-B/documents", files={"file": ("补件.csv", content, "text/csv")})
self.assertEqual(response.status_code, 201, response.text)
document = response.json()
self.assertEqual(document["candidates"][0]["locator"], "第1行")
self.assertEqual(self.project()["facts"]["due_diligence_liabilities"], "12800")
accepted = self.post("/projects/DEMO-B/supplements", {"candidate_ids": [document["candidates"][0]["id"]], "revision": self.project()["revision"], "reason": "确认财报口径"})
self.assertEqual(accepted.status_code, 201, accepted.text)
run = self.run_project()
self.assertTrue(any(e.get("document_id") == document["id"] and e["locator"] == "第1行" for e in run["evidence"]))
cross = self.client.get(self.base + "/projects/DEMO-A/documents/" + document["id"] + "/download")
self.assertEqual(cross.status_code, 404)
def test_unexecuted_check_does_not_resolve(self):
self.run_project()
with self.service.connect() as db:
release = get(db, "release", "corporate.default")
plan = get(db, "component_version", release["version_id"])
# Simulates a later published plan that removes the consistency check.
plan["skills"] = [s for s in plan["skills"] if s != "corporate.consistency"]
put(db, "component_version", plan)
self.supplement({"due_diligence_liabilities": "13200"})
self.run_project()
finding = next(f for f in self.project()["findings"] if f["code"] == "financial.liabilities.conflict")
self.assertNotEqual(finding["status"], "已解决")
self.assertTrue(finding["stale"])
def test_admin_project_permission_is_not_implicit(self):
users = [{"id": "it", "token": "it", "role": "admin", "projects": []}]
with patch.dict(os.environ, {"XINGBANG_AUTH_USERS": json.dumps(users)}):
headers = {"Authorization": "Bearer it"}
self.assertEqual(self.client.get(self.base + "/projects", headers=headers).json()["projects"], [])
self.assertEqual(self.client.get(self.base + "/projects/DEMO-B", headers=headers).status_code, 403)
self.assertEqual(self.client.get(self.base + "/components", headers=headers).status_code, 200)
def test_permission_revocation_before_worker(self):
user = {"id": "m", "token": "secret", "role": "manager", "projects": ["DEMO-B"]}
with patch.dict(os.environ, {"XINGBANG_AUTH_USERS": json.dumps([user])}):
response = self.post("/projects/DEMO-B/runs", headers={"Authorization": "Bearer secret"})
self.assertEqual(response.status_code, 202)
user["projects"] = []
with patch.dict(os.environ, {"XINGBANG_AUTH_USERS": json.dumps([user])}):
self.worker.process_next()
self.assertEqual(self.service.resource("run", response.json()["id"])["status"], "failed")
def test_expired_lease_recovers_and_keeps_snapshot(self):
first = self.post("/projects/DEMO-B/runs").json()
self.worker.claim()
with self.service.connect() as db:
db.execute("UPDATE assistant_jobs SET lease_until=0 WHERE id=?", (first["id"],))
replacement = DurableWorker(self.service)
self.assertTrue(replacement.process_next())
run = self.service.resource("run", first["id"])
self.assertEqual(run["attempt"], 2)
self.assertEqual(run["status"], "partial")
def test_report_lock_candidates_and_export(self):
first = self.run_project()
report = self.post("/projects/DEMO-B/reports", {"run_id": first["id"]}).json()
path = "/projects/DEMO-B/reports/" + report["id"] + "/actions"
edited = self.post(path, {"action": "section", "revision": report["revision"], "payload": {"section_id": "judgment", "content": "人工确认意见", "locked": True, "confirmed": True}}).json()
self.supplement({"operating_cashflow": "900"})
second = self.run_project()
report = self.service.resource("report", report["id"])
regenerated = self.post(path, {"action": "regenerate", "revision": report["revision"], "payload": {"run_id": second["id"]}}).json()
section = next(s for s in regenerated["sections"] if s["id"] == "judgment")
self.assertEqual(section["content"], "人工确认意见")
self.assertTrue(section["candidate"])
self.assertTrue(section["stale"])
export = self.client.get(self.base + "/projects/DEMO-B/reports/" + report["id"] + "/export")
self.assertEqual(export.status_code, 200)
self.assertTrue(export.content.startswith(b"PK"))
def test_report_idempotency_and_protected_fact_numbers(self):
run = self.run_project()
headers = {"Idempotency-Key": "same-report"}
first = self.post("/projects/DEMO-B/reports", {"run_id": run["id"]}, headers=headers).json()
repeated = self.post("/projects/DEMO-B/reports", {"run_id": run["id"]}, headers=headers).json()
self.assertEqual(first["id"], repeated["id"])
response = self.post("/projects/DEMO-B/reports/" + first["id"] + "/actions", {"action": "section", "revision": first["revision"], "payload": {"section_id": "overview", "content": "融资1万元"}})
self.assertEqual(response.status_code, 422)
def test_experience_evaluation_release_and_cross_project_application(self):
run = self.run_project()
feedback = self.post("/projects/DEMO-B/feedback", {"category": "rule_omission", "target_kind": "run", "target_id": run["id"], "note": "回款依赖应追加凭证检查"}).json()
payload = {"title": "回款依据核查", "trigger": "回款依赖为真", "action": "补充到账凭证", "exceptions": "已提供到账凭证", "feedback_id": feedback["id"],
"conditions": {"collection.dependency": True}, "required_field": "collection.evidence", "exception_field": "collection.evidence", "scope": "team", "team": "demo", "expires_at": "2099-01-01T00:00:00+00:00"}
draft = self.post("/projects/DEMO-B/experiences", payload)
self.assertEqual(draft.status_code, 201, draft.text)
draft = draft.json()
path = "/projects/DEMO-B/experiences/" + draft["id"] + "/actions"
result = self.post(path, {"action": "publish", "revision": draft["revision"], "note": "审核"})
self.assertEqual(result.status_code, 409)
evaluated = self.post(path, {"action": "evaluate", "revision": draft["revision"], "note": "隔离评测"}).json()
self.assertTrue(evaluated["evaluation"]["passed"])
published = self.post(path, {"action": "publish", "revision": evaluated["revision"], "note": "审核通过"})
self.assertEqual(published.status_code, 200, published.text)
c_run = self.run_project("DEMO-C")
self.assertTrue(any(f["code"].startswith("experience.") for f in c_run["findings"]))
self.assertEqual(len(self.project("DEMO-C")["experience_applications"]), 1)
a_run = self.run_project("DEMO-A")
self.assertFalse(any(f["code"].startswith("experience.") for f in a_run["findings"]))
self.assertNotIn("experience_versions", c_run)
def test_reset_is_explicit_scoped_and_archived(self):
self.run_project()
self.assertEqual(self.post("/demo/reset").status_code, 422)
result = self.post("/demo/reset", {"confirmation": "RESET-DEMO-A-B-C"})
self.assertEqual(result.status_code, 200, result.text)
self.assertTrue(result.json()["history_reset"])
self.assertEqual(self.project()["revision"], 1)
with self.service.connect() as db:
self.assertEqual(len(listing(db, "reset_archive")), 3)
self.assertTrue(listing(db, "component_version"))
project = self.project("DEMO-B")
self.assertEqual(project["stage"], "补件中")
self.assertEqual(len(project["timeline"]), 3)
self.assertEqual([item["action"] for item in project["events"]], ["demo.reset"])
def test_mock_and_readonly_sqlite_adapter_agree(self):
import sqlite3
from services.assistant_data import load_catalog, read_cards
from services.component_registry import freeze
project = self.service.resource("project", "DEMO-B")
with self.service.connect() as db:
components = freeze(db, "corporate.default", load_catalog())
mock_values, _, _, errors = read_cards(project, components)
self.assertEqual(errors, [])
sql_path = self.path / "source.sqlite3"
with sqlite3.connect(sql_path) as db:
for (table, sample), mapping in zip(catalog.SAMPLES.items(), catalog.MAPPINGS):
row = {key: None for key in sample[0]}
for field, column in mapping.items():
row[column] = project["values"].get(field)
row["project_id"] = project["id"]
if table == "project":
row["customer_name"] = project["customer"]
if table == "finance":
row.update(period="2025", scope="合并")
db.execute(f"CREATE TABLE {table} ({','.join(row)})")
db.execute(f"INSERT INTO {table} VALUES ({','.join('?' for _ in row)})", tuple(row.values()))
components["legacy_connections"] = [{"id": "conn-erp", "type": "SQLite", "data_domain": "demo", "database": str(sql_path), "allowed_tables": list(catalog.SAMPLES)}]
with patch.dict(os.environ, {"XINGBANG_SQLITE_ROOT": str(self.path)}):
values, _, _, errors = read_cards(project, components)
self.assertEqual(errors, [])
self.assertEqual(values, mock_values)
def test_multitable_business_sql_plan_reaches_project_pre_review(self):
project = self.project("DEMO-A")
queued = self.post("/projects/DEMO-A/runs", {
"plan_id": "corporate.business-sql", "revision": project["revision"],
"requirement": "核对真实多表数据卡",
})
self.assertEqual(queued.status_code, 202, queued.text)
self.assertTrue(self.worker.process_next())
run = self.service.resource("run", queued.json()["id"])
self.assertIn(run["status"], ("completed", "partial"), run)
snapshot = self.service.resource("snapshot", run["snapshot_id"])
self.assertEqual(
{item["card_id"] for item in snapshot["datasets"]},
{"card-business-project", "card-business-finance", "card-business-credit", "card-business-lease", "card-business-repayment"},
)
project_source = next(item for item in run["evidence"] if item["field"] == "facility.amount" and item.get("card_id") == "card-business-project")
self.assertEqual(project_source["value"], "3000.0")
self.assertEqual(project_source["locator"], "数据卡字段 facility_amount")
def test_concurrent_queued_projects_stay_isolated(self):
from concurrent.futures import ThreadPoolExecutor
queued = [self.post(f"/projects/{p}/runs").json() for p in ("DEMO-A", "DEMO-B", "DEMO-C")]
workers = [DurableWorker(self.service) for _ in queued]
with ThreadPoolExecutor(max_workers=3) as pool:
self.assertTrue(all(pool.map(lambda w: w.process_next(), workers)))
results = [self.service.resource("run", r["id"]) for r in queued]
self.assertEqual(len({r["snapshot_id"] for r in results}), 3)
for run in results:
self.assertTrue(all(e["project_id"] == run["project_id"] for e in run["evidence"]))
self.assertTrue(all(f["project_id"] == run["project_id"] for f in run["findings"]))
def test_legacy_migration_is_idempotent_and_references_remain_readable(self):
import sqlite3
target = self.path / "legacy.sqlite3"
project = {"id": "DEMO-B", "facts": {"operating_cashflow": "777"}}
with sqlite3.connect(target) as db:
db.execute("CREATE TABLE projects(id TEXT PRIMARY KEY,body TEXT,revision INTEGER)")
db.execute("INSERT INTO projects VALUES (?,?,2)", ("DEMO-B", json.dumps(project)))
for kind in ("runs", "snapshots", "findings", "scenarios", "reports", "experiences"):
db.execute(f"CREATE TABLE {kind}(id TEXT,body TEXT)")
db.execute("INSERT INTO reports VALUES (?,?)", ("old-report", json.dumps({"id": "old-report", "project_id": "DEMO-B", "sections": [{"content": "旧版人工正文"}]})))
migrated = ProjectAssistantService(target)
self.assertEqual(migrated.resource("project", "DEMO-B")["values"]["financial.operating_cashflow"], "777")
self.assertEqual(migrated.resource("legacy_archive", "reports:old-report", "DEMO-B")["body"]["sections"][0]["content"], "旧版人工正文")
ProjectAssistantService(target)
with migrated.connect() as db:
self.assertEqual(len(listing(db, "legacy_archive")), 1)
def test_role_specific_plans_time_dashboard_and_demo_worker(self):
env = {"XINGBANG_DEMO_ROLE_LOGIN": "1", "XINGBANG_DEMO_PROJECTS": "DEMO-A,DEMO-B,DEMO-C"}
headers = {"X-Xingbang-Demo-Role": "manager"}
with patch.dict(os.environ, env):
project = self.client.get(self.base + "/projects/DEMO-B", headers=headers).json()
self.assertIn("corporate.pre-review", [p["id"] for p in project["plans"]])
self.assertNotIn("corporate.independent-review", [p["id"] for p in project["plans"]])
denied = self.post("/projects/DEMO-B/runs", {"plan_id": "corporate.independent-review"}, headers=headers)
self.assertEqual(denied.status_code, 403)
queued = self.post("/projects/DEMO-B/runs", {"plan_id": "corporate.pre-review"}, headers=headers).json()
self.assertTrue(self.worker.process_next())
self.assertIn(self.service.resource("run", queued["id"])["status"], ("completed", "partial"))
dashboard = self.client.get(self.base + "/time-dashboard", headers=headers)
self.assertEqual(dashboard.status_code, 200, dashboard.text)
self.assertGreaterEqual(dashboard.json()["summary"]["project_count"], 3)
timeline = self.service.get_project("DEMO-B")["timeline"]
live = next(e for e in timeline if e["source_id"] == queued["id"])
self.assertEqual(live["status"], "completed")
def test_conversation_command_uses_business_service_and_submits_report(self):
env = {"XINGBANG_DEMO_ROLE_LOGIN": "1", "XINGBANG_DEMO_PROJECTS": "DEMO-A,DEMO-B,DEMO-C"}
headers = {"X-Xingbang-Demo-Role": "manager"}
with patch.dict(os.environ, env):
command = self.post("/commands", {"project_id": "DEMO-A", "message": "请对这个项目进行AI预审"}, headers={**headers, "Idempotency-Key": "chat-run"})
self.assertEqual(command.status_code, 200, command.text)
self.assertEqual(command.json()["intent"], "start_run")
self.worker.process_next()
report = self.post("/commands", {"project_id": "DEMO-A", "message": "生成AI预审报告"}, headers={**headers, "Idempotency-Key": "chat-report"})
self.assertEqual(report.status_code, 200, report.text)
self.assertEqual(report.json()["intent"], "create_report")
submitted = self.post("/commands", {"project_id": "DEMO-A", "message": "提交正式评审"}, headers=headers)
self.assertEqual(submitted.status_code, 200, submitted.text)
self.assertEqual(submitted.json()["intent"], "submit_review")
self.assertEqual(submitted.json()["url"], "/review-pool?submitted=DEMO-A")
self.assertEqual(self.service.resource("project", "DEMO-A")["workflow_state"], "submitted")
reviewer_list = self.client.get(self.base + "/projects", headers={"X-Xingbang-Demo-Role": "reviewer"})
reviewer_a = next(item for item in reviewer_list.json()["projects"] if item["id"] == "DEMO-A")
self.assertEqual(reviewer_a["submitted_at"], self.service.resource("project", "DEMO-A")["submitted_at"])
self.assertEqual(reviewer_a["review_state"], "待领取")
reviewer = {"X-Xingbang-Demo-Role": "reviewer", "Idempotency-Key": "chat-review"}
claimed = self.post("/projects/DEMO-A/review-claim", {}, headers={"X-Xingbang-Demo-Role": "reviewer"})
self.assertEqual(claimed.status_code, 201, claimed.text)
self.assertEqual(claimed.json()["submitted_at"], reviewer_a["submitted_at"])
self.assertGreaterEqual(claimed.json()["wait_hours"], 0)
independent = self.post("/commands", {"project_id": "DEMO-A", "message": "发起独立评审"}, headers=reviewer)
self.assertEqual(independent.status_code, 200, independent.text)
self.assertEqual(independent.json()["intent"], "start_run")
self.assertEqual(self.service.resource("run", independent.json()["object_id"])["run_kind"], "independent_review")
decision = self.post("/projects/DEMO-A/review-decisions", {"decision": "退回", "opinion": "补充最新银行流水"}, headers={"X-Xingbang-Demo-Role": "reviewer"})
self.assertEqual(decision.status_code, 200, decision.text)
self.assertEqual(decision.json()["decision"], "退回")
manager_list = self.client.get(self.base + "/projects", headers={"X-Xingbang-Demo-Role": "manager"})
returned = next(item for item in manager_list.json()["projects"] if item["id"] == "DEMO-A")
self.assertEqual(returned["workflow_state"], "returned")
self.assertEqual(returned["review_state"], "已退回")
self.assertEqual(returned["return_reason"], "补充最新银行流水")
self.assertEqual(returned["next_action"], "查看退回要求并补充材料")
self.assertTrue(returned["returned_at"])
def test_role_scoped_miniclaw_understands_risk_assessment_phrase(self):
env = {"XINGBANG_DEMO_ROLE_LOGIN": "1", "XINGBANG_DEMO_PROJECTS": "DEMO-A,DEMO-B,DEMO-C"}
with patch.dict(os.environ, env):
response = self.post(
"/commands",
{"project_id": "DEMO-A", "message": "帮我做一次项目风险研判"},
headers={"X-Xingbang-Demo-Role": "manager", "Idempotency-Key": "cloud-mini-openclaw-risk"},
)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()["intent"], "start_run")
self.assertEqual(self.service.resource("run", response.json()["object_id"])["run_kind"], "pre_review")
def test_conversation_read_questions_receive_distinct_answers(self):
env = {"XINGBANG_DEMO_ROLE_LOGIN": "1", "XINGBANG_DEMO_PROJECTS": "DEMO-A,DEMO-B,DEMO-C"}
headers = {"X-Xingbang-Demo-Role": "manager"}
with patch.dict(os.environ, env):
status = self.post("/commands", {"project_id": "DEMO-B", "message": "这个项目到哪一步了?"}, headers=headers)
next_step = self.post("/commands", {"project_id": "DEMO-B", "message": "我接下来要做什么?"}, headers=headers)
issues = self.post("/commands", {"project_id": "DEMO-B", "message": "现在有哪些风险?"}, headers=headers)
materials = self.post("/commands", {"project_id": "DEMO-B", "message": "还缺什么材料?"}, headers=headers)
unknown = self.post("/commands", {"project_id": "DEMO-B", "message": "请详细分析这个客户"}, headers=headers)
for response in (status, next_step, issues, materials, unknown):
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(status.json()["intent"], "project_status")
self.assertIn("当前处于", status.json()["message"])
self.assertEqual(next_step.json()["intent"], "next_step")
self.assertIn("下一步", next_step.json()["message"])
self.assertNotIn("lease.duplicate_count完整性", next_step.json()["message"])
self.assertNotEqual(status.json()["message"], next_step.json()["message"])
self.assertEqual(issues.json()["intent"], "project_issues")
self.assertIn("未闭环问题", issues.json()["message"])
self.assertEqual(materials.json()["intent"], "materials")
self.assertEqual(unknown.json()["intent"], "clarify")
def test_full_review_topics_gate_approval_and_freeze_trace(self):
pre_run = self.run_project("DEMO-A")
report = self.post("/projects/DEMO-A/reports", {"run_id": pre_run["id"]}).json()
submitted = self.post("/projects/DEMO-A/reports/" + report["id"] + "/actions",
{"action": "submit", "revision": report["revision"], "payload": {}})
self.assertEqual(submitted.status_code, 200, submitted.text)
self.assertEqual(self.post("/projects/DEMO-A/review-claim").status_code, 201)
queued = self.post("/projects/DEMO-A/runs", {"plan_id": "corporate.independent-review", "revision": self.project("DEMO-A")["revision"]})
self.assertEqual(queued.status_code, 202, queued.text)
self.worker.process_next()
project = self.project("DEMO-A")
self.assertEqual(len(project["review_topics"]), 6)
topic_codes = [check["code"] for topic in project["review_topics"] for check in topic["checks"]]
self.assertEqual(len(topic_codes), len(set(topic_codes)))
lease_topic = next(item for item in project["review_topics"] if item["id"] == "lease_asset")
self.assertTrue(any("coverage" in check["code"] for check in lease_topic["checks"]))
denied = self.post("/projects/DEMO-A/review-decisions", {"decision": "批准"})
self.assertEqual(denied.status_code, 409, denied.text)
run_id = queued.json()["id"]
for topic in project["review_topics"]:
decision = "确认无异常" if topic["ai_status"] == "pass" else "不适用"
response = self.post("/projects/DEMO-A/review-decisions", {
"node_id": topic["id"], "decision": decision,
"opinion": "该领域不适用于本项目" if decision == "不适用" else "",
})
self.assertEqual(response.status_code, 200, response.text)
trace = response.json()
self.assertEqual(trace["run_id"], run_id)
self.assertEqual(trace["source_snapshot_id"], queued.json().get("snapshot_id") or self.service.resource("run", run_id)["snapshot_id"])
self.assertIn("evidence_ids", trace)
self.assertIn("basis_ids", trace)
approved = self.post("/projects/DEMO-A/review-decisions", {"decision": "批准"})
self.assertEqual(approved.status_code, 200, approved.text)
self.assertEqual(len(approved.json()["decision_refs"]), 6)
def test_manager_review_pool_withdraw_and_resubmit_preserves_versions(self):
env = {"XINGBANG_DEMO_ROLE_LOGIN": "1", "XINGBANG_DEMO_PROJECTS": "DEMO-A,DEMO-B,DEMO-C"}
manager = {"X-Xingbang-Demo-Role": "manager"}
with patch.dict(os.environ, env):
queued = self.post("/projects/DEMO-A/runs", {"plan_id": "corporate.pre-review"}, headers=manager)
self.assertEqual(queued.status_code, 202, queued.text)
self.worker.process_next()
report = self.post("/projects/DEMO-A/reports", {"run_id": queued.json()["id"], "template_id": "template.ai-pre-review"}, headers=manager).json()
submitted = self.post("/projects/DEMO-A/reports/" + report["id"] + "/actions",
{"action": "submit", "revision": report["revision"], "payload": {}}, headers=manager)
self.assertEqual(submitted.status_code, 200, submitted.text)
pool = self.client.get(self.base + "/review-pool", headers=manager).json()["projects"]
self.assertEqual(next(item for item in pool if item["project_id"] == "DEMO-A")["status"], "submitted")
withdrawn = self.post("/projects/DEMO-A/review-withdraw", headers=manager)
self.assertEqual(withdrawn.status_code, 200, withdrawn.text)
self.assertEqual(withdrawn.json()["status"], "withdrawn")
repeated = self.post("/projects/DEMO-A/review-resubmit", headers=manager)
self.assertEqual(repeated.status_code, 201, repeated.text)
self.assertEqual(repeated.json()["sequence"], 2)
reviewer_pool = self.client.get(self.base + "/review-pool", headers={"X-Xingbang-Demo-Role": "reviewer"}).json()["projects"]
self.assertTrue(any(item["project_id"] == "DEMO-A" and item["status"] == "submitted" for item in reviewer_pool))
with self.service.connect() as db:
versions = listing(db, "submission", "DEMO-A")
self.assertEqual(len(versions), 2)
self.assertEqual(versions[1]["status"], "withdrawn")
if __name__ == "__main__":
unittest.main()