From 2a511b85590cde348a1b875d8d4754fd7975f027 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 29 May 2026 22:15:09 +0200 Subject: [PATCH] feat(event_bus): add EventCatalog class with register/get/all methods --- surfsense_backend/app/event_bus/catalog.py | 48 ++++++++++++ .../tests/unit/event_bus/conftest.py | 25 +++++++ .../tests/unit/event_bus/test_catalog.py | 73 +++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 surfsense_backend/app/event_bus/catalog.py create mode 100644 surfsense_backend/tests/unit/event_bus/conftest.py create mode 100644 surfsense_backend/tests/unit/event_bus/test_catalog.py diff --git a/surfsense_backend/app/event_bus/catalog.py b/surfsense_backend/app/event_bus/catalog.py new file mode 100644 index 000000000..a50be689f --- /dev/null +++ b/surfsense_backend/app/event_bus/catalog.py @@ -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() diff --git a/surfsense_backend/tests/unit/event_bus/conftest.py b/surfsense_backend/tests/unit/event_bus/conftest.py new file mode 100644 index 000000000..81ba4e464 --- /dev/null +++ b/surfsense_backend/tests/unit/event_bus/conftest.py @@ -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) diff --git a/surfsense_backend/tests/unit/event_bus/test_catalog.py b/surfsense_backend/tests/unit/event_bus/test_catalog.py new file mode 100644 index 000000000..cbd377b02 --- /dev/null +++ b/surfsense_backend/tests/unit/event_bus/test_catalog.py @@ -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