import unittest from datetime import datetime, timedelta, timezone from fastapi import HTTPException from fastapi.testclient import TestClient from app.api.contracts import get_audit_log_service, get_contract_generation_service, get_tenant_service, require_current_user from app.main import app from app.services.contract_generation_service import ExportFormat class StubAuditLogService: def __init__(self): self.operations = [] def record_event(self, **kwargs): self.operations.append(kwargs) return kwargs class StubTenantService: def __init__(self, blocked_action=None): self.blocked_action = blocked_action self.calls = [] def assert_billing_allows(self, current_user, action, requested_file_size_bytes=0): self.calls.append((action, requested_file_size_bytes)) if action == self.blocked_action: raise HTTPException(status_code=402, detail="quota exceeded") class StubContractGenerationService: def _auto_review_summary(self, review_id="review-auto-123", status="completed"): return { "review_id": review_id, "review_title": "Procurement Template", "status": status, "review_as": "party_a", "contract_type": "procurement", "risk_level": "high", "current_stage": "report", "current_stage_label": "Report generated", "status_detail": "Auto review completed", "failure_reason": None, "next_action": "Open review report", "can_retry": False, "review_opinion": "High risk terms found", "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 _record(self, record_id, tenant_id="tenant-demo-001", contract_type="procurement", document_type_code="procurement_goods"): return { "id": record_id, "template_id": "tpl-123", "template_name": "Procurement Template", "contract_type": contract_type, "document_type_code": document_type_code, "generated_content": "Contract with Supplier A", "generated_html": "
Contract with Supplier A
", "variable_values": {"supplier_name": "Supplier A"}, "elements_snapshot": None, "missing_fields": [], "ai_hints": [], "related_legal_articles": [], "generated_at": "2026-04-17T00:00:00+00:00", "export_format": "pdf", "file_name": "contract.pdf", "file_size": 128, "preview_url": f"/api/v1/contracts/{record_id}/preview", "download_url": f"/api/v1/contracts/{record_id}/download", "auto_review": self._auto_review_summary(f"review-{record_id}"), "lifecycle": { "status": "generated", "counterparty_name": None, "owner_name": None, "effective_at": None, "expires_at": None, "days_to_expiry": None, "due_status": "not_set", "note": None, "updated_by": None, "updated_at": None, }, "metadata": { "generated_by": "user-admin", "generated_by_name": "Admin", "tenant_id": tenant_id, "original_template": "Procurement Template", "document_type_code": document_type_code, "export_options": {"watermark": True}, "variables_used": 1, "asset_file_id": "file-generated-123", "missing_field_count": 0, "related_legal_article_count": 0, "generation_source": None, }, "tenant_id": tenant_id, } def list_generated_contracts(self, current_user, page, page_size, contract_type=None, document_type_code=None, lifecycle_status=None, keyword=None): records = [ self._record("contract-123"), self._record("contract-456", contract_type="employment", document_type_code="labor_contract"), self._record("contract-other-tenant", tenant_id="tenant-other-001"), ] 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 document_type_code: tenant_records = [record for record in tenant_records if record["document_type_code"] == document_type_code] if lifecycle_status: tenant_records = [record for record in tenant_records if record["lifecycle"]["status"] == lifecycle_status] 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 return { "items": [{key: value for key, value in item.items() if key != "tenant_id"} for item in tenant_records[start : start + page_size]], "total": len(tenant_records), "page": page, "page_size": page_size, } def update_generated_contract_lifecycle( self, current_user, contract_id, lifecycle_status, counterparty_name=None, owner_name=None, effective_at=None, expires_at=None, lifecycle_note=None, ): record = self.get_generated_contract(current_user, contract_id) record["lifecycle"] = { "status": lifecycle_status, "counterparty_name": counterparty_name, "owner_name": owner_name, "effective_at": effective_at.isoformat() if effective_at else None, "expires_at": expires_at.isoformat() if expires_at else None, "days_to_expiry": 20, "due_status": "due_soon", "note": lifecycle_note, "updated_by": current_user["id"], "updated_at": "2026-05-21T00:00:00+00:00", } return record def _lifecycle_task(self, contract_id="contract-123", task_id="clt-001", status_value="open"): return { "id": task_id, "tenant_id": "tenant-demo-001", "contract_id": contract_id, "task_key": "renewal:contract-123", "task_type": "renewal_reminder", "title": "Renewal reminder", "description": "Confirm renewal decision", "priority": "high", "status": status_value, "due_at": "2026-06-01T00:00:00+00:00", "due_status": "due_soon", "days_to_due": 7, "completed_by": "user-admin" if status_value == "completed" else None, "completed_at": "2026-05-21T00:00:00+00:00" if status_value == "completed" else None, "evidence_file_id": "file-evidence-001" if status_value == "completed" else None, "evidence_file": { "id": "file-evidence-001", "file_name": "termination-proof.pdf", "file_type": "contract_lifecycle_evidence", "mime_type": "application/pdf", "file_size": 128, "download_url": "/api/v1/files/file-evidence-001/download", "created_at": "2026-05-21T00:00:00+00:00", } if status_value == "completed" else None, "evidence_note": "Archive proof" if status_value == "completed" else None, "metadata": {"source": "manual"}, "created_by": "user-admin", "created_at": "2026-05-21T00:00:00+00:00", "updated_at": "2026-05-21T00:00:00+00:00", } def list_contract_lifecycle_tasks(self, current_user, contract_id, status_value=None, task_type=None): self.get_generated_contract(current_user, contract_id) task = self._lifecycle_task(contract_id=contract_id) if status_value and task["status"] != status_value: items = [] elif task_type and task["task_type"] != task_type: items = [] else: items = [task] return { "contract_id": contract_id, "items": items, "total": len(items), "summary": {"open": len(items), "in_progress": 0, "completed": 0, "skipped": 0, "archived": 0, "overdue": 0, "due_soon": len(items), "with_evidence": 0}, } def sync_contract_lifecycle_tasks(self, current_user, contract_id): result = self.list_contract_lifecycle_tasks(current_user, contract_id) result["synced"] = True result["reason"] = "renewal_reminder_synced" return result def create_contract_lifecycle_task( self, current_user, contract_id, task_type, title, description=None, priority="medium", status_value="open", due_at=None, evidence_file_id=None, evidence_note=None, ): self.get_generated_contract(current_user, contract_id) task = self._lifecycle_task(contract_id=contract_id, task_id="clt-created", status_value=status_value) task["task_type"] = task_type task["title"] = title task["description"] = description task["priority"] = priority task["due_at"] = due_at.isoformat() if due_at else None task["evidence_file_id"] = evidence_file_id task["evidence_note"] = evidence_note return task def update_contract_lifecycle_task( self, current_user, contract_id, task_id, task_type=None, title=None, description=None, priority=None, status_value=None, due_at=None, evidence_file_id=None, evidence_note=None, ): self.get_generated_contract(current_user, contract_id) task = self._lifecycle_task(contract_id=contract_id, task_id=task_id, status_value=status_value or "open") if task_type is not None: task["task_type"] = task_type if title is not None: task["title"] = title if description is not None: task["description"] = description if priority is not None: task["priority"] = priority if due_at is not None: task["due_at"] = due_at.isoformat() if evidence_file_id is not None: task["evidence_file_id"] = evidence_file_id if evidence_note is not None: task["evidence_note"] = evidence_note return task def list_available_templates(self, current_user, contract_type, document_type_code=None, template_category=None, industry_category=None): return [ { "id": "tpl-123", "name": "Procurement Template", "contract_type": contract_type or "procurement", "document_type_code": document_type_code or "procurement_goods", "description": "Standard procurement contract", "variables": [{"name": "supplier_name", "label": "Supplier", "required": True}], "preview_excerpt": "Buyer purchases goods from supplier.", } ] def preview_contract(self, current_user, template_id, variable_values): if template_id == "tpl-missing": raise HTTPException(status_code=404, detail="template not found") return { "preview_content": f"Contract with {variable_values.get('supplier_name', '')}", "template_name": "Procurement Template", "variable_summary": [ { "name": "supplier_name", "label": "Supplier", "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="contract not found") return records[0] def list_generated_contract_versions(self, current_user, contract_id): self.get_generated_contract(current_user, contract_id) return { "contract_id": contract_id, "base_version": {"version_number": 1, "version_label": "v1", "content": "Contract with Supplier A"}, "items": [ { "id": "gcv-001", "contract_id": contract_id, "version_number": 2, "version_label": "v2", "content": "Contract with Supplier A. Payment deadline added.", "locate_status": "applied", "detail_url": f"/contracts/{contract_id}?version=gcv-001", } ], "total": 1, } def get_generated_contract_version(self, current_user, contract_id, version_id): versions = self.list_generated_contract_versions(current_user, contract_id) for item in versions["items"]: if item["id"] == version_id: return item raise HTTPException(status_code=404, detail="contract version not found") def diff_generated_contract_versions(self, current_user, contract_id, from_version_id=None, to_version_id=None): self.get_generated_contract(current_user, contract_id) return { "contract_id": contract_id, "from_version": {"id": "base", "version_number": 1, "version_label": "v1"}, "to_version": {"id": to_version_id or "gcv-001", "version_number": 2, "version_label": "v2"}, "summary": { "added": 1, "removed": 0, "modified": 1, "unchanged": 1, "change_count": 2, "from_paragraph_count": 2, "to_paragraph_count": 3, }, "items": [ {"change_type": "modified", "old_paragraph_index": 1, "new_paragraph_index": 1, "old_text": "Old", "new_text": "New"}, {"change_type": "added", "old_paragraph_index": None, "new_paragraph_index": 3, "old_text": None, "new_text": "Payment deadline added."}, ], } def export_generated_contract_version(self, current_user, contract_id, version_id=None, export_format=ExportFormat.DOCX): self.get_generated_contract(current_user, contract_id) return { "id": contract_id, "contract_id": contract_id, "version": {"id": version_id or "latest", "version_number": 2, "version_label": "v2"}, "file_name": "contract-v2.docx", "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "content": b"docx-bytes", "file_size": 10, "export_format": export_format.value, } def create_generated_contract_version( self, current_user, contract_id, content, change_type="editor_save", change_summary=None, source_version_id=None, original_text=None, replacement_text=None, locate_status="applied", ): self.get_generated_contract(current_user, contract_id) return { "id": "gcv-editor-001", "tenant_id": current_user["tenant_id"], "contract_id": contract_id, "review_id": None, "review_risk_action_id": None, "version_number": 3, "version_label": "v3", "source_version_id": source_version_id or "gcv-001", "change_type": change_type, "change_summary": change_summary or "Editor save", "original_text": original_text, "replacement_text": replacement_text, "content": content, "locate_status": locate_status, "created_by": current_user["id"], "created_at": "2026-05-20T00:00:00+00:00", "detail_url": f"/contracts/{contract_id}?version=gcv-editor-001", } def generate_contract( self, current_user, template_id, variable_values, export_format, options, document_type_code=None, elements_snapshot=None, element_schema_id=None, element_schema_version_label=None, fill_missing_variables=False, ): if template_id == "tpl-missing": raise HTTPException(status_code=404, detail="template not found") return { "id": "contract-123", "template_id": template_id, "template_name": "Procurement Template", "contract_type": "procurement", "document_type_code": document_type_code or "procurement_goods", "generated_content": f"Contract with {variable_values.get('supplier_name', '')}", "generated_html": "
Contract
", "variable_values": variable_values, "elements_snapshot": elements_snapshot, "missing_fields": [], "ai_hints": [], "related_legal_articles": [], "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"), "lifecycle": { "status": "generated", "counterparty_name": None, "owner_name": None, "effective_at": None, "expires_at": None, "days_to_expiry": None, "due_status": "not_set", "note": None, "updated_by": None, "updated_at": None, }, "metadata": { "generated_by": current_user["id"], "generated_by_name": current_user.get("display_name", "Admin"), "tenant_id": current_user["tenant_id"], "original_template": "Procurement Template", "document_type_code": document_type_code or "procurement_goods", "elements_snapshot": elements_snapshot, "element_schema_id": element_schema_id, "element_schema_version_label": element_schema_version_label, "export_options": options, "variables_used": len(variable_values), "missing_field_count": 0, "related_legal_article_count": 0, }, } def generate_contract_from_elements( self, current_user, document_type_code, elements, export_format, options, template_id=None, variable_values=None, ): result = self.generate_contract( current_user=current_user, template_id=template_id or "tpl-auto-selected", variable_values={"supplier_name": (elements.get("party_b") or {}).get("name", "seller"), **(variable_values or {})}, export_format=export_format, options=options, document_type_code=document_type_code, elements_snapshot=elements, element_schema_id="ldes-auto-v1", element_schema_version_label="v1.0", fill_missing_variables=True, ) result["missing_fields"] = [{"field_key": "payment.terms", "label": "Payment terms", "placeholder": "please fill"}] result["ai_hints"] = [{"code": "missing_required_fields", "level": "warning", "message": "Fields still need input"}] result["related_legal_articles"] = [{"source_id": "lart-civil-585", "title": "Civil Code Article 585"}] result["metadata"]["missing_field_count"] = 1 result["metadata"]["related_legal_article_count"] = 1 result["metadata"]["generation_source"] = {"mode": "structured_elements", "template_selection": "auto"} return result def get_generated_contract_preview(self, current_user, contract_id): if contract_id == "contract-missing": raise HTTPException(status_code=404, detail="contract not found") return { "id": contract_id, "template_name": "Procurement Template", "contract_type": "procurement", "preview_content": "Contract with Supplier 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="contract not found") 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() self.tenant_service = StubTenantService() app.dependency_overrides[get_contract_generation_service] = lambda: StubContractGenerationService() app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service app.dependency_overrides[get_tenant_service] = lambda: self.tenant_service self.client = TestClient(app) def tearDown(self): app.dependency_overrides.clear() def _login(self, tenant_id="tenant-demo-001"): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", "tenant_id": tenant_id, "display_name": "Admin", "role": "admin", } def test_user_can_list_contract_templates(self): self._login() response = self.client.get("/api/v1/contracts/templates", params={"contract_type": "procurement"}) self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["data"][0]["document_type_code"], "procurement_goods") def test_user_can_list_generated_contracts_with_filters(self): self._login() response = self.client.get("/api/v1/contracts", params={"document_type_code": "labor_contract"}) self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["data"]["total"], 1) self.assertEqual(body["data"]["items"][0]["document_type_code"], "labor_contract") self.assertEqual(body["data"]["items"][0]["lifecycle"]["status"], "generated") def test_user_can_update_contract_lifecycle_and_write_audit_log(self): self._login() response = self.client.patch( "/api/v1/contracts/contract-123/lifecycle", json={ "lifecycle_status": "effective", "counterparty_name": "Supplier A", "owner_name": "Legal Ops", "effective_at": "2026-05-21T00:00:00+08:00", "expires_at": "2026-06-10T00:00:00+08:00", "lifecycle_note": "已签署,进入履约跟踪。", }, ) self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["data"]["lifecycle"]["status"], "effective") self.assertEqual(body["data"]["lifecycle"]["counterparty_name"], "Supplier A") self.assertEqual(body["data"]["lifecycle"]["due_status"], "due_soon") self.assertEqual(self.audit_log_service.operations[0]["action"], "contract_lifecycle_update") self.assertEqual(self.audit_log_service.operations[0]["detail_json"]["counterparty_name"], "Supplier A") def test_user_can_manage_contract_lifecycle_tasks_and_write_audit_logs(self): self._login() listed = self.client.get("/api/v1/contracts/contract-123/lifecycle/tasks") self.assertEqual(listed.status_code, 200) self.assertEqual(listed.json()["data"]["items"][0]["task_type"], "renewal_reminder") synced = self.client.post("/api/v1/contracts/contract-123/lifecycle/tasks/sync") self.assertEqual(synced.status_code, 200) self.assertTrue(synced.json()["data"]["synced"]) self.assertEqual(self.audit_log_service.operations[-1]["action"], "contract_lifecycle_task_sync") created = self.client.post( "/api/v1/contracts/contract-123/lifecycle/tasks", json={ "task_type": "performance_milestone", "title": "验收节点", "description": "确认验收材料", "priority": "high", "status_value": "open", "due_at": "2026-06-01T00:00:00+08:00", }, ) self.assertEqual(created.status_code, 200) self.assertEqual(created.json()["data"]["task_type"], "performance_milestone") self.assertEqual(self.audit_log_service.operations[-1]["action"], "contract_lifecycle_task_create") updated = self.client.put( "/api/v1/contracts/contract-123/lifecycle/tasks/clt-created", json={ "status_value": "completed", "evidence_file_id": "file-evidence-001", "evidence_note": "终止归档证据已绑定", }, ) self.assertEqual(updated.status_code, 200) self.assertEqual(updated.json()["data"]["status"], "completed") self.assertEqual(updated.json()["data"]["evidence_file_id"], "file-evidence-001") self.assertEqual(self.audit_log_service.operations[-1]["action"], "contract_lifecycle_task_update") def test_generated_contracts_are_tenant_isolated(self): self._login("tenant-other-001") response = self.client.get("/api/v1/contracts") self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["data"]["items"][0]["id"], "contract-other-tenant") def test_user_can_get_generated_contract_detail(self): self._login() 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"]["metadata"]["document_type_code"], "procurement_goods") self.assertIn("generated_content", body["data"]) def test_user_can_list_and_get_generated_contract_versions(self): self._login() response = self.client.get("/api/v1/contracts/contract-123/versions") self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["data"]["base_version"]["version_label"], "v1") self.assertEqual(body["data"]["items"][0]["version_label"], "v2") detail = self.client.get("/api/v1/contracts/contract-123/versions/gcv-001") self.assertEqual(detail.status_code, 200) self.assertEqual(detail.json()["data"]["id"], "gcv-001") def test_user_can_diff_and_download_generated_contract_version(self): self._login() diff_response = self.client.get("/api/v1/contracts/contract-123/versions/diff") self.assertEqual(diff_response.status_code, 200) diff_body = diff_response.json()["data"] self.assertEqual(diff_body["summary"]["change_count"], 2) self.assertEqual(diff_body["items"][0]["change_type"], "modified") self.assertEqual(self.audit_log_service.operations[-1]["action"], "contract_version_diff") download_response = self.client.get("/api/v1/contracts/contract-123/versions/gcv-001/download") self.assertEqual(download_response.status_code, 200) self.assertEqual( download_response.headers["content-type"], "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ) self.assertEqual(download_response.content, b"docx-bytes") self.assertEqual(self.audit_log_service.operations[-1]["action"], "download_contract_version") def test_user_can_create_generated_contract_version_and_write_audit_log(self): self._login() response = self.client.post( "/api/v1/contracts/contract-123/versions", json={ "content": "Contract with Supplier A. Acceptance clause added.", "change_type": "clause_recommendation", "change_summary": "Apply acceptance clause", "original_text": "", "replacement_text": "Acceptance clause added.", }, ) self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["data"]["version_label"], "v3") self.assertEqual(body["data"]["change_type"], "clause_recommendation") self.assertEqual(self.audit_log_service.operations[0]["action"], "contract_version_create") self.assertEqual(self.audit_log_service.operations[0]["detail_json"]["contract_id"], "contract-123") def test_user_can_generate_contract_and_write_audit_log(self): self._login() response = self.client.post( "/api/v1/contracts/generate", data={ "template_id": "tpl-123", "variable_values_json": '{"supplier_name":"Supplier 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"]["metadata"]["export_options"], {"watermark": True}) self.assertEqual([call[0] for call in self.tenant_service.calls], ["generation", "review"]) self.assertEqual(self.audit_log_service.operations[0]["action"], "contract_generation") def test_user_can_generate_contract_from_elements_with_auto_template(self): self._login() response = self.client.post( "/api/v1/contracts/generate", data={ "document_type_code": "procurement_goods", "elements_json": '{"party_a":{"name":"buyer"},"party_b":{"name":"seller"},"goods":{"name":"server"}}', "export_format": "html", }, ) self.assertEqual(response.status_code, 200) body = response.json() self.assertEqual(body["data"]["template_id"], "tpl-auto-selected") self.assertEqual(body["data"]["missing_fields"][0]["field_key"], "payment.terms") self.assertEqual(body["data"]["related_legal_articles"][0]["source_id"], "lart-civil-585") self.assertEqual(self.audit_log_service.operations[0]["detail_json"]["missing_field_count"], 1) def test_generate_contract_returns_billing_block_message(self): self._login() app.dependency_overrides[get_tenant_service] = lambda: StubTenantService(blocked_action="generation") response = self.client.post( "/api/v1/contracts/generate", data={ "template_id": "tpl-123", "variable_values_json": '{"supplier_name":"Supplier A"}', "export_format": "pdf", }, ) self.assertEqual(response.status_code, 402) def test_user_can_get_preview_download_and_error_responses(self): self._login() preview = self.client.post( "/api/v1/contracts/preview", data={"template_id": "tpl-123", "variable_values_json": '{"supplier_name":"Supplier A"}'}, ) self.assertEqual(preview.status_code, 200) self.assertIn("Supplier A", preview.json()["data"]["preview_content"]) download = self.client.get("/api/v1/contracts/contract-123/download") self.assertEqual(download.status_code, 200) self.assertEqual(download.content, b"pdf-bytes") self.assertEqual(self.audit_log_service.operations[-1]["action"], "download_contract") invalid_json = self.client.post( "/api/v1/contracts/preview", data={"template_id": "tpl-123", "variable_values_json": "{bad json}"}, ) self.assertEqual(invalid_json.status_code, 400) invalid_format = self.client.post( "/api/v1/contracts/generate", data={"template_id": "tpl-123", "variable_values_json": '{"supplier_name":"Supplier A"}', "export_format": "xlsx"}, ) self.assertEqual(invalid_format.status_code, 400) missing_template = self.client.post( "/api/v1/contracts/generate", data={"template_id": "tpl-missing", "variable_values_json": '{"supplier_name":"Supplier A"}'}, ) self.assertEqual(missing_template.status_code, 404) class ContractGenerationServiceTestCase(unittest.TestCase): def setUp(self): from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from app.db import Base from app.models.contract_lifecycle_task import ContractLifecycleTask from app.models.contract_template import ContractTemplate from app.models.file import File from app.models.generated_contract import GeneratedContract from app.models.generated_contract_version import GeneratedContractVersion from app.models.legal_article import LegalArticle from app.models.legal_document_element_schema import LegalDocumentElementSchema from app.models.legal_document_type import LegalDocumentType 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.ContractTemplate = ContractTemplate self.ContractLifecycleTask = ContractLifecycleTask self.File = File self.GeneratedContract = GeneratedContract self.GeneratedContractVersion = GeneratedContractVersion self.LegalArticle = LegalArticle self.LegalDocumentType = LegalDocumentType self.LegalDocumentElementSchema = LegalDocumentElementSchema 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"} def get_review_detail(self, current_user, review_id): return { "id": review_id, "contract_type": "procurement", "review_as": "party_a", "status": "completed", "current_stage": "report", "current_stage_label": "Report generated", "status_detail": "done", "workflow_state": {"risk_level": "low"}, "ai_trace": {"status": "completed"}, "source_document": {"file_name": "contract.docx"}, } def get_review_result(self, current_user, review_id): return { "basic_info": {"contract_type": "procurement"}, "risk_summary": {"overall": "low"}, "risk_items": [], "review_opinion": "ok", "recommendation": "approved", } 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.current_user = {"id": "user-admin", "tenant_id": "tenant-demo-001", "display_name": "Admin", "role": "admin"} def tearDown(self): self.db.close() self.engine.dispose() def _seed_law_doc_004_data(self): now = datetime.now(timezone.utc) self.db.add( self.LegalDocumentType( id="ldt-procurement", code="procurement_goods", name="Procurement goods", category="contract", industry="trade", scenario="purchase", risk_level="medium", is_active=True, sort_order=10, created_at=now, updated_at=now, ) ) self.db.add( self.LegalDocumentElementSchema( id="ldes-procurement-v1", document_type_code="procurement_goods", version_label="v1.0", schema_json={ "sections": [ { "key": "base", "title": "Base", "fields": [ {"key": "party_a.name", "label": "Buyer", "type": "text", "required": True}, {"key": "party_b.name", "label": "Supplier", "type": "text", "required": True}, {"key": "goods.name", "label": "Goods", "type": "text", "required": True}, {"key": "price.total", "label": "Amount", "type": "money", "required": True}, {"key": "payment.terms", "label": "Payment", "type": "textarea", "required": True}, ], } ] }, status="published", published_at=now, published_by="seed", created_by="seed", updated_by="seed", created_at=now, updated_at=now, ) ) self.db.add( self.ContractTemplate( id="tpl-elements", tenant_id="tenant-demo-001", name="Procurement Template", contract_type="procurement", document_type_code="procurement_goods", description="Element based template", content="Buyer {{buyer}} buys {{product_name}} from {{supplier}} for {{amount}}. Payment: {{payment_terms}}. Extra: {{missing_required}}.", variables_json=[ {"name": "buyer", "label": "Buyer", "required": True}, {"name": "supplier", "label": "Supplier", "required": True}, {"name": "product_name", "label": "Goods", "required": True}, {"name": "amount", "label": "Amount", "required": True}, {"name": "payment_terms", "label": "Payment", "required": True}, {"name": "missing_required", "label": "Extra", "required": True}, ], enabled=True, created_by="seed", updated_by="seed", created_at=now, updated_at=now, ) ) self.db.add( self.LegalArticle( id="lart-civil-585", tenant_id="tenant-demo-001", source_id="civil-code", source_title="中华人民共和国民法典", source_type="law", authority="NPC", region="CN", article_no="Article 585", article_text="当事人可以约定一方违约时应当根据违约情况向对方支付一定数额的违约金。", tags_json=["违约", "买卖合同"], index_status="indexed", published_status="published", published_at=now, published_by="seed", created_at=now, updated_at=now, ) ) self.db.commit() def _seed_service_law_doc_data(self): now = datetime.now(timezone.utc) self.db.add( self.LegalDocumentType( id="ldt-service", code="service_general", name="Service general", category="contract", industry="service", scenario="service-delivery", risk_level="medium", is_active=True, sort_order=20, created_at=now, updated_at=now, ) ) self.db.add( self.LegalDocumentElementSchema( id="ldes-service-v1", document_type_code="service_general", version_label="v1.0", schema_json={ "sections": [ { "key": "base", "title": "Base", "fields": [ {"key": "party_a.name", "label": "Buyer", "type": "text", "required": True}, {"key": "party_b.name", "label": "Supplier", "type": "text", "required": True}, {"key": "service.name", "label": "Service", "type": "text", "required": True}, {"key": "service.scope", "label": "Scope", "type": "textarea", "required": True}, {"key": "deliverables", "label": "Deliverables", "type": "textarea", "required": True}, {"key": "service.period", "label": "Period", "type": "text", "required": True}, {"key": "service_fee", "label": "Fee", "type": "money", "required": True}, {"key": "payment_terms", "label": "Payment", "type": "textarea", "required": True}, {"key": "acceptance.standard", "label": "Acceptance", "type": "textarea", "required": True}, {"key": "data.scope", "label": "Data Scope", "type": "textarea", "required": True}, {"key": "confidentiality.scope", "label": "Confidentiality", "type": "textarea", "required": True}, {"key": "breach_liability", "label": "Breach", "type": "textarea", "required": True}, { "key": "dispute_resolution.method", "label": "Dispute", "type": "select", "required": True, "options": ["仲裁", "诉讼"], }, {"key": "sign_date", "label": "Sign Date", "type": "date", "required": True}, ], } ] }, status="published", published_at=now, published_by="seed", created_by="seed", updated_by="seed", created_at=now, updated_at=now, ) ) self.db.add( self.ContractTemplate( id="tpl-service-elements", tenant_id="tenant-demo-001", name="Service Template", contract_type="service", document_type_code="service_general", description="Element based service template", content=( "Buyer {{buyer}} engages {{supplier}} for {{service_name}}. " "Scope: {{service_scope}}. Deliverables: {{deliverables}}. " "Period: {{service_period}}. Fee: {{service_fee}}. " "Payment: {{payment_terms}}. Data: {{data_scope}}. " "Dispute: {{dispute_resolution}}. Extra: {{missing_required}}." ), variables_json=[ {"name": "buyer", "label": "Buyer", "required": True}, {"name": "supplier", "label": "Supplier", "required": True}, {"name": "service_name", "label": "Service", "required": True}, {"name": "service_scope", "label": "Scope", "required": True}, {"name": "deliverables", "label": "Deliverables", "required": True}, {"name": "service_period", "label": "Period", "required": True}, {"name": "service_fee", "label": "Fee", "required": True}, {"name": "payment_terms", "label": "Payment", "required": True}, {"name": "acceptance_standard", "label": "Acceptance", "required": True}, {"name": "data_scope", "label": "Data Scope", "required": True}, {"name": "confidentiality_scope", "label": "Confidentiality", "required": True}, {"name": "breach_liability", "label": "Breach", "required": True}, {"name": "dispute_resolution", "label": "Dispute", "required": True}, {"name": "sign_date", "label": "Sign Date", "required": True}, {"name": "missing_required", "label": "Extra", "required": True}, ], enabled=True, created_by="seed", updated_by="seed", created_at=now, updated_at=now, ) ) self.db.add( self.LegalArticle( id="lart-service-509", tenant_id="tenant-demo-001", source_id="lart-service-509", source_title="中华人民共和国民法典", source_type="law", authority="NPC", region="CN", article_no="Article 509", article_text="当事人应当按照约定全面履行自己的义务。服务合同应明确服务范围、验收、数据处理和违约责任。", tags_json=["服务合同", "数据处理", "验收"], index_status="indexed", published_status="published", published_at=now, published_by="seed", created_at=now, updated_at=now, ) ) self.db.commit() def test_generate_contract_accepts_json_column_variable_definitions(self): now = datetime.now(timezone.utc) template = self.ContractTemplate( id="tpl-real-json", tenant_id="tenant-demo-001", name="Procurement Template", contract_type="procurement", description="JSON variables", content="Contract with {{supplier_name}}", variables_json=[{"name": "supplier_name", "label": "Supplier", "required": True}], enabled=True, created_by="seed", updated_by="seed", created_at=now, updated_at=now, ) self.db.add(template) self.db.commit() result = self.service.generate_contract( current_user=self.current_user, template_id="tpl-real-json", variable_values={"supplier_name": "Supplier A"}, ) self.assertEqual(result["template_id"], "tpl-real-json") self.assertEqual(result["variable_values"]["supplier_name"], "Supplier 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["metadata"]["prompt_runtime"]["contract_generation"]["prompt_version"], "contract_generation.template_render.v1", ) def test_generate_contract_from_elements_auto_selects_template_and_returns_law_doc_004_payload(self): self._seed_law_doc_004_data() result = self.service.generate_contract_from_elements( current_user=self.current_user, document_type_code="procurement_goods", elements={ "party_a": {"name": "Buyer Co"}, "party_b": {"name": "Supplier Co"}, "goods": {"name": "Servers"}, "price": {"total": "180000"}, }, export_format=ExportFormat.HTML, ) self.assertEqual(result["template_id"], "tpl-elements") self.assertEqual(result["document_type_code"], "procurement_goods") self.assertIn("missing-fields", result["generated_html"]) self.assertIn("payment.terms", [item["field_key"] for item in result["missing_fields"]]) self.assertIn("missing_required", [item["field_key"] for item in result["missing_fields"]]) self.assertEqual(result["related_legal_articles"][0]["source_id"], "lart-civil-585") self.assertTrue(result["ai_hints"]) self.assertIn("generated_html", result) self.assertEqual(result["metadata"]["generation_source"]["template_selection"], "auto") self.assertEqual(self.review_service.create_calls[-1]["extraction_metadata"]["missing_field_count"], len(result["missing_fields"])) def test_generate_contract_from_elements_auto_selects_service_template_and_returns_law_doc_payload(self): self._seed_service_law_doc_data() result = self.service.generate_contract_from_elements( current_user=self.current_user, document_type_code="service_general", elements={ "party_a": {"name": "Buyer Co"}, "party_b": {"name": "Supplier Co"}, "service": { "name": "Operations Support Service", "scope": "Operation support and weekly reporting", "period": "2026-06-01 to 2027-05-31", }, "deliverables": "Weekly reports and monthly recap", "service_fee": "360000", "payment_terms": "Quarterly in arrears", "acceptance": {"standard": "Reports are complete and traceable"}, "data": {"scope": "Only work order ids and contact details"}, "confidentiality": {"scope": "Business data, customer data, and pricing"}, "breach_liability": "Delay, leakage, or unauthorized subcontracting must be compensated", "dispute_resolution": {"method": "诉讼"}, "sign_date": "2026-05-23", }, export_format=ExportFormat.HTML, ) self.assertEqual(result["template_id"], "tpl-service-elements") self.assertEqual(result["document_type_code"], "service_general") self.assertIn("missing-fields", result["generated_html"]) self.assertIn("missing_required", [item["field_key"] for item in result["missing_fields"]]) self.assertEqual(result["related_legal_articles"][0]["source_id"], "lart-service-509") self.assertEqual(result["metadata"]["generation_source"]["template_selection"], "auto") self.assertEqual(self.review_service.create_calls[-1]["contract_type"], "service") self.assertEqual(self.review_service.create_calls[-1]["extraction_metadata"]["missing_field_count"], len(result["missing_fields"])) 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.assertTrue(captured["payload"]["allow_owner_access"]) def test_create_generated_contract_version_saves_editor_content(self): now = datetime.now(timezone.utc) self.db.add( self.ContractTemplate( id="tpl-editor", tenant_id="tenant-demo-001", name="Editor Template", contract_type="procurement", document_type_code="procurement_goods", description="Editor template", content="Original contract content.", variables_json=[], enabled=True, created_by="seed", updated_by="seed", created_at=now, updated_at=now, ) ) self.db.add( self.GeneratedContract( id="contract-editor-001", tenant_id="tenant-demo-001", template_id="tpl-editor", template_name="Editor Template", contract_type="procurement", document_type_code="procurement_goods", generated_by="user-admin", generated_by_name="Admin", export_format="docx", file_id=None, file_name="editor.docx", mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", file_size=10, generated_content="Original contract content.", variable_values_json={}, export_options_json={}, file_content_base64="", status="generated", created_at=now, ) ) self.db.commit() result = self.service.create_generated_contract_version( current_user=self.current_user, contract_id="contract-editor-001", content="Original contract content.\nAcceptance clause added.", change_type="clause_recommendation", change_summary="Apply acceptance clause", replacement_text="Acceptance clause added.", ) self.assertEqual(result["version_label"], "v2") self.assertEqual(result["change_type"], "clause_recommendation") self.assertIn("Acceptance clause added", result["content"]) def test_create_generated_contract_version_rejects_unchanged_content(self): now = datetime.now(timezone.utc) self.db.add( self.GeneratedContract( id="contract-editor-unchanged", tenant_id="tenant-demo-001", template_id="tpl-editor", template_name="Editor Template", contract_type="procurement", document_type_code="procurement_goods", generated_by="user-admin", generated_by_name="Admin", export_format="docx", file_id=None, file_name="editor.docx", mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", file_size=10, generated_content="Original contract content.", variable_values_json={}, export_options_json={}, file_content_base64="", status="generated", created_at=now, ) ) self.db.commit() with self.assertRaises(HTTPException) as ctx: self.service.create_generated_contract_version( current_user=self.current_user, contract_id="contract-editor-unchanged", content="Original contract content.", ) self.assertEqual(ctx.exception.status_code, 409) def test_diff_generated_contract_versions_returns_paragraph_changes(self): now = datetime.now(timezone.utc) self.db.add( self.GeneratedContract( id="contract-diff-001", tenant_id="tenant-demo-001", template_id="tpl-diff", template_name="Diff Template", contract_type="procurement", document_type_code="procurement_goods", generated_by="user-admin", generated_by_name="Admin", export_format="docx", file_id=None, file_name="diff.docx", mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", file_size=10, generated_content="第一条 标的\n第二条 价款", variable_values_json={}, export_options_json={}, file_content_base64="", status="generated", created_at=now, ) ) self.db.add( self.GeneratedContractVersion( id="gcv-diff-002", tenant_id="tenant-demo-001", contract_id="contract-diff-001", version_number=2, version_label="v2", change_type="editor_save", change_summary="补充验收", content="第一条 标的\n第二条 价款\n第三条 验收", locate_status="applied", created_by="user-admin", created_at=now + timedelta(minutes=1), ) ) self.db.commit() result = self.service.diff_generated_contract_versions( current_user=self.current_user, contract_id="contract-diff-001", ) self.assertEqual(result["from_version"]["version_label"], "v1") self.assertEqual(result["to_version"]["version_label"], "v2") self.assertEqual(result["summary"]["added"], 1) self.assertEqual(result["items"][0]["change_type"], "added") self.assertIn("第三条 验收", result["items"][0]["new_text"]) def test_export_generated_contract_version_returns_docx_payload(self): now = datetime.now(timezone.utc) self.db.add( self.GeneratedContract( id="contract-export-version-001", tenant_id="tenant-demo-001", template_id="tpl-export", template_name="Export Template", contract_type="procurement", document_type_code="procurement_goods", generated_by="user-admin", generated_by_name="Admin", export_format="docx", file_id=None, file_name="export.docx", mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", file_size=10, generated_content="第一条 标的\n第二条 价款", variable_values_json={}, export_options_json={}, file_content_base64="", status="generated", created_at=now, ) ) self.db.add( self.GeneratedContractVersion( id="gcv-export-002", tenant_id="tenant-demo-001", contract_id="contract-export-version-001", version_number=2, version_label="v2", change_type="editor_save", change_summary="补充交付", content="第一条 标的\n第二条 价款\n第三条 交付", locate_status="applied", created_by="user-admin", created_at=now + timedelta(minutes=1), ) ) self.db.commit() result = self.service.export_generated_contract_version( current_user=self.current_user, contract_id="contract-export-version-001", version_id="gcv-export-002", ) self.assertEqual(result["export_format"], "docx") self.assertEqual(result["version"]["version_label"], "v2") self.assertTrue(result["file_name"].endswith(".docx")) self.assertTrue(result["content"].startswith(b"PK")) def test_update_generated_contract_lifecycle_tracks_expiry_and_owner(self): now = datetime.now(timezone.utc) self.db.add( self.GeneratedContract( id="contract-lifecycle-001", tenant_id="tenant-demo-001", template_id="tpl-lifecycle", template_name="Lifecycle Template", contract_type="procurement", document_type_code="procurement_goods", generated_by="user-admin", generated_by_name="Admin", export_format="docx", file_id=None, file_name="lifecycle.docx", mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", file_size=10, generated_content="Lifecycle contract content.", variable_values_json={}, export_options_json={}, file_content_base64="", status="generated", created_at=now, ) ) self.db.commit() result = self.service.update_generated_contract_lifecycle( current_user=self.current_user, contract_id="contract-lifecycle-001", lifecycle_status="effective", counterparty_name="Supplier A", owner_name="Legal Ops", effective_at=now, expires_at=now + timedelta(days=20), lifecycle_note="已签署,进入履约跟踪。", ) self.assertEqual(result["lifecycle"]["status"], "effective") self.assertEqual(result["lifecycle"]["counterparty_name"], "Supplier A") self.assertEqual(result["lifecycle"]["owner_name"], "Legal Ops") self.assertEqual(result["lifecycle"]["due_status"], "due_soon") self.assertEqual(result["lifecycle"]["days_to_expiry"], 20) self.assertEqual(result["lifecycle"]["updated_by"], "user-admin") def test_sync_contract_lifecycle_tasks_creates_renewal_reminder(self): now = datetime.now(timezone.utc) self.db.add( self.GeneratedContract( id="contract-renewal-001", tenant_id="tenant-demo-001", template_id="tpl-renewal", template_name="Renewal Template", contract_type="procurement", document_type_code="procurement_goods", generated_by="user-admin", generated_by_name="Admin", export_format="docx", file_id=None, file_name="renewal.docx", mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", file_size=10, generated_content="Renewal contract content.", variable_values_json={}, export_options_json={}, file_content_base64="", status="generated", lifecycle_status="effective", expires_at=now + timedelta(days=20), created_at=now, ) ) self.db.commit() result = self.service.sync_contract_lifecycle_tasks( current_user=self.current_user, contract_id="contract-renewal-001", ) self.assertTrue(result["synced"]) self.assertEqual(result["items"][0]["task_type"], "renewal_reminder") self.assertEqual(result["items"][0]["priority"], "high") self.assertEqual(result["summary"]["open"], 1) persisted = self.db.query(self.ContractLifecycleTask).filter_by(contract_id="contract-renewal-001").one() self.assertEqual(persisted.task_key, "renewal:contract-renewal-001") def test_contract_lifecycle_task_can_bind_archive_evidence(self): now = datetime.now(timezone.utc) self.db.add( self.GeneratedContract( id="contract-archive-001", tenant_id="tenant-demo-001", template_id="tpl-archive", template_name="Archive Template", contract_type="procurement", document_type_code="procurement_goods", generated_by="user-admin", generated_by_name="Admin", export_format="docx", file_id=None, file_name="archive.docx", mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", file_size=10, generated_content="Archive contract content.", variable_values_json={}, export_options_json={}, file_content_base64="", status="generated", lifecycle_status="terminated", created_at=now, ) ) self.db.add( self.File( id="file-archive-evidence", tenant_id="tenant-demo-001", uploaded_by="user-admin", bucket="default", object_key="contract-lifecycle-evidence/file-archive-evidence.pdf", file_name="termination-proof.pdf", mime_type="application/pdf", file_size=256, file_hash="hash-archive-evidence", file_type="contract_lifecycle_evidence", status="active", created_at=now, ) ) self.db.commit() created = self.service.create_contract_lifecycle_task( current_user=self.current_user, contract_id="contract-archive-001", task_type="termination_archive", title="终止归档", status_value="completed", evidence_file_id="file-archive-evidence", evidence_note="双方确认终止并归档。", ) self.assertEqual(created["status"], "completed") self.assertEqual(created["evidence_file"]["file_name"], "termination-proof.pdf") self.assertEqual(created["due_status"], "closed") listed = self.service.list_contract_lifecycle_tasks(self.current_user, "contract-archive-001") self.assertEqual(listed["summary"]["completed"], 1) self.assertEqual(listed["summary"]["with_evidence"], 1) def test_contract_lifecycle_task_rejects_missing_evidence_file(self): now = datetime.now(timezone.utc) self.db.add( self.GeneratedContract( id="contract-missing-evidence", tenant_id="tenant-demo-001", template_id="tpl-archive", template_name="Archive Template", contract_type="procurement", document_type_code="procurement_goods", generated_by="user-admin", generated_by_name="Admin", export_format="docx", file_id=None, file_name="archive.docx", mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", file_size=10, generated_content="Archive contract content.", variable_values_json={}, export_options_json={}, file_content_base64="", status="generated", created_at=now, ) ) self.db.commit() with self.assertRaises(HTTPException) as ctx: self.service.create_contract_lifecycle_task( current_user=self.current_user, contract_id="contract-missing-evidence", task_type="termination_archive", title="终止归档", evidence_file_id="file-missing", ) self.assertEqual(ctx.exception.status_code, 404) def test_update_generated_contract_lifecycle_rejects_invalid_range(self): now = datetime.now(timezone.utc) self.db.add( self.GeneratedContract( id="contract-lifecycle-invalid", tenant_id="tenant-demo-001", template_id="tpl-lifecycle", template_name="Lifecycle Template", contract_type="procurement", document_type_code="procurement_goods", generated_by="user-admin", generated_by_name="Admin", export_format="docx", file_id=None, file_name="lifecycle.docx", mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", file_size=10, generated_content="Lifecycle contract content.", variable_values_json={}, export_options_json={}, file_content_base64="", status="generated", created_at=now, ) ) self.db.commit() with self.assertRaises(HTTPException) as ctx: self.service.update_generated_contract_lifecycle( current_user=self.current_user, contract_id="contract-lifecycle-invalid", lifecycle_status="effective", effective_at=now, expires_at=now - timedelta(days=1), ) self.assertEqual(ctx.exception.status_code, 400) if __name__ == "__main__": unittest.main()