Files
ai-contract-platform/backend/tests/test_reviews_api.py
T
2026-05-03 12:28:38 +08:00

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