message_recovery_acceptance.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. import asyncio
  2. import json
  3. from pathlib import Path
  4. from uuid import UUID, uuid4
  5. import pytest
  6. from sqlalchemy import func, select
  7. from test_after_sales import scenario # noqa: F401
  8. from test_fulfillment import approve, connected, due, operation_for # noqa: F401
  9. from zhifu.core.config import Settings
  10. from zhifu.modules.after_sales.models import BusinessEvent
  11. from zhifu.modules.integrations.fulfillment import execute_due
  12. from zhifu.modules.integrations.models import ConsumerReceipt, ExternalOperation
  13. from zhifu.modules.integrations.worker import Broker, consume_one, publish_one
  14. from zhifu.oms.models import Exchange, Order
  15. from zhifu.operations.message_recovery import reconcile
  16. async def test_lost_message_replay_and_duplicate_fulfillment(connected): # noqa: F811
  17. sessions = connected["app"].state.sessions
  18. tenant = connected["tenant"].bytes
  19. ticket, event_id = await approve(connected)
  20. name = "recovery-" + uuid4().hex
  21. async with Broker(Settings().broker_url.get_secret_value(), name, name) as broker:
  22. try:
  23. pending = await reconcile(
  24. sessions, tenant, [event_id], operator="acceptance", reason="检查等待事件"
  25. )
  26. assert pending["items"][0]["disposition"] == "PENDING"
  27. assert not pending["items"][0]["requeued"]
  28. assert await publish_one(sessions, broker, event_id)
  29. # 仅清空本次创建的独立队列,实际制造发布已确认但消息不可再读取的场景。
  30. await broker.queue.purge()
  31. assert not await consume_one(sessions, broker, 5)
  32. report = await reconcile(sessions, tenant, [event_id])
  33. assert report["items"][0]["disposition"] == "SENT_WITHOUT_RECEIPT"
  34. with pytest.raises(ValueError, match="租户"):
  35. await reconcile(sessions, uuid4().bytes, [event_id], operator="test", reason="核对")
  36. with pytest.raises(ValueError, match="租户"):
  37. await reconcile(
  38. sessions,
  39. tenant,
  40. [event_id, UUID(int=(1 << 128) - 1).bytes],
  41. operator="acceptance",
  42. reason="检查批量事务",
  43. )
  44. async with sessions() as session:
  45. assert (await session.get(BusinessEvent, event_id)).delivery_status == "SENT"
  46. reports = await asyncio.gather(
  47. *(
  48. reconcile(
  49. sessions, tenant, [event_id], operator="acceptance", reason="队列恢复演练"
  50. )
  51. for _ in range(2)
  52. )
  53. )
  54. assert sum(item["items"][0]["requeued"] for item in reports) == 1
  55. assert await publish_one(sessions, broker, event_id)
  56. assert await consume_one(sessions, broker, 5)
  57. operation = await operation_for(connected, ticket)
  58. assert await execute_due(sessions, 5, operation.id)
  59. async with sessions() as session:
  60. event = await session.get(BusinessEvent, event_id)
  61. await broker.publish_event(event)
  62. await broker.publish_event(event)
  63. assert await consume_one(sessions, broker, 5)
  64. assert await consume_one(sessions, broker, 5)
  65. skipped = await reconcile(
  66. sessions, tenant, [event_id], operator="acceptance", reason="再次核对"
  67. )
  68. assert skipped["items"][0]["disposition"] == "CONSUMED"
  69. assert not skipped["items"][0]["requeued"]
  70. async with sessions() as session:
  71. for model, predicate in (
  72. (ConsumerReceipt, ConsumerReceipt.event_id == event_id),
  73. (ExternalOperation, ExternalOperation.tenant_id == tenant),
  74. (
  75. BusinessEvent,
  76. (BusinessEvent.tenant_id == tenant)
  77. & (BusinessEvent.event_type == "fulfillment.replay_requested"),
  78. ),
  79. ):
  80. assert (
  81. await session.scalar(
  82. select(func.count()).select_from(model).where(predicate)
  83. )
  84. == 1
  85. )
  86. async with connected["oms_sessions"]() as session:
  87. assert (
  88. await session.scalar(
  89. select(func.count())
  90. .select_from(Exchange)
  91. .where(Exchange.tenant_id == tenant)
  92. )
  93. == 1
  94. )
  95. order = await session.get(Order, connected["order_id"].bytes)
  96. assert order.available_quantity == 0
  97. assert order.source_version == 2
  98. response = await connected["warehouse"].post(
  99. f"/exchanges/{UUID(bytes=operation.id)}/complete"
  100. )
  101. assert response.status_code == 200
  102. await due(connected, operation.id)
  103. assert await execute_due(sessions, 5, operation.id)
  104. response = await connected["call"]("GET", f"/openapi/v1/tickets/{ticket['ticket_id']}")
  105. assert response.json()["status"] == "COMPLETED"
  106. await asyncio.to_thread(
  107. Path("docs/verification/message-recovery.json").write_text,
  108. json.dumps(
  109. {
  110. "queue_loss_recovered": True,
  111. "concurrent_replay_changes": 1,
  112. "consumer_receipts": 1,
  113. "external_operations": 1,
  114. "oms_exchanges": 1,
  115. "ticket_status": "COMPLETED",
  116. "foreign_tenant_rejected": True,
  117. "invalid_batch_atomic": True,
  118. "pending_event_skipped": True,
  119. },
  120. indent=2,
  121. ),
  122. encoding="utf-8",
  123. )
  124. finally:
  125. await broker.queue.delete(if_unused=False, if_empty=False)
  126. await broker.exchange.delete(if_unused=False)