feat(event_bus): add EventCatalog class with register/get/all methods

This commit is contained in:
CREDO23 2026-05-29 22:15:09 +02:00
parent 30fff9e52f
commit 2a511b8559
3 changed files with 146 additions and 0 deletions

View file

@ -0,0 +1,48 @@
"""Event type catalog: the deliberate contract behind each event.
``EventType`` declares a dotted name and the shape of its payload.
``EventCatalog`` is the registry populated once at import by each event type
module. ``catalog`` is the process-wide singleton.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from pydantic import BaseModel
@dataclass(frozen=True, slots=True)
class EventType:
type: str
description: str
payload_model: type[BaseModel]
@property
def payload_schema(self) -> dict[str, Any]:
"""JSON Schema (draft 2020-12) derived from ``payload_model``."""
return self.payload_model.model_json_schema()
class EventCatalog:
"""Registry of known event types. Populated at import; read at runtime."""
def __init__(self) -> None:
self._registry: dict[str, EventType] = {}
def register(self, event_type: EventType) -> None:
"""Register an event type. Raises on duplicate type."""
if event_type.type in self._registry:
raise ValueError(f"Event type already registered: {event_type.type!r}")
self._registry[event_type.type] = event_type
def get(self, type_: str) -> EventType | None:
return self._registry.get(type_)
def all(self) -> dict[str, EventType]:
"""Defensive snapshot of the registry."""
return dict(self._registry)
catalog = EventCatalog()

View file

@ -0,0 +1,25 @@
"""Shared fixtures for the ``app.event_bus`` unit-test tree.
The event-type catalog is a module-level registry populated at import. Tests
that register their own event types (or assert on registry contents) snapshot
and restore it so state never leaks between tests.
"""
from __future__ import annotations
from collections.abc import Iterator
import pytest
from app.event_bus.catalog import catalog
@pytest.fixture
def isolated_event_catalog() -> Iterator[None]:
"""Snapshot and restore the event-type catalog around a test."""
snapshot = dict(catalog._registry)
try:
yield
finally:
catalog._registry.clear()
catalog._registry.update(snapshot)

View file

@ -0,0 +1,73 @@
"""EventCatalog contract: register, look up, snapshot, derive schema."""
from __future__ import annotations
import pytest
from pydantic import BaseModel
from app.event_bus.catalog import EventCatalog, EventType
pytestmark = pytest.mark.unit
class _SamplePayload(BaseModel):
document_id: int
def _event_type(type_: str = "test.thing") -> EventType:
return EventType(
type=type_,
description="A thing happened.",
payload_model=_SamplePayload,
)
def test_register_then_get_returns_the_event_type(isolated_event_catalog: None) -> None:
from app.event_bus.catalog import catalog
catalog.register(_event_type())
assert catalog.get("test.thing") is not None
assert catalog.get("test.thing").type == "test.thing"
def test_get_unknown_type_returns_none(isolated_event_catalog: None) -> None:
from app.event_bus.catalog import catalog
assert catalog.get("does.not.exist") is None
def test_register_duplicate_type_raises(isolated_event_catalog: None) -> None:
"""A type is a contract; registering it twice is a bug, not an override."""
from app.event_bus.catalog import catalog
catalog.register(_event_type())
with pytest.raises(ValueError, match="already registered"):
catalog.register(_event_type())
def test_all_is_a_defensive_snapshot(isolated_event_catalog: None) -> None:
"""Mutating the returned dict must not corrupt the registry."""
from app.event_bus.catalog import catalog
catalog.register(_event_type())
snapshot = catalog.all()
snapshot.clear()
assert catalog.get("test.thing") is not None
def test_payload_schema_is_derived_from_the_payload_model() -> None:
"""The JSON Schema a UI/validator consumes comes from the payload model."""
event_type = _event_type()
assert event_type.payload_schema == _SamplePayload.model_json_schema()
def test_each_catalog_instance_has_its_own_registry() -> None:
"""Two EventCatalog instances are fully independent."""
a = EventCatalog()
b = EventCatalog()
a.register(_event_type())
assert a.get("test.thing") is not None
assert b.get("test.thing") is None