import unittest from fastapi import HTTPException from fastapi.testclient import TestClient from app.api.contracts import get_audit_log_service, get_contract_generation_service, require_current_user from app.main import app class StubAuditLogService: def __init__(self): self.operations = [] def record_event(self, **kwargs): self.operations.append(kwargs) return kwargs class StubContractGenerationService: def _auto_review_summary(self, review_id="review-auto-123", status="completed"): return { "review_id": review_id, "review_title": "采购合同模板", "status": status, "review_as": "party_a", "contract_type": "procurement", "risk_level": "high", "current_stage": "report", "current_stage_label": "报告已生成", "status_detail": "文本提取、规则审查与报告阶段已完成,可继续查看详情和导出报告。", "failure_reason": None, "next_action": "查看风险详情,必要时导出报告并继续进入审查助手追问。", "can_retry": False, "review_opinion": "检测到高风险条款", "recommendation": "manual_review", "source_file_id": "file-generated-123", "source_file_name": "contract.pdf", "source_text_length": 12, "processing_time_ms": 1200, "detail_url": f"/reviews/{review_id}", "report_download_url": f"/api/v1/reviews/{review_id}/report/download", "report_file_name": f"review-report-{review_id}.pdf", "chat_url": f"/chat?review={review_id}", } def list_generated_contracts(self, current_user, page, page_size, contract_type=None, keyword=None): records = [ { "id": "contract-123", "template_id": "tpl-123", "template_name": "采购合同模板", "contract_type": "procurement", "generated_content": "采购方与供应商A签订合同", "variable_values": {"supplier_name": "供应商A"}, "generated_at": "2026-04-17T00:00:00+00:00", "export_format": "pdf", "file_name": "contract.pdf", "file_size": 128, "preview_url": "/api/v1/contracts/contract-123/preview", "download_url": "/api/v1/contracts/contract-123/download", "metadata": { "generated_by": "user-admin", "generated_by_name": "管理员", "tenant_id": "tenant-demo-001", "original_template": "采购合同模板", "export_options": {"watermark": True}, "variables_used": 1, }, "tenant_id": "tenant-demo-001", }, { "id": "contract-456", "template_id": "tpl-456", "template_name": "劳动合同模板", "contract_type": "employment", "generated_content": "甲方与乙方签订劳动合同", "variable_values": {"employee_name": "张三"}, "generated_at": "2026-04-16T00:00:00+00:00", "export_format": "docx", "file_name": "employment.docx", "file_size": 256, "preview_url": "/api/v1/contracts/contract-456/preview", "download_url": "/api/v1/contracts/contract-456/download", "metadata": { "generated_by": "user-admin", "generated_by_name": "法务", "tenant_id": "tenant-demo-001", "original_template": "劳动合同模板", "export_options": {}, "variables_used": 1, }, "tenant_id": "tenant-demo-001", }, { "id": "contract-other-tenant", "template_id": "tpl-other", "template_name": "其他租户模板", "contract_type": "procurement", "generated_content": "其他租户合同", "variable_values": {"supplier_name": "外部供应商"}, "generated_at": "2026-04-15T00:00:00+00:00", "export_format": "pdf", "file_name": "other.pdf", "file_size": 512, "preview_url": "/api/v1/contracts/contract-other-tenant/preview", "download_url": "/api/v1/contracts/contract-other-tenant/download", "metadata": { "generated_by": "user-other", "generated_by_name": "外部租户", "tenant_id": "tenant-other-001", "original_template": "其他租户模板", "export_options": {"seal": True}, "variables_used": 1, }, "tenant_id": "tenant-other-001", }, ] for record in records: record["auto_review"] = self._auto_review_summary(f"review-{record['id']}") tenant_records = [record for record in records if record["tenant_id"] == current_user["tenant_id"]] if contract_type: tenant_records = [record for record in tenant_records if record["contract_type"] == contract_type] if keyword: normalized_keyword = keyword.strip().lower() tenant_records = [ record for record in tenant_records if normalized_keyword in record["template_name"].lower() or normalized_keyword in record["file_name"].lower() or normalized_keyword in record["metadata"]["generated_by_name"].lower() ] start = (page - 1) * page_size items = tenant_records[start : start + page_size] return { "items": [{key: value for key, value in item.items() if key != "tenant_id"} for item in items], "total": len(tenant_records), "page": page, "page_size": page_size, } def list_available_templates(self, current_user, contract_type, template_category=None, industry_category=None): return [ { "id": "tpl-123", "name": "采购合同模板", "contract_type": contract_type or "procurement", "description": "标准采购合同", "variables": [ { "name": "supplier_name", "label": "供应商名称", "required": True, "default_value": "", "description": "填写供应商名称", } ], "preview_excerpt": "甲方向乙方采购货物。", } ] def preview_contract(self, current_user, template_id, variable_values): if template_id == "tpl-missing": raise HTTPException(status_code=404, detail="模板不存在或无权访问") return { "preview_content": f"采购方与{variable_values.get('supplier_name', '')}签订合同", "template_name": "采购合同模板", "variable_summary": [ { "name": "supplier_name", "label": "供应商名称", "required": True, "provided": "supplier_name" in variable_values, "value_preview": variable_values.get("supplier_name", ""), "is_empty": not bool(variable_values.get("supplier_name", "").strip()), } ], } def get_generated_contract(self, current_user, contract_id): records = [ item for item in self.list_generated_contracts(current_user, page=1, page_size=50)["items"] if item["id"] == contract_id ] if not records: raise HTTPException(status_code=404, detail="合同不存在或无权访问") return records[0] def generate_contract(self, current_user, template_id, variable_values, export_format, options): if template_id == "tpl-missing": raise HTTPException(status_code=404, detail="模板不存在或无权访问") return { "id": "contract-123", "template_id": template_id, "template_name": "采购合同模板", "contract_type": "procurement", "generated_content": f"采购方与{variable_values.get('supplier_name', '')}签订合同", "variable_values": variable_values, "generated_at": "2026-04-17T00:00:00+00:00", "export_format": export_format.value, "file_name": f"contract.{export_format.value}", "file_size": 128, "file_content_base64": "ZmlsZQ==", "preview_url": "/api/v1/contracts/contract-123/preview", "download_url": "/api/v1/contracts/contract-123/download", "auto_review": self._auto_review_summary("review-auto-123"), "metadata": { "generated_by": current_user["id"], "generated_by_name": current_user.get("display_name", "管理员"), "tenant_id": current_user["tenant_id"], "original_template": "采购合同模板", "export_options": options, "variables_used": len(variable_values), }, } def get_generated_contract_preview(self, current_user, contract_id): if contract_id == "contract-missing": raise HTTPException(status_code=404, detail="合同不存在或无权访问") return { "id": contract_id, "template_name": "采购合同模板", "contract_type": "procurement", "preview_content": "采购方与供应商A签订合同", "export_format": "pdf", "file_name": "contract.pdf", "generated_at": "2026-04-17T00:00:00+00:00", "preview_url": f"/api/v1/contracts/{contract_id}/preview", "download_url": f"/api/v1/contracts/{contract_id}/download", } def get_generated_contract_download(self, current_user, contract_id): if contract_id == "contract-missing": raise HTTPException(status_code=404, detail="合同不存在或无权访问") return { "id": contract_id, "file_name": "contract.pdf", "mime_type": "application/pdf", "content": b"pdf-bytes", } class ContractsApiTestCase(unittest.TestCase): def setUp(self): self.audit_log_service = StubAuditLogService() app.dependency_overrides[get_contract_generation_service] = lambda: StubContractGenerationService() app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service self.client = TestClient(app) def tearDown(self): app.dependency_overrides.clear() def test_user_can_list_contract_templates(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "管理员", "role": "admin", } response = self.client.get("/api/v1/contracts/templates", params={"contract_type": "procurement"}) self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["data"][0]["name"], "采购合同模板") self.assertEqual(body["data"][0]["contract_type"], "procurement") def test_user_can_list_generated_contracts(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "管理员", "role": "admin", } response = self.client.get("/api/v1/contracts", params={"page": 1, "page_size": 10}) self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["data"]["total"], 2) self.assertEqual(body["data"]["page"], 1) self.assertEqual(body["data"]["page_size"], 10) self.assertEqual(len(body["data"]["items"]), 2) self.assertEqual(body["data"]["items"][0]["id"], "contract-123") self.assertEqual(body["data"]["items"][0]["metadata"]["generated_by_name"], "管理员") self.assertIn("preview_url", body["data"]["items"][0]) self.assertIn("download_url", body["data"]["items"][0]) def test_generated_contract_list_supports_contract_type_filter(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "管理员", "role": "admin", } response = self.client.get("/api/v1/contracts", params={"contract_type": "employment"}) self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["data"]["total"], 1) self.assertEqual(len(body["data"]["items"]), 1) self.assertEqual(body["data"]["items"][0]["contract_type"], "employment") self.assertEqual(body["data"]["items"][0]["id"], "contract-456") def test_generated_contract_list_supports_keyword_filter(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "管理员", "role": "admin", } response = self.client.get("/api/v1/contracts", params={"keyword": "法务"}) self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["data"]["total"], 1) self.assertEqual(len(body["data"]["items"]), 1) self.assertEqual(body["data"]["items"][0]["id"], "contract-456") self.assertEqual(body["data"]["items"][0]["metadata"]["generated_by_name"], "法务") def test_generated_contract_list_is_tenant_isolated(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-other", "tenant_id": "tenant-other-001", "display_name": "外部租户", "role": "admin", } response = self.client.get("/api/v1/contracts") self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["data"]["total"], 1) self.assertEqual(len(body["data"]["items"]), 1) self.assertEqual(body["data"]["items"][0]["id"], "contract-other-tenant") self.assertEqual(body["data"]["items"][0]["file_name"], "other.pdf") def test_user_can_get_generated_contract_detail(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "管理员", "role": "admin", } response = self.client.get("/api/v1/contracts/contract-123") self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["data"]["id"], "contract-123") self.assertEqual(body["data"]["template_id"], "tpl-123") self.assertEqual(body["data"]["export_format"], "pdf") self.assertEqual(body["data"]["variable_values"]["supplier_name"], "供应商A") self.assertEqual(body["data"]["metadata"]["export_options"], {"watermark": True}) self.assertEqual(body["data"]["auto_review"]["status"], "completed") self.assertEqual(body["data"]["auto_review"]["detail_url"], "/reviews/review-contract-123") self.assertIn("generated_content", body["data"]) def test_generated_contract_detail_is_tenant_isolated(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "管理员", "role": "admin", } response = self.client.get("/api/v1/contracts/contract-other-tenant") self.assertEqual(response.status_code, 404) self.assertEqual(response.json()["message"], "合同不存在或无权访问") def test_user_can_generate_contract_and_write_audit_log(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "管理员", "role": "admin", } response = self.client.post( "/api/v1/contracts/generate", data={ "template_id": "tpl-123", "variable_values_json": '{"supplier_name":"供应商A"}', "export_format": "pdf", "options_json": '{"watermark":true}', }, ) self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["data"]["export_format"], "pdf") self.assertEqual(body["data"]["file_name"], "contract.pdf") self.assertEqual(body["data"]["metadata"]["export_options"], {"watermark": True}) self.assertEqual(body["data"]["auto_review"]["status"], "completed") self.assertEqual(body["data"]["auto_review"]["report_download_url"], "/api/v1/reviews/review-auto-123/report/download") self.assertEqual(self.audit_log_service.operations[0]["action"], "contract_generation") self.assertEqual(self.audit_log_service.operations[0]["resource_id"], "contract-123") def test_user_can_get_generated_contract_preview(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "管理员", "role": "admin", } response = self.client.get("/api/v1/contracts/contract-123/preview") self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["data"]["id"], "contract-123") self.assertEqual(body["data"]["file_name"], "contract.pdf") self.assertIn("供应商A", body["data"]["preview_content"]) def test_user_can_download_generated_contract_and_write_audit_log(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "管理员", "role": "admin", } response = self.client.get("/api/v1/contracts/contract-123/download") self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b"pdf-bytes") self.assertEqual(response.headers["content-type"], "application/pdf") self.assertIn("attachment;", response.headers["content-disposition"]) self.assertIn("contract.pdf", response.headers["content-disposition"]) self.assertEqual(self.audit_log_service.operations[0]["action"], "download_contract") self.assertEqual( self.audit_log_service.operations[0]["detail_json"], { "file_name": "contract.pdf", "mime_type": "application/pdf", }, ) self.assertEqual(self.audit_log_service.operations[0]["resource_id"], "contract-123") def test_preview_rejects_invalid_json(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "管理员", "role": "admin", } response = self.client.post( "/api/v1/contracts/preview", data={ "template_id": "tpl-123", "variable_values_json": "{bad json}", }, ) self.assertEqual(response.status_code, 400) self.assertEqual(response.json()["message"], "JSON解析错误") def test_generate_rejects_invalid_export_format(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "管理员", "role": "admin", } response = self.client.post( "/api/v1/contracts/generate", data={ "template_id": "tpl-123", "variable_values_json": '{"supplier_name":"供应商A"}', "export_format": "xlsx", }, ) self.assertEqual(response.status_code, 400) self.assertIn("不支持的导出格式", response.json()["message"]) def test_generate_surfaces_service_404(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "管理员", "role": "admin", } response = self.client.post( "/api/v1/contracts/generate", data={ "template_id": "tpl-missing", "variable_values_json": '{"supplier_name":"供应商A"}', "export_format": "docx", }, ) self.assertEqual(response.status_code, 404) self.assertEqual(response.json()["message"], "模板不存在或无权访问") def test_generated_contract_preview_surfaces_service_404(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "管理员", "role": "admin", } response = self.client.get("/api/v1/contracts/contract-missing/preview") self.assertEqual(response.status_code, 404) self.assertEqual(response.json()["message"], "合同不存在或无权访问") def test_generated_contract_download_surfaces_service_404(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "管理员", "role": "admin", } response = self.client.get("/api/v1/contracts/contract-missing/download") self.assertEqual(response.status_code, 404) self.assertEqual(response.json()["message"], "合同不存在或无权访问") class ContractGenerationServiceVariableParsingTestCase(unittest.TestCase): def setUp(self): from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from app.db import Base from app.models.contract_template import ContractTemplate from app.services.contract_generation_service import ContractGenerationService self.engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(bind=self.engine) self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine) self.db = self.SessionLocal() self.service = ContractGenerationService(self.db) self.service.file_service = type( "StubFileService", (), { "create_file_record": lambda _self, **kwargs: { "id": "file-generated-123", "file_name": kwargs["file_name"], "mime_type": kwargs["mime_type"], "file_size": len(kwargs["content"]), "file_type": kwargs["file_type"], } }, )() class StubReviewService: def __init__(self): self.create_calls = [] def create_review_task(self, **kwargs): self.create_calls.append(kwargs) return { "id": "review-auto-generated", "status": "completed", "review_as": kwargs["review_as"], "contract_type": kwargs["contract_type"], } def get_review_detail(self, current_user, review_id): return { "id": review_id, "title": "contract.docx", "contract_type": "procurement", "review_as": "party_a", "status": "completed", "source_type": "file", "source_text_length": 12, "source_text_preview": "采购方与供应商A签订合同", "processing_time_ms": 1200, "created_at": "2026-04-17T00:00:00+00:00", "completed_at": "2026-04-17T00:00:01+00:00", "error_message": None, "current_stage": "report", "current_stage_label": "报告已生成", "status_detail": "文本提取、规则审查与报告阶段已完成,可继续查看详情和导出报告。", "failure_reason": None, "next_action": "查看风险详情,必要时导出报告并继续进入审查助手追问。", "can_retry": False, "workflow_state": { "review_id": review_id, "risk_level": "high", "status": "completed", "current_stage": "report", }, "ai_trace": { "review_id": review_id, "status": "completed", "provider": "stub", }, "source_document": { "document_id": "file-generated-123", "file_name": "contract.docx", "source_type": "file", "parse_status": "completed", "extraction_mode": "template_render", "ocr_gate_status": "PASS", "text_length": 12, "preview": "采购方与供应商A签订合同", }, } def get_review_result(self, current_user, review_id): return { "basic_info": { "contract_type": "procurement", "party_a": "甲方", "party_b": "乙方", "amount": "待识别", "term": "待识别", }, "risk_summary": {"high": 1, "medium": 0, "low": 0, "overall": "high"}, "risk_items": [{"title": "付款条件建议明确", "level": "high", "description": "desc"}], "review_opinion": "检测到高风险条款", "recommendation": "manual_review", } def get_review_report(self, current_user, review_id): return { "file_name": f"review-report-{review_id}.pdf", "download_url": f"/api/v1/reviews/{review_id}/report/download", } self.review_service = StubReviewService() self.service.review_service = self.review_service self.ContractTemplate = ContractTemplate def tearDown(self): self.db.close() self.engine.dispose() def test_generate_contract_accepts_json_column_variable_definitions(self): template = self.ContractTemplate( id="tpl-real-json", tenant_id="tenant-demo-001", name="采购合同模板", contract_type="procurement", description="验证 JSON 列变量", content="采购方与{{supplier_name}}签订合同", variables_json=[ { "name": "supplier_name", "label": "供应商名称", "required": True, "default_value": "", "description": "填写供应商名称", } ], enabled=True, created_by="seed", updated_by="seed", ) self.db.add(template) self.db.commit() result = self.service.generate_contract( current_user={ "id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "管理员", }, template_id="tpl-real-json", variable_values={"supplier_name": "供应商A"}, ) self.assertEqual(result["template_id"], "tpl-real-json") self.assertEqual(result["variable_values"]["supplier_name"], "供应商A") self.assertEqual(self.review_service.create_calls[0]["source_file_id"], "file-generated-123") self.assertEqual(result["auto_review"]["status"], "completed") self.assertEqual(result["auto_review"]["chat_url"].startswith("/chat?review="), True) def test_get_generated_contract_download_allows_owner_access_to_file_payload(self): generated_contract = type( "GeneratedContractRecord", (), { "id": "contract-owner-001", "tenant_id": "tenant-demo-001", "file_id": "file-owner-001", "file_name": "contract.txt", "mime_type": "text/plain", "file_content_base64": "", }, )() captured = {} self.service.generated_contract_repository = type( "StubGeneratedContractRepository", (), {"get_by_id": lambda _self, contract_id, tenant_id: generated_contract}, )() self.service.file_service = type( "StubFileService", (), { "get_download_payload": lambda _self, current_user, file_id, allow_owner_access=False: captured.setdefault( "payload", { "current_user": current_user, "file_id": file_id, "allow_owner_access": allow_owner_access, "file_name": "contract.txt", "mime_type": "text/plain", "content": b"contract-bytes", }, ) }, )() result = self.service.get_generated_contract_download( current_user={"id": "user-member", "tenant_id": "tenant-demo-001", "role": "member"}, contract_id="contract-owner-001", ) self.assertEqual(result["file_name"], "contract.txt") self.assertEqual(captured["payload"]["file_id"], "file-owner-001") self.assertTrue(captured["payload"]["allow_owner_access"]) if __name__ == "__main__": unittest.main()