364 lines
16 KiB
Python
364 lines
16 KiB
Python
import unittest
|
|
|
|
from fastapi import HTTPException
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.api.reviews import get_audit_log_service, get_review_service, require_current_user
|
|
from app.main import app
|
|
|
|
|
|
class StubAuditLogService:
|
|
def __init__(self):
|
|
self.events = []
|
|
|
|
def record_event(self, **kwargs):
|
|
self.events.append(kwargs)
|
|
return kwargs
|
|
|
|
|
|
class StubReviewService:
|
|
def merge_review_text(self, text, file_text):
|
|
parts = [part for part in [text, file_text] if part]
|
|
return "\n\n".join(parts) if parts else None
|
|
|
|
def extract_text_from_file(self, file_bytes, file_name):
|
|
if not file_bytes:
|
|
return None
|
|
return file_bytes.decode("utf-8", errors="ignore")
|
|
|
|
def validate_review_upload(self, file_name, mime_type):
|
|
if file_name.endswith(".png") or file_name.endswith(".exe"):
|
|
raise HTTPException(status_code=400, detail="仅支持 PDF / DOCX / TXT 文件")
|
|
|
|
def create_review_task(self, tenant_id, user_id, review_as, contract_type, text, file_name):
|
|
return {
|
|
"id": "review-123",
|
|
"status": "completed",
|
|
"review_as": review_as,
|
|
"contract_type": contract_type or "未分类合同",
|
|
"merged_text": text,
|
|
"file_name": file_name,
|
|
"tenant_id": tenant_id,
|
|
"user_id": user_id,
|
|
}
|
|
|
|
def list_reviews(self, current_user):
|
|
return {
|
|
"items": [
|
|
{
|
|
"id": "review-123",
|
|
"title": "采购合同-A",
|
|
"contract_type": "procurement",
|
|
"review_as": "party_a",
|
|
"status": "processing",
|
|
"risk_level": "high",
|
|
"created_at": "2026-04-15T00:00:00+00:00",
|
|
"current_stage": "review",
|
|
"current_stage_label": "规则审查中",
|
|
"status_detail": "系统正在基于已提取文本执行规则审查。",
|
|
"failure_reason": None,
|
|
"next_action": "等待规则审查完成,随后可查看风险项与报告。",
|
|
"can_retry": False,
|
|
},
|
|
{
|
|
"id": "review-456",
|
|
"title": "服务合同-B",
|
|
"contract_type": "service",
|
|
"review_as": "party_b",
|
|
"status": "failed",
|
|
"risk_level": "medium",
|
|
"created_at": "2026-04-14T00:00:00+00:00",
|
|
"current_stage": "parse",
|
|
"current_stage_label": "文本解析失败",
|
|
"status_detail": "未能提取到可审查文本,请检查上传内容或改用文本输入后重试。",
|
|
"failure_reason": "原文为空或无法提取到可审查文本。",
|
|
"next_action": "请检查文件格式、文本质量或直接粘贴合同正文后重试。",
|
|
"can_retry": True,
|
|
},
|
|
{
|
|
"id": "review-789",
|
|
"title": "框架协议-C",
|
|
"contract_type": "service",
|
|
"review_as": "party_a",
|
|
"status": "partial",
|
|
"risk_level": "high",
|
|
"created_at": "2026-04-13T00:00:00+00:00",
|
|
"current_stage": "report",
|
|
"current_stage_label": "报告部分完成",
|
|
"status_detail": "审查已部分完成,当前结果可供参考,但仍需补齐失败阶段后再作为正式输出。",
|
|
"failure_reason": "报告生成不完整",
|
|
"next_action": "请先复核当前结果与错误原因,修正后重新发起审查。",
|
|
"can_retry": True,
|
|
},
|
|
],
|
|
"page": 1,
|
|
"page_size": 20,
|
|
"total": 3,
|
|
"tenant_id": current_user["tenant_id"],
|
|
"user_id": current_user["id"],
|
|
}
|
|
|
|
def get_review_detail(self, current_user, review_id):
|
|
if review_id != "review-123":
|
|
return None
|
|
return {
|
|
"id": review_id,
|
|
"title": "demo.pdf",
|
|
"contract_type": "service",
|
|
"review_as": "party_a",
|
|
"status": "completed",
|
|
"source_type": "file",
|
|
"source_text_length": 6,
|
|
"source_text_preview": "自动续约\n免责",
|
|
"processing_time_ms": 1200,
|
|
"created_at": "2026-04-15T00:00:00+00:00",
|
|
"completed_at": "2026-04-15T00:01:00+00:00",
|
|
"error_message": None,
|
|
"current_stage": "report",
|
|
"current_stage_label": "报告已生成",
|
|
"status_detail": "文本提取、规则审查与报告阶段已完成,可继续查看详情和导出报告。",
|
|
"parse_status": "completed",
|
|
"review_status": "completed",
|
|
"report_status": "ready",
|
|
"queued_at": "2026-04-15T00:00:00+00:00",
|
|
"processing_started_at": "2026-04-15T00:00:58.800000+00:00",
|
|
"failure_reason": None,
|
|
"next_action": "查看风险详情,必要时导出报告并继续进入审查助手追问。",
|
|
"can_retry": False,
|
|
}
|
|
|
|
def retry_review_task(self, current_user, review_id):
|
|
if review_id == "missing":
|
|
return None
|
|
return {
|
|
"id": "review-retry-123",
|
|
"title": "服务合同-B(重试)",
|
|
"contract_type": "service",
|
|
"review_as": "party_b",
|
|
"status": "completed",
|
|
"risk_level": "medium",
|
|
"created_at": "2026-04-15T00:02:00+00:00",
|
|
"current_stage": "report",
|
|
"current_stage_label": "报告已生成",
|
|
"status_detail": "文本提取、规则审查与报告阶段已完成,可继续查看详情和导出报告。",
|
|
"failure_reason": None,
|
|
"next_action": "查看风险详情,必要时导出报告并继续进入审查助手追问。",
|
|
"can_retry": False,
|
|
"original_review_id": review_id,
|
|
}
|
|
|
|
def get_review_result(self, current_user, review_id):
|
|
if review_id != "review-123":
|
|
return None
|
|
return {
|
|
"basic_info": {
|
|
"contract_type": "service",
|
|
"party_a": "甲方",
|
|
"party_b": "乙方",
|
|
"amount": "待识别",
|
|
"term": "待识别",
|
|
},
|
|
"risk_summary": {"high": 1, "medium": 0, "low": 0, "overall": "high"},
|
|
"clauses": [{"title": "条款1", "content": "自动续约"}],
|
|
"risk_items": [{"title": "存在自动续约安排", "level": "high", "description": "desc"}],
|
|
"review_opinion": "检测到高风险条款",
|
|
"recommendation": "manual_review",
|
|
}
|
|
|
|
def get_review_report(self, current_user, review_id):
|
|
if review_id != "review-123":
|
|
return None
|
|
return {
|
|
"file_name": "review-report-review-123.pdf",
|
|
"download_url": f"/api/v1/reviews/{review_id}/report/download",
|
|
}
|
|
|
|
def build_report_content(self, current_user, review_id):
|
|
if review_id != "review-123":
|
|
return None
|
|
return "报告内容"
|
|
|
|
def build_report_pdf(self, current_user, review_id):
|
|
if review_id != "review-123":
|
|
return None
|
|
return b"%PDF-1.4\n%stub report\n"
|
|
|
|
|
|
class ReviewsApiTestCase(unittest.TestCase):
|
|
def setUp(self):
|
|
self.audit_log_service = StubAuditLogService()
|
|
app.dependency_overrides[get_review_service] = lambda: StubReviewService()
|
|
app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service
|
|
app.dependency_overrides[require_current_user] = lambda: {"id": "test-user", "tenant_id": "tenant-demo-001"}
|
|
self.client = TestClient(app)
|
|
|
|
def tearDown(self):
|
|
app.dependency_overrides.clear()
|
|
|
|
def test_create_review_accepts_text_only(self):
|
|
response = self.client.post("/api/v1/reviews", data={"review_as": "party_a", "text": "合同文本"})
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
body = response.json()
|
|
self.assertEqual(body["code"], 0)
|
|
self.assertEqual(body["data"]["id"], "review-123")
|
|
self.assertEqual(body["data"]["merged_text"], "合同文本")
|
|
self.assertEqual(body["data"]["user_id"], "test-user")
|
|
self.assertEqual(self.audit_log_service.events[0]["action"], "create_review")
|
|
self.assertEqual(self.audit_log_service.events[0]["user_id"], "test-user")
|
|
self.assertEqual(
|
|
self.audit_log_service.events[0]["detail_json"],
|
|
{
|
|
"contract_type": None,
|
|
"review_as": "party_a",
|
|
"source_type": "text",
|
|
"file_name": None,
|
|
"text_length": len("合同文本"),
|
|
"status": "completed",
|
|
},
|
|
)
|
|
|
|
def test_create_review_accepts_file_only(self):
|
|
response = self.client.post(
|
|
"/api/v1/reviews",
|
|
data={"review_as": "party_a"},
|
|
files={"file": ("contract.txt", "自动续约\n免责".encode("utf-8"), "text/plain")},
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
body = response.json()
|
|
self.assertEqual(body["code"], 0)
|
|
self.assertEqual(body["data"]["file_name"], "contract.txt")
|
|
self.assertEqual(body["data"]["merged_text"], "自动续约\n免责")
|
|
|
|
def test_create_review_rejects_missing_file_and_text(self):
|
|
response = self.client.post("/api/v1/reviews", data={"review_as": "party_a"})
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
body = response.json()
|
|
self.assertEqual(body["code"], 40001)
|
|
|
|
def test_create_review_rejects_unsupported_file_type(self):
|
|
response = self.client.post(
|
|
"/api/v1/reviews",
|
|
data={"review_as": "party_a"},
|
|
files={"file": ("contract.png", b"png", "image/png")},
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertEqual(response.json()["message"], "仅支持 PDF / DOCX / TXT 文件")
|
|
|
|
def test_create_review_rejects_unextractable_supported_file_without_text_fallback(self):
|
|
class UnextractableReviewService(StubReviewService):
|
|
def extract_text_from_file(self, file_bytes, file_name):
|
|
return None
|
|
|
|
app.dependency_overrides[get_review_service] = lambda: UnextractableReviewService()
|
|
|
|
response = self.client.post(
|
|
"/api/v1/reviews",
|
|
data={"review_as": "party_a"},
|
|
files={"file": ("contract.pdf", b"%PDF", "application/pdf")},
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
body = response.json()
|
|
self.assertEqual(body["code"], 40002)
|
|
self.assertIn("无法从上传文件中提取可审查文本", body["message"])
|
|
|
|
def test_list_reviews_uses_current_user_scope(self):
|
|
response = self.client.get("/api/v1/reviews")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
body = response.json()
|
|
self.assertEqual(body["code"], 0)
|
|
self.assertEqual(body["data"]["tenant_id"], "tenant-demo-001")
|
|
self.assertEqual(body["data"]["user_id"], "test-user")
|
|
|
|
def test_list_reviews_returns_lifecycle_fields(self):
|
|
response = self.client.get("/api/v1/reviews")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
items = response.json()["data"]["items"]
|
|
processing_item = items[0]
|
|
failed_item = items[1]
|
|
partial_item = items[2]
|
|
self.assertEqual(processing_item["current_stage"], "review")
|
|
self.assertEqual(processing_item["current_stage_label"], "规则审查中")
|
|
self.assertIn("规则审查", processing_item["status_detail"])
|
|
self.assertEqual(processing_item["next_action"], "等待规则审查完成,随后可查看风险项与报告。")
|
|
self.assertIsNone(processing_item["failure_reason"])
|
|
self.assertFalse(processing_item["can_retry"])
|
|
self.assertEqual(failed_item["current_stage"], "parse")
|
|
self.assertEqual(failed_item["current_stage_label"], "文本解析失败")
|
|
self.assertEqual(failed_item["failure_reason"], "原文为空或无法提取到可审查文本。")
|
|
self.assertIn("检查文件格式", failed_item["next_action"])
|
|
self.assertTrue(failed_item["can_retry"])
|
|
self.assertEqual(partial_item["current_stage"], "report")
|
|
self.assertEqual(partial_item["current_stage_label"], "报告部分完成")
|
|
self.assertEqual(partial_item["failure_reason"], "报告生成不完整")
|
|
self.assertIn("复核当前结果", partial_item["next_action"])
|
|
self.assertTrue(partial_item["can_retry"])
|
|
|
|
def test_get_review_detail_returns_lifecycle_fields(self):
|
|
response = self.client.get("/api/v1/reviews/review-123")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
item = response.json()["data"]
|
|
self.assertEqual(item["current_stage"], "report")
|
|
self.assertEqual(item["current_stage_label"], "报告已生成")
|
|
self.assertEqual(item["parse_status"], "completed")
|
|
self.assertEqual(item["review_status"], "completed")
|
|
self.assertEqual(item["report_status"], "ready")
|
|
self.assertIsNone(item["failure_reason"])
|
|
self.assertIn("进入审查助手追问", item["next_action"])
|
|
|
|
def test_retry_review_records_audit_log(self):
|
|
response = self.client.post("/api/v1/reviews/review-456/retry")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
body = response.json()
|
|
self.assertEqual(body["data"]["id"], "review-retry-123")
|
|
self.assertEqual(body["data"]["original_review_id"], "review-456")
|
|
self.assertEqual(self.audit_log_service.events[0]["action"], "retry_review")
|
|
self.assertEqual(
|
|
self.audit_log_service.events[0]["detail_json"],
|
|
{
|
|
"original_review_id": "review-456",
|
|
"new_review_id": "review-retry-123",
|
|
"status": "completed",
|
|
"current_stage": "report",
|
|
"next_action": "查看风险详情,必要时导出报告并继续进入审查助手追问。",
|
|
},
|
|
)
|
|
|
|
def test_get_result_and_download_report(self):
|
|
result_response = self.client.get("/api/v1/reviews/review-123/result")
|
|
download_response = self.client.get("/api/v1/reviews/review-123/report/download")
|
|
|
|
self.assertEqual(result_response.status_code, 200)
|
|
self.assertEqual(result_response.json()["data"]["risk_summary"]["overall"], "high")
|
|
self.assertEqual(download_response.status_code, 200)
|
|
self.assertEqual(download_response.headers["content-type"], "application/pdf")
|
|
self.assertEqual(download_response.headers["content-disposition"], "attachment; filename*=UTF-8''review-report-review-123.pdf")
|
|
self.assertTrue(download_response.content.startswith(b"%PDF"))
|
|
self.assertEqual(self.audit_log_service.events[0]["action"], "download_report")
|
|
self.assertEqual(self.audit_log_service.events[0]["user_id"], "test-user")
|
|
self.assertEqual(
|
|
self.audit_log_service.events[0]["detail_json"],
|
|
{
|
|
"file_name": "review-report-review-123.pdf",
|
|
"download_url": "/api/v1/reviews/review-123/report/download",
|
|
},
|
|
)
|
|
|
|
def test_missing_review_returns_404(self):
|
|
response = self.client.get("/api/v1/reviews/missing/result")
|
|
|
|
self.assertEqual(response.status_code, 404)
|
|
self.assertEqual(response.json()["message"], "review not found")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|