Add durable platform event delivery contract

This commit is contained in:
2026-07-29 17:34:52 +02:00
parent e8fed6d25a
commit a192a2215f
8 changed files with 373 additions and 17 deletions

View File

@@ -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] = []

View File

@@ -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(

View 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()