Files
roboco/tests/unit/events/test_bus.py
T

156 lines
5.0 KiB
Python
Raw Normal View History

2026-05-06 21:02:31 +02:00
"""Coverage for roboco.events.bus thin wrapper functions."""
from __future__ import annotations
2026-06-30 08:08:35 +02:00
from typing import TYPE_CHECKING, cast
2026-05-06 21:02:31 +02:00
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.events.bus import EventBus, get_event_bus, init_event_bus
from roboco.events.stream_bus import StreamEventBus
2026-06-30 08:08:35 +02:00
from roboco.models.events import Event, EventType
if TYPE_CHECKING:
from redis.asyncio import Redis
2026-05-06 21:02:31 +02:00
def test_get_event_bus_delegates() -> None:
"""get_event_bus() returns the underlying stream event bus singleton."""
fake_bus = MagicMock()
with patch(
"roboco.events.bus.get_stream_event_bus", return_value=fake_bus
) as mock_get:
result = get_event_bus()
mock_get.assert_called_once()
assert result is fake_bus
@pytest.mark.asyncio
async def test_init_event_bus_delegates() -> None:
"""init_event_bus() forwards args to init_stream_event_bus (line 53)."""
fake_bus = MagicMock()
with patch(
"roboco.events.bus.init_stream_event_bus",
new_callable=AsyncMock,
return_value=fake_bus,
) as mock_init:
result = await init_event_bus(consumer_name="custom", recover_pending=False)
mock_init.assert_awaited_once_with(consumer_name="custom", recover_pending=False)
assert result is fake_bus
def test_event_bus_alias_is_stream_event_bus() -> None:
"""EventBus is an alias for StreamEventBus."""
assert EventBus is StreamEventBus
2026-06-30 08:08:35 +02:00
# --- #19: replayed events must not re-run already-succeeded handlers ---
class _FakeRedis:
"""In-memory stand-in for the redis client's SET-NX + DELETE surface.
``set(..., nx=True)`` returns True the first time a key is set, None if it
already exists (matches redis-py). ``delete`` removes a key. ``get`` is
unused but kept for completeness.
"""
def __init__(self) -> None:
self.keys: dict[str, str] = {}
self.set_calls: list[tuple[str, bool]] = []
async def set(
self, key: str, value: str, *, nx: bool = False, ex: int | None = None
) -> bool | None:
del ex
self.set_calls.append((key, nx))
if nx:
if key in self.keys:
return None
self.keys[key] = value
return True
self.keys[key] = value
return True
async def delete(self, key: str) -> int:
return 1 if self.keys.pop(key, None) is not None else 0
async def get(self, key: str) -> str | None:
return self.keys.get(key)
@pytest.mark.asyncio
async def test_dispatch_skips_handler_that_already_succeeded_on_replay() -> None:
"""#19: ``recover_pending`` re-delivers a message whose ACK was blocked by a
sibling handler's failure. A handler that already succeeded for that event
must NOT re-run (no duplicate side effects) — the bus marks each
(event.id, handler) processed via a SET-NX guard."""
bus = StreamEventBus()
bus._redis = cast("Redis", _FakeRedis())
calls: list[str] = []
async def _handler(_event: Event) -> None:
calls.append("ran")
bus.subscribe(EventType.NOTIFICATION_SENT, _handler)
event = Event(type=EventType.NOTIFICATION_SENT, data={"task_id": "t1"})
await bus._dispatch_event(event) # first delivery: handler runs
assert calls == ["ran"]
await bus._dispatch_event(event) # replay: handler already succeeded → skip
assert calls == ["ran"] # not re-run
@pytest.mark.asyncio
async def test_dispatch_reruns_handler_that_failed_on_first_attempt() -> None:
"""#19: a handler that failed on the first delivery must NOT be marked
processed — a replay re-runs it (the SET-NX key is cleared on failure)."""
bus = StreamEventBus()
bus._redis = cast("Redis", _FakeRedis())
attempts: list[str] = []
async def _flaky(_event: Event) -> None:
attempts.append("ran")
if len(attempts) == 1:
raise RuntimeError("transient blow-up")
bus.subscribe(EventType.NOTIFICATION_SENT, _flaky)
event = Event(type=EventType.NOTIFICATION_SENT, data={"task_id": "t2"})
first = await bus._dispatch_event(event) # fails → not marked processed
assert first is False
assert attempts == ["ran"]
await bus._dispatch_event(event) # replay: re-run (failed before)
assert attempts == ["ran", "ran"]
@pytest.mark.asyncio
async def test_dispatch_runs_handler_when_redis_guard_unavailable() -> None:
"""#19: the idempotency guard is best-effort — with no redis the bus must
still run the handler (fail-open: never skip a handler because the dedup
infra is down)."""
bus = StreamEventBus()
# No redis connected — guard is skipped, handler runs normally.
assert bus._redis is None
calls: list[str] = []
async def _handler(_event: Event) -> None:
calls.append("ran")
bus.subscribe(EventType.NOTIFICATION_SENT, _handler)
event = Event(type=EventType.NOTIFICATION_SENT, data={"task_id": "t3"})
ok = await bus._dispatch_event(event)
assert ok is True
assert calls == ["ran"]