test_business.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. """真正经过 HTTP、文件、数据库、LangGraph 和 outbox 的业务闭环测试。"""
  2. from datetime import timedelta
  3. import json
  4. from sqlalchemy import select
  5. from app.domain.models import AgentConfig, AuditRule, AuditRun, ApprovalChain, ApprovalRecord, ApprovalRound, OutboxEvent, Reimbursement
  6. from app.infrastructure.database import transaction, Session
  7. from app.application.worker import worker
  8. from app.application.approvals import start_approval
  9. from app.shared.common import utcnow
  10. from .helpers import BASE, fields, image_bytes, reimbursement, submit
  11. def test_auth_rotation_and_access_control(client, headers):
  12. login = client.post(BASE + '/auth/login', json={'username': 'alice', 'password': 'Demo@2026!'}).json()['data']
  13. refresh = client.post(BASE + '/auth/refresh', json={'refresh_token': login['refresh_token']})
  14. assert refresh.status_code == 200
  15. assert client.post(BASE + '/auth/refresh', json={'refresh_token': login['refresh_token']}).status_code == 401
  16. assert client.get(BASE + '/agents', headers=headers['alice']).status_code == 403
  17. assert client.get(BASE + '/users', headers=headers['carol']).status_code == 403
  18. assert client.get(BASE + '/reimbursements').status_code == 401
  19. assert client.post(BASE + '/auth/login', json={'username': 'alice', 'password': 'wrong'}).status_code == 401
  20. def test_upload_and_manual_approval_feedback_alert(client, headers, drain):
  21. row = reimbursement(client, headers)
  22. audit_id = submit(client, headers, row)
  23. # 同一幂等键和旧 revision 重送仍返回同一审核,不能新建一个重复任务。
  24. assert submit(client, headers, row) == audit_id
  25. drain()
  26. audit = client.get(f'{BASE}/audit/{audit_id}', headers=headers['alice']).json()['data']
  27. assert audit['status'] == 'completed' and audit['decision'] == 'to_supervisor'
  28. assert audit['result']['risk_degraded'] is True
  29. assert audit['result']['extracted_invoices'][0]['source'] == 'user_input_fallback'
  30. assert {node['node_name'] for node in audit['nodes']} == {'extract', 'rule_check', 'risk_detect', 'make_decision'}
  31. assert client.get(f'{BASE}/audit/{audit_id}/events', headers=headers['alice']).text.count('event: node') == 4
  32. records = client.get(BASE + '/approvals?pending=true', headers=headers['bob']).json()['data']['items']
  33. record = records[0]
  34. assert client.post(f"{BASE}/approvals/{record['id']}/decide", headers=headers['alice'], json={'action': 'approve', 'opinion': '本人签署'}).status_code == 403
  35. body = {'action': 'approve', 'opinion': '已检查发票原件与报销用途'}
  36. result = client.post(f"{BASE}/approvals/{record['id']}/decide", headers=headers['bob'], json=body)
  37. assert result.status_code == 200 and result.json()['data']['reimbursement']['status'] == 'approved'
  38. assert client.post(f"{BASE}/approvals/{record['id']}/decide", headers=headers['bob'], json=body).status_code == 409
  39. feedback = client.post(f'{BASE}/audit/{audit_id}/feedback', headers=headers['bob'], json={'approval_record_id': record['id'], 'corrected_fields': {'note': '人工核验'}, 'reason': '人工确认票面一致'})
  40. assert feedback.status_code == 200 and feedback.json()['data']['model_update_needed'] is True
  41. alerts = client.get(BASE + '/alerts', headers=headers['carol']).json()['data']['items']
  42. alert = alerts[0]
  43. assert client.post(f"{BASE}/alerts/{alert['id']}/resolve", headers=headers['carol'], json={'opinion': '已完成核验'}).status_code == 409
  44. assert client.post(f"{BASE}/alerts/{alert['id']}/acknowledge", headers=headers['carol'], json={'opinion': '接手核验'}).status_code == 200
  45. assert client.post(f"{BASE}/alerts/{alert['id']}/resolve", headers=headers['carol'], json={'opinion': '已核验原件,模型待配置'}).status_code == 200
  46. assert client.get(BASE + '/logs?trace=audit-' + str(audit_id), headers=headers['carol']).json()['data']['total'] >= 7
  47. assert client.get(BASE + '/stats/overview', headers=headers['alice']).json()['data']['approved_count'] == 1
  48. def test_missing_fields_to_manual(client, headers, drain):
  49. row = reimbursement(client, headers, declared={})
  50. audit_id = submit(client, headers, row)
  51. drain()
  52. audit = client.get(f'{BASE}/audit/{audit_id}', headers=headers['alice']).json()['data']
  53. assert audit['decision'] == 'to_manual'
  54. assert client.get(BASE + '/approvals?pending=true', headers=headers['carol']).json()['data']['total'] == 1
  55. def test_duplicate_is_critical_with_real_history(client, headers, drain):
  56. first = reimbursement(client, headers)
  57. submit(client, headers, first); drain()
  58. second = reimbursement(client, headers)
  59. audit_id = submit(client, headers, second); drain()
  60. audit = client.get(f'{BASE}/audit/{audit_id}', headers=headers['carol']).json()['data']
  61. duplicate = next(item for item in audit['rules'] if item['rule_code'] == 'invoice_no_duplicate')
  62. assert not duplicate['passed'] and duplicate['severity'] == 'critical'
  63. assert audit['decision'] == 'to_finance' and audit['risk_level'] == 3
  64. assert audit['snapshot']['context']['recent_submissions'] == 1
  65. assert audit['result']['risk_features']['dept_avg_amount_ratio'] == 1
  66. def test_revision_foreign_owner_and_withdraw(client, headers, drain):
  67. row = reimbursement(client, headers)
  68. assert client.get(f"{BASE}/reimbursements/{row['id']}", headers=headers['erin']).status_code == 200
  69. assert client.patch(f"{BASE}/reimbursements/{row['id']}", headers=headers['erin'], json={'revision': row['revision'], 'title': '管理员覆盖'}).status_code == 403
  70. assert client.patch(f"{BASE}/reimbursements/{row['id']}", headers=headers['alice'], json={'revision': 1, 'title': '旧版本覆盖'}).status_code == 409
  71. audit_id = submit(client, headers, row)
  72. pending = client.get(f"{BASE}/reimbursements/{row['id']}", headers=headers['alice']).json()['data']
  73. assert client.post(f"{BASE}/reimbursements/{row['id']}/withdraw", headers=headers['alice'], json={'revision': pending['revision']}).status_code == 200
  74. drain()
  75. assert client.get(f'{BASE}/audit/{audit_id}', headers=headers['alice']).json()['data']['status'] == 'cancelled'
  76. assert client.get(BASE + '/approvals', headers=headers['bob']).json()['data']['total'] == 0
  77. def test_partial_upload_keeps_committed_file(client, headers):
  78. row = client.post(BASE + '/reimbursements', headers=headers['alice'], json={'title': '部分失败测试', 'total_amount': '106.00'}).json()['data']
  79. response = client.post(f"{BASE}/reimbursements/{row['id']}/invoices", headers=headers['alice'], data={'revision': row['revision']}, files=[('files', ('ok.png', image_bytes(), 'image/png')), ('files', ('fake.png', b'not-image', 'image/png'))])
  80. assert 'event: progress' in response.text and 'event: error' in response.text and 'event: completed' not in response.text
  81. detail = client.get(f"{BASE}/reimbursements/{row['id']}", headers=headers['alice']).json()['data']
  82. assert len(detail['invoices']) == 1 and detail['revision'] == row['revision'] + 1
  83. def test_frozen_rules_return_and_resubmit(client, headers, drain):
  84. row = reimbursement(client, headers)
  85. first_id = submit(client, headers, row)
  86. rules = client.get(BASE + '/rules', headers=headers['erin']).json()['data']['items']
  87. rule = next(item for item in rules if item['code'] == 'tax_number_valid')
  88. update = client.patch(BASE + '/rules/tax_number_valid', headers=headers['erin'], json={'expected_version': rule['version'], 'params': {'lengths': [20]}})
  89. assert update.status_code == 200
  90. drain()
  91. first = client.get(f'{BASE}/audit/{first_id}', headers=headers['alice']).json()['data']
  92. assert next(item for item in first['rules'] if item['rule_code'] == 'tax_number_valid')['rule_version'] == 1
  93. approval = client.get(BASE + '/approvals?pending=true', headers=headers['bob']).json()['data']['items'][0]
  94. assert client.post(f"{BASE}/approvals/{approval['id']}/decide", headers=headers['bob'], json={'action': 'return', 'opinion': '补充业务说明'}).status_code == 200
  95. returned = client.get(f"{BASE}/reimbursements/{row['id']}", headers=headers['alice']).json()['data']
  96. second_id = submit(client, headers, returned, key='another-submit-key'); drain()
  97. second = client.get(f'{BASE}/audit/{second_id}', headers=headers['alice']).json()['data']
  98. assert second['run_no'] == 2 and next(item for item in second['rules'] if item['rule_code'] == 'tax_number_valid')['rule_version'] == 2
  99. assert client.post(f"{BASE}/approvals/{approval['id']}/decide", headers=headers['bob'], json={'action': 'approve', 'opinion': '旧节点重签'}).status_code == 409
  100. def test_real_model_contract_mock_for_auto_approval(client, headers, drain, monkeypatch):
  101. from app.infrastructure.ai import ai
  102. from app.shared.config import settings
  103. from app.shared.schemas import ExtractedInvoice, RiskAssessment
  104. monkeypatch.setattr(settings, 'vlm_base_url', 'http://model-contract.test/v1')
  105. monkeypatch.setattr(settings, 'vlm_model', 'contract-test')
  106. async def contract(prompt, schema, **kwargs):
  107. if schema is ExtractedInvoice:
  108. return schema(**fields()), {'total_tokens': 10}
  109. return RiskAssessment(risk_level=1, risk_score='0.1', risk_factors=[], reasoning='测试模型返回低风险'), {'total_tokens': 8}
  110. monkeypatch.setattr(ai, 'structured', contract)
  111. rule = next(item for item in client.get(BASE + '/rules', headers=headers['erin']).json()['data']['items'] if item['code'] == 'policy_compliance')
  112. assert client.patch(BASE + '/rules/policy_compliance', headers=headers['erin'], json={'expected_version': rule['version'], 'params': {'required': False}}).status_code == 200
  113. row = reimbursement(client, headers)
  114. audit_id = submit(client, headers, row); drain()
  115. assert client.get(f'{BASE}/audit/{audit_id}', headers=headers['alice']).json()['data']['decision'] == 'auto_approve'
  116. detail = client.get(f"{BASE}/reimbursements/{row['id']}", headers=headers['alice']).json()['data']
  117. assert detail['status'] == 'approved' and detail['approvals'][0]['approver_id'] is None
  118. # 重复派单不产生第二审批轮次。
  119. client.portal.call(start_approval, audit_id)
  120. assert len(client.get(f"{BASE}/reimbursements/{row['id']}", headers=headers['alice']).json()['data']['approval_rounds']) == 1
  121. def test_outbox_recovery_and_timeout_dedup(client, headers, drain):
  122. row = reimbursement(client, headers)
  123. audit_id = submit(client, headers, row)
  124. async def abandoned():
  125. async with transaction() as session:
  126. event = await session.scalar(select(OutboxEvent).where(OutboxEvent.event_type == 'audit'))
  127. event.status, event.lease_until = 'processing', utcnow() - timedelta(seconds=1)
  128. client.portal.call(abandoned); drain()
  129. async def overdue():
  130. async with transaction() as session:
  131. record = await session.scalar(select(ApprovalRecord))
  132. record.created_at = utcnow() - timedelta(hours=49)
  133. client.portal.call(overdue)
  134. client.portal.call(worker.scan_timeouts); client.portal.call(worker.scan_timeouts)
  135. assert client.get(BASE + '/alerts?type=approval_timeout', headers=headers['carol']).json()['data']['total'] == 1
  136. def test_self_review_chain_missing_and_configuration_recovery(client, headers, drain):
  137. # carol 自己发起且关键字段缺失,默认人工链 carol 必须被拒绝。
  138. row = reimbursement(client, headers, declared={}, owner='carol')
  139. audit_id = submit(client, headers, row, owner='carol'); drain()
  140. assert client.get(BASE + '/alerts?type=chain_missing', headers=headers['carol']).json()['data']['total'] == 1
  141. assert client.get(BASE + '/approvals?pending=true', headers=headers['carol']).json()['data']['total'] == 0
  142. # 派单耗尽后配置新的非提交审核员,创建链会重新激活持久事件。
  143. async def exhausted():
  144. async with transaction() as session:
  145. event = await session.scalar(select(OutboxEvent).where(OutboxEvent.event_type == 'approval'))
  146. event.status = 'failed'
  147. client.portal.call(exhausted)
  148. user = client.post(BASE + '/users', headers=headers['erin'], json={'username': 'frank', 'real_name': '另一审计员', 'password': 'Demo@2026!', 'department_id': 1, 'role': 'auditor'}).json()['data']
  149. assert client.post(BASE + '/approvals/chains', headers=headers['dave'], json={'name': '替代人工复核链', 'route': 'to_manual', 'priority': 100, 'nodes': [{'approver_id': user['id'], 'role': 'auditor'}]}).status_code == 201
  150. drain()
  151. detail = client.get(f"{BASE}/reimbursements/{row['id']}", headers=headers['carol']).json()['data']
  152. assert detail['status'] == 'in_approval' and detail['approvals'][0]['approver_id'] == user['id']
  153. def test_config_optimistic_and_core_guards(client, headers):
  154. assert client.patch(BASE + '/agents/rule_check', headers=headers['erin'], json={'expected_version': 1, 'enabled': False}).status_code == 422
  155. assert client.patch(BASE + '/rules/invoice_no_duplicate', headers=headers['erin'], json={'expected_version': 1, 'severity': 'warning'}).status_code == 422
  156. body = {'expected_version': 1, 'config': {'max_attempts': 2, 'timeout': 10, 'concurrency': 2}}
  157. assert client.patch(BASE + '/agents/extract', headers=headers['erin'], json=body).status_code == 200
  158. assert client.patch(BASE + '/agents/extract', headers=headers['erin'], json=body).status_code == 409
  159. assert client.post(BASE + '/agents/extract/test', headers=headers['carol']).json()['data']['status'] == 'degraded'
  160. assert client.patch(BASE + '/users/5', headers=headers['erin'], json={'active': False}).status_code == 409
  161. def test_policies_keyword_versions_and_exports(client, headers):
  162. row = client.post(BASE + '/knowledge/documents', headers=headers['carol'], json={'title': '测试制度原文', 'content': '# 第一条\n\n测试制度:差旅费用应说明用途。'}).json()['data']
  163. result = client.post(BASE + '/knowledge/search', headers=headers['alice'], json={'query': '差旅', 'mode': 'keyword'})
  164. assert result.status_code == 200 and result.json()['data']['items'][0]['document_id'] == row['id']
  165. assert client.post(BASE + '/knowledge/search', headers=headers['alice'], json={'query': '差旅', 'mode': 'semantic'}).status_code == 503
  166. assert client.post(f"{BASE}/knowledge/documents/{row['id']}/reindex", headers=headers['carol']).status_code == 503
  167. assert client.patch(f"{BASE}/knowledge/documents/{row['id']}", headers=headers['carol'], json={'title': '测试制度修订', 'content': '测试办公政策', 'expected_version': 1}).json()['data']['version'] == 2
  168. reimbursement(client, headers)
  169. assert client.get(BASE + '/stats/export?format=xlsx', headers=headers['dave']).content[:2] == b'PK'
  170. assert client.get(BASE + '/stats/export?format=pdf', headers=headers['dave']).content[:4] == b'%PDF'
  171. assert client.get(BASE + '/logs/export', headers=headers['carol']).content.startswith(b'\xef\xbb\xbf')
  172. assert client.get(BASE + '/stats/export?format=xlsx', headers=headers['alice']).status_code == 403
  173. def test_multi_node_freeze_and_identity_change(client, headers, drain):
  174. # 同具体度、高优先级的两节点主管链覆盖默认链。
  175. chain = client.post(BASE + '/approvals/chains', headers=headers['dave'], json={'name': '两级测试链', 'route': 'to_supervisor', 'priority': 90,
  176. 'nodes': [{'approver_id': 2, 'role': 'supervisor'}, {'approver_id': 4, 'role': 'manager'}]}).json()['data']
  177. row = reimbursement(client, headers); submit(client, headers, row); drain()
  178. # 修改当前链不能改变正在进行的第二审批人。
  179. assert client.patch(f"{BASE}/approvals/chains/{chain['id']}", headers=headers['dave'], json={'name': '改为单节点', 'route': 'to_supervisor', 'priority': 90, 'nodes': [{'approver_id': 2, 'role': 'supervisor'}], 'expected_version': 1}).status_code == 200
  180. bob = client.get(BASE + '/approvals?pending=true', headers=headers['bob']).json()['data']['items'][0]
  181. assert client.post(f"{BASE}/approvals/{bob['id']}/decide", headers=headers['bob'], json={'action': 'approve', 'opinion': '第一节点通过'}).status_code == 200
  182. dave = client.get(BASE + '/approvals?pending=true', headers=headers['dave']).json()['data']['items'][0]
  183. assert client.patch(BASE + '/users/4', headers=headers['erin'], json={'role': 'auditor'}).status_code == 200
  184. assert client.post(f"{BASE}/approvals/{dave['id']}/decide", headers=headers['dave'], json={'action': 'approve', 'opinion': '身份变化后签署'}).status_code == 409
  185. assert client.patch(BASE + '/users/4', headers=headers['erin'], json={'role': 'manager'}).status_code == 200
  186. assert client.post(f"{BASE}/approvals/{dave['id']}/decide", headers=headers['dave'], json={'action': 'reject', 'opinion': '第二节点驳回'}).status_code == 200
  187. assert client.get(f"{BASE}/reimbursements/{row['id']}", headers=headers['alice']).json()['data']['status'] == 'rejected'