Files
mini-openclaw/backend/test_project_assistant.py
T

279 lines
17 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.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"))
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_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)
if __name__ == "__main__":
unittest.main()