Add durable platform event delivery contract
This commit is contained in:
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_event, audit_operation_context
|
||||
from govoplan_core.core.events import (
|
||||
DurableEventConsumer,
|
||||
EventActorRef,
|
||||
EventBus,
|
||||
EventObjectRef,
|
||||
@@ -41,6 +42,42 @@ def _configure_audit_runtime() -> None:
|
||||
|
||||
|
||||
class CoreEventTests(unittest.TestCase):
|
||||
def test_durable_consumer_requires_policy_for_classified_events(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "policy decision"):
|
||||
DurableEventConsumer(
|
||||
consumer_id="workflow.triggers.v1",
|
||||
classifications=frozenset({"restricted"}),
|
||||
handler=lambda _event, _delivery_key: None,
|
||||
)
|
||||
|
||||
consumer = DurableEventConsumer(
|
||||
consumer_id="workflow.triggers.v1",
|
||||
event_types=frozenset({"case.changed"}),
|
||||
classifications=frozenset(
|
||||
{"internal", "confidential"}
|
||||
),
|
||||
policy_decision_ref="policy:decision:1",
|
||||
handler=lambda _event, _delivery_key: None,
|
||||
)
|
||||
accepted = PlatformEvent(
|
||||
type="case.changed",
|
||||
module_id="cases",
|
||||
classification="confidential",
|
||||
event_id="event-1",
|
||||
)
|
||||
rejected = PlatformEvent(
|
||||
type="case.deleted",
|
||||
module_id="cases",
|
||||
classification="confidential",
|
||||
)
|
||||
|
||||
self.assertTrue(consumer.accepts(accepted))
|
||||
self.assertFalse(consumer.accepts(rejected))
|
||||
self.assertEqual(
|
||||
"event-1:workflow.triggers.v1",
|
||||
consumer.delivery_key(accepted),
|
||||
)
|
||||
|
||||
def test_fallback_event_waits_for_outer_commit_after_savepoint(self) -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
seen: list[PlatformEvent] = []
|
||||
|
||||
@@ -21,6 +21,30 @@ class InstallConfigTests(unittest.TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
|
||||
|
||||
def test_platform_event_outbox_limits_are_validated(self) -> None:
|
||||
defaults = Settings()
|
||||
self.assertEqual(defaults.platform_event_outbox_max_attempts, 8)
|
||||
self.assertEqual(
|
||||
defaults.platform_event_outbox_terminal_retention_days,
|
||||
90,
|
||||
)
|
||||
configured = Settings(
|
||||
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS="12",
|
||||
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS="30",
|
||||
)
|
||||
self.assertEqual(configured.platform_event_outbox_max_attempts, 12)
|
||||
self.assertEqual(
|
||||
configured.platform_event_outbox_terminal_retention_days,
|
||||
30,
|
||||
)
|
||||
for values in (
|
||||
{"PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS": "0"},
|
||||
{"PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS": "101"},
|
||||
{"PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS": "-1"},
|
||||
):
|
||||
with self.subTest(values=values), self.assertRaises(ValidationError):
|
||||
Settings(**values)
|
||||
|
||||
def test_scheduling_cancellation_notice_setting_is_bounded(self) -> None:
|
||||
self.assertEqual(Settings().scheduling_cancellation_notice_days, 30)
|
||||
self.assertEqual(
|
||||
|
||||
131
tests/test_platform_event_worker.py
Normal file
131
tests/test_platform_event_worker.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from govoplan_core.celery_app import (
|
||||
celery,
|
||||
dispatch_platform_events,
|
||||
purge_platform_events,
|
||||
)
|
||||
from govoplan_core.core.events import PlatformEvent
|
||||
|
||||
|
||||
class PlatformEventWorkerTests(unittest.TestCase):
|
||||
def test_dispatch_uses_a_durable_dataflow_consumer_and_commits(self) -> None:
|
||||
session = MagicMock()
|
||||
database = MagicMock()
|
||||
database.SessionLocal.return_value.__enter__.return_value = session
|
||||
outbox = MagicMock()
|
||||
outbox.dispatch_pending.return_value = {
|
||||
"selected": 1,
|
||||
"delivered": 1,
|
||||
"retrying": 0,
|
||||
"quarantined": 0,
|
||||
"dispatched": 1,
|
||||
"observer_failed": 0,
|
||||
}
|
||||
dataflow = MagicMock()
|
||||
registry = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_core.celery_app._platform_registry",
|
||||
return_value=registry,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._platform_event_outbox",
|
||||
return_value=outbox,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app._dataflow_trigger_dispatcher",
|
||||
return_value=dataflow,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
):
|
||||
result = dispatch_platform_events.run(25)
|
||||
|
||||
call = outbox.dispatch_pending.call_args
|
||||
self.assertEqual(session, call.args[0])
|
||||
self.assertEqual(25, call.kwargs["limit"])
|
||||
consumer = call.kwargs["consumers"][0]
|
||||
self.assertEqual(
|
||||
"dataflow.event-triggers.v1",
|
||||
consumer.consumer_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
frozenset({"public", "internal"}),
|
||||
consumer.classifications,
|
||||
)
|
||||
event = PlatformEvent(
|
||||
type="files.file.created",
|
||||
module_id="files",
|
||||
)
|
||||
consumer.handler(event, consumer.delivery_key(event))
|
||||
dataflow.ingest_event.assert_called_once_with(
|
||||
session,
|
||||
event=event,
|
||||
)
|
||||
session.commit.assert_called_once_with()
|
||||
self.assertEqual(1, result["delivered"])
|
||||
|
||||
def test_retention_task_uses_the_configured_terminal_window(self) -> None:
|
||||
session = MagicMock()
|
||||
database = MagicMock()
|
||||
database.SessionLocal.return_value.__enter__.return_value = session
|
||||
outbox = MagicMock()
|
||||
outbox.purge_terminal.return_value = {"deleted": 2}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_core.celery_app._platform_event_outbox",
|
||||
return_value=outbox,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.db.session.get_database",
|
||||
return_value=database,
|
||||
),
|
||||
patch(
|
||||
"govoplan_core.celery_app.settings."
|
||||
"platform_event_outbox_terminal_retention_days",
|
||||
30,
|
||||
),
|
||||
):
|
||||
result = purge_platform_events.run(75)
|
||||
|
||||
call = outbox.purge_terminal.call_args
|
||||
self.assertEqual(session, call.args[0])
|
||||
self.assertEqual(75, call.kwargs["limit"])
|
||||
before = call.kwargs["before"]
|
||||
self.assertIsInstance(before, datetime)
|
||||
self.assertEqual(timezone.utc, before.tzinfo)
|
||||
session.commit.assert_called_once_with()
|
||||
self.assertEqual({"deleted": 2}, result)
|
||||
|
||||
def test_worker_routes_and_periodic_tasks_are_registered(self) -> None:
|
||||
self.assertEqual(
|
||||
{"queue": "events"},
|
||||
celery.conf.task_routes[
|
||||
"govoplan.events.dispatch_outbox"
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
{"queue": "events"},
|
||||
celery.conf.task_routes[
|
||||
"govoplan.events.purge_outbox"
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
"govoplan.events.purge_outbox",
|
||||
celery.conf.beat_schedule[
|
||||
"platform-event-retention-daily"
|
||||
]["task"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user