test_outbox.py 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. from __future__ import annotations
  2. from datetime import datetime, timedelta, timezone
  3. from pathlib import Path
  4. ROOT = Path(__file__).resolve().parents[1]
  5. def test_retry_delay_is_exponential_and_capped():
  6. from app.core.events.consumer import retry_delay
  7. assert retry_delay(1, base_seconds=2, maximum_seconds=30) == timedelta(seconds=2)
  8. assert retry_delay(2, base_seconds=2, maximum_seconds=30) == timedelta(seconds=4)
  9. assert retry_delay(10, base_seconds=2, maximum_seconds=30) == timedelta(seconds=30)
  10. def test_successful_dispatch_records_idempotent_completion():
  11. from app.core.events.consumer import ClaimedEvent, dispatch_event
  12. event = ClaimedEvent(
  13. event_id="01900000-0000-7000-8000-000000000001",
  14. event_type="governance.updated",
  15. payload={"uid": "domain-1"},
  16. attempts=0,
  17. )
  18. calls = []
  19. result = dispatch_event(event, lambda payload: calls.append(payload))
  20. duplicate = dispatch_event(
  21. event,
  22. lambda payload: calls.append(payload),
  23. already_processed=True,
  24. )
  25. assert result.status == "published"
  26. assert result.record_consumption is True
  27. assert duplicate.status == "published"
  28. assert duplicate.record_consumption is False
  29. assert calls == [{"uid": "domain-1"}]
  30. def test_failed_dispatch_retries_then_enters_dead_letter():
  31. from app.core.events.consumer import ClaimedEvent, dispatch_event
  32. now = datetime(2026, 7, 16, tzinfo=timezone.utc)
  33. def fail(_payload):
  34. raise RuntimeError("downstream unavailable with secret-token")
  35. retry = dispatch_event(
  36. ClaimedEvent("event-1", "test", {}, attempts=0),
  37. fail,
  38. max_attempts=2,
  39. now=now,
  40. )
  41. dead = dispatch_event(
  42. ClaimedEvent("event-1", "test", {}, attempts=1),
  43. fail,
  44. max_attempts=2,
  45. now=now,
  46. )
  47. assert retry.status == "pending"
  48. assert retry.attempts == 1
  49. assert retry.available_at > now
  50. assert retry.last_error == "downstream unavailable with [redacted]"
  51. assert dead.status == "dead_letter"
  52. assert dead.attempts == 2
  53. def test_outbox_migration_and_claim_query_define_delivery_contract():
  54. migration = (
  55. ROOT / "migrations" / "versions" / "20260716_03_outbox.py"
  56. ).read_text(encoding="utf-8")
  57. repository = (ROOT / "app/core/events/outbox.py").read_text(encoding="utf-8")
  58. for column in (
  59. "event_id",
  60. "aggregate_type",
  61. "aggregate_id",
  62. "event_type",
  63. "payload",
  64. "status",
  65. "attempts",
  66. "available_at",
  67. "published_at",
  68. "last_error",
  69. ):
  70. assert column in migration
  71. assert "outbox_consumptions" in migration
  72. assert "FOR UPDATE SKIP LOCKED" in repository
  73. assert "outbox_health" in repository
  74. def test_system_health_exposes_outbox_counts_without_payloads():
  75. health = (ROOT / "app/core/system/health.py").read_text(encoding="utf-8")
  76. assert "outbox_health" in health
  77. assert 'health_status["outbox"]' in health
  78. assert "payload" not in health
  79. def test_consumer_without_registered_handlers_does_not_claim_events(monkeypatch):
  80. from app.commands import process_outbox
  81. def unexpected_claim(*_args, **_kwargs):
  82. raise AssertionError("events must not be claimed without a registered handler")
  83. monkeypatch.setattr(process_outbox, "claim_outbox", unexpected_claim)
  84. assert process_outbox.process_available_events(object(), handlers={}) == 0