mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-31 19:45:15 +02:00
feat(event_bus): add EventCatalog class with register/get/all methods
This commit is contained in:
parent
30fff9e52f
commit
2a511b8559
3 changed files with 146 additions and 0 deletions
48
surfsense_backend/app/event_bus/catalog.py
Normal file
48
surfsense_backend/app/event_bus/catalog.py
Normal 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()
|
||||||
25
surfsense_backend/tests/unit/event_bus/conftest.py
Normal file
25
surfsense_backend/tests/unit/event_bus/conftest.py
Normal 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)
|
||||||
73
surfsense_backend/tests/unit/event_bus/test_catalog.py
Normal file
73
surfsense_backend/tests/unit/event_bus/test_catalog.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue