test_boundaries.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
  1. """验证并发单号、文件页数、异步取消、外部重试与授权边界。"""
  2. from concurrent.futures import ThreadPoolExecutor
  3. from datetime import date
  4. from sqlalchemy import select
  5. import httpx
  6. import pymupdf
  7. import pytest
  8. from app.domain.models import OutboxEvent
  9. from app.infrastructure.database import transaction
  10. from app.infrastructure.files import validate_file
  11. from app.infrastructure.ai import ai, AIUnavailable
  12. from app.shared.common import BusinessError
  13. from app.shared.config import settings
  14. from .helpers import BASE, fields, reimbursement, submit
  15. def test_parallel_number_allocation(client, headers):
  16. def create(index):
  17. response = client.post(BASE + '/reimbursements', headers=headers['alice'], json={'title': f'并发测试{index}', 'total_amount': '1.00'})
  18. assert response.status_code == 201, response.text
  19. return response.json()['data']['number']
  20. with ThreadPoolExecutor(max_workers=4) as pool:
  21. numbers = list(pool.map(create, range(8)))
  22. assert len(set(numbers)) == 8
  23. def test_pdf_type_and_page_limit():
  24. with pymupdf.open() as pdf:
  25. pdf.new_page()
  26. assert validate_file(pdf.tobytes()) == ('application/pdf', '.pdf')
  27. for _ in range(5):
  28. pdf.new_page()
  29. with pytest.raises(BusinessError) as error:
  30. validate_file(pdf.tobytes())
  31. assert error.value.code == 'PDF_LIMIT'
  32. def test_graph_extraction_retry_is_bounded(client, headers, drain, monkeypatch):
  33. monkeypatch.setattr(settings, 'vlm_base_url', 'http://transient.test/v1')
  34. monkeypatch.setattr(settings, 'vlm_model', 'transient-test')
  35. attempts = []
  36. async def failing(prompt, schema, **kwargs):
  37. if kwargs.get('vision'):
  38. attempts.append(1)
  39. raise AIUnavailable('瞬时网络故障')
  40. monkeypatch.setattr(ai, 'structured', failing)
  41. row = reimbursement(client, headers)
  42. audit_id = submit(client, headers, row); drain()
  43. audit = client.get(f'{BASE}/audit/{audit_id}', headers=headers['carol']).json()['data']
  44. assert len(attempts) == 3
  45. assert len([node for node in audit['nodes'] if node['node_name'] == 'extract']) == 3
  46. assert audit['status'] == 'completed' and audit['result']['extracted_invoices'][0]['source'] == 'user_input_fallback'
  47. def test_cancelled_audit_not_overwritten_by_late_failure(client, headers, drain, monkeypatch):
  48. from app.application import pipeline
  49. row = reimbursement(client, headers)
  50. audit_id = submit(client, headers, row)
  51. current = client.get(f"{BASE}/reimbursements/{row['id']}", headers=headers['alice']).json()['data']
  52. assert client.post(f"{BASE}/reimbursements/{row['id']}/withdraw", headers=headers['alice'], json={'revision': current['revision']}).status_code == 200
  53. async def exhaust():
  54. async with transaction() as session:
  55. event = await session.scalar(select(OutboxEvent).where(OutboxEvent.event_type == 'audit'))
  56. event.attempts = 2
  57. async def fail_late(audit):
  58. raise RuntimeError('明确模拟撤回后的迟到核心失败')
  59. client.portal.call(exhaust)
  60. monkeypatch.setattr(pipeline, 'run_audit', fail_late)
  61. drain()
  62. assert client.get(f'{BASE}/audit/{audit_id}', headers=headers['alice']).json()['data']['status'] == 'cancelled'
  63. def test_gross_total_mismatch_requires_finance(client, headers, drain):
  64. row = reimbursement(client, headers, total='100.00')
  65. audit_id = submit(client, headers, row); drain()
  66. audit = client.get(f'{BASE}/audit/{audit_id}', headers=headers['alice']).json()['data']
  67. assert audit['decision'] == 'to_finance'
  68. assert not next(item for item in audit['rules'] if item['rule_code'] == 'reimbursement_total_match')['passed']
  69. def test_cross_department_and_cors(client, headers):
  70. department = client.post(BASE + '/departments', headers=headers['erin'], json={'name': '另一测试部门'}).json()['data']
  71. user = client.post(BASE + '/users', headers=headers['erin'], json={'username': 'outside', 'real_name': '跨部门员工', 'password': 'Demo@2026!', 'department_id': department['id'], 'role': 'employee'}).json()['data']
  72. token = client.post(BASE + '/auth/login', json={'username': 'outside', 'password': 'Demo@2026!'}).json()['data']['access_token']
  73. row = client.post(BASE + '/reimbursements', headers={'Authorization': 'Bearer ' + token}, json={'title': '跨部门测试', 'total_amount': '1.00'}).json()['data']
  74. assert client.get(f"{BASE}/reimbursements/{row['id']}", headers=headers['alice']).status_code == 403
  75. assert client.get(f"{BASE}/reimbursements/{row['id']}", headers=headers['bob']).status_code == 403
  76. assert client.get(f"{BASE}/reimbursements/{row['id']}", headers=headers['carol']).status_code == 200
  77. response = client.options(BASE + '/reimbursements', headers={'Origin': 'http://127.0.0.1:5173', 'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Headers': 'authorization,content-type,idempotency-key'})
  78. assert response.status_code == 200 and response.headers['access-control-allow-origin'] == 'http://127.0.0.1:5173'
  79. assert client.options(BASE + '/reimbursements', headers={'Origin': 'http://untrusted.test', 'Access-Control-Request-Method': 'POST'}).status_code == 400
  80. assert client.get('/openapi.json').json()['components']['securitySchemes']['HTTPBearer']['scheme'] == 'bearer'
  81. def test_invalid_rule_test_context_is_422(client, headers):
  82. response = client.post(BASE + '/rules/invoice_date_valid/test', headers=headers['carol'], json={'invoice': {'invoice_date': '2026-01-01'}, 'context': {'today': 'not-a-date'}})
  83. assert response.status_code == 422 and response.json()['error']['code'] == 'RULE_TEST_INVALID'