"""Coverage for roboco.events.bus thin wrapper functions.""" from __future__ import annotations import asyncio import contextlib import json from typing import TYPE_CHECKING, cast 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 from roboco.models.events import Event, EventType if TYPE_CHECKING: from redis.asyncio import Redis 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 # --- #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"] # --- poison-pill: an undecodable message must be ACKed, not retried forever --- class _FakeRedisStream: """In-memory redis surface for the xack/xadd/group path. Tracks ACKs and dead-letter xadds so the poison-pill test can assert a malformed message is acknowledged (not left pending) and parked on the dead-letter stream. ``xreadgroup`` yields nothing so a listen loop never spins. """ def __init__(self) -> None: self.xack_calls: list[tuple[str, str, tuple[str, ...]]] = [] self.xadd_calls: list[tuple[str, dict]] = [] async def xack(self, stream: str, group: str, *ids: str) -> int: self.xack_calls.append((stream, group, ids)) return len(ids) async def xadd( self, stream: str, fields: dict, maxlen: int | None = None, approximate: bool = True, ) -> bytes: del maxlen, approximate self.xadd_calls.append((stream, dict(fields))) return b"1-0" async def xreadgroup(self, *args: object, **kwargs: object) -> list: del args, kwargs return [] async def xgroup_create(self, *args: object, **kwargs: object) -> bool: del args, kwargs return True async def xpending(self, *args: object, **kwargs: object) -> dict: del args, kwargs return {"pending": 0} async def xpending_range(self, *args: object, **kwargs: object) -> list: del args, kwargs return [] async def xclaim(self, *args: object, **kwargs: object) -> list: del args, kwargs return [] async def set( self, key: str, value: str, *, nx: bool = False, ex: int | None = None ) -> bool: del key, value, nx, ex return True async def delete(self, key: str) -> int: del key return 1 async def get(self, key: str) -> None: del key async def close(self) -> None: """No-op close for the fake client.""" @pytest.mark.asyncio async def test_undecodable_message_is_acked_and_dead_lettered() -> None: """A message whose payload fails Event.from_json (unknown EventType value, bad UUID, malformed JSON) is a poison pill: no handler could ever process it, so retrying is pointless. The bus must ACK it (and dead-letter it) so the stream doesn't wedge on an unkillable pending message re-failing on every reclaim.""" bus = StreamEventBus() fake = _FakeRedisStream() bus._redis = cast("Redis", fake) invoked: list[str] = [] async def _handler(_event: Event) -> None: invoked.append("ran") bus.subscribe(EventType.NOTIFICATION_SENT, _handler) # type="task.bogus" is not a real EventType → EventType(...) raises ValueError # inside Event.from_json. malformed = json.dumps( { "id": "not-a-uuid", "type": "task.bogus_unknown", "data": {}, "timestamp": "2026-06-30T00:00:00+00:00", } ) await bus._handle_message("roboco:stream:task", "1234-0", {b"data": malformed}) # ACKed exactly once — not left pending for reclaim to re-fail forever. assert len(fake.xack_calls) == 1 assert fake.xack_calls[0][2] == ("1234-0",) # Dead-lettered for inspection before the ACK. assert len(fake.xadd_calls) == 1 assert fake.xadd_calls[0][0] == StreamEventBus.DEAD_LETTER_STREAM # No handler could run — the event never decoded. assert invoked == [] class _FakeRecoverRedis: """Fake whose xpending_range returns the message id as BYTES (the real client has no decode_responses), and which captures the ids XCLAIM gets.""" def __init__(self, message_id: bytes) -> None: self._message_id = message_id self.claimed_ids: list[object] = [] async def xpending(self, *args: object, **kwargs: object) -> dict: del args, kwargs return {"pending": 1} async def xpending_range(self, *args: object, **kwargs: object) -> list: del args, kwargs return [{"message_id": self._message_id, "time_since_delivered": 10_000}] async def xclaim(self, *args: object, **kwargs: object) -> list: del args self.claimed_ids = cast("list[object]", kwargs.get("message_ids") or []) return [] # nothing claimed back → no handling @pytest.mark.asyncio async def test_recover_stream_decodes_bytes_message_id_for_xclaim() -> None: """xpending_range returns the message id as bytes; _recover_stream must decode it before XCLAIM. A raw ``str(bytes)`` yields ``"b'1782..-0'"``, which Redis rejects with "Unrecognized XCLAIM option", so pending-message recovery silently fails every reclaim tick.""" bus = StreamEventBus() fake = _FakeRecoverRedis(b"1782066556728-0") bus._redis = cast("Redis", fake) await bus._recover_stream("roboco:stream:usage", idle_time_ms=0) assert fake.claimed_ids == ["1782066556728-0"] # decoded, not "b'...'" # --- periodic reclaim: a runtime handler failure is retried without a restart --- @pytest.mark.asyncio async def test_reclaim_loop_periodically_calls_recover_pending() -> None: """XREADGROUP '>' delivers only NEW messages, so a handler that fails at runtime leaves its message pending and unretried until the orchestrator restarts. A periodic reclaim loop must call recover_pending so the idempotency-guarded replay actually fires.""" bus = StreamEventBus() bus._running = True bus._reclaim_interval = 60 calls: list[int] = [] async def _fake_recover(idle_time_ms: int = 60000) -> int: calls.append(idle_time_ms) bus._running = False # break the loop after the first reclaim return 0 bus.recover_pending = _fake_recover # type: ignore[method-assign] async def _no_sleep(_seconds: float) -> None: return with patch("roboco.events.stream_bus.asyncio.sleep", new=_no_sleep): await bus._reclaim_loop() # Reclaim ran once with the interval-aligned idle window, then the loop exited. assert calls == [60000] @pytest.mark.asyncio async def test_start_listening_spawns_reclaim_task_alongside_listen() -> None: """start_listening must spawn the reclaim task, not just the listen task — otherwise pending messages are never re-delivered at runtime.""" bus = StreamEventBus() bus._redis = cast("Redis", _FakeRedisStream()) async def _noop(self: StreamEventBus) -> None: del self async def _handler(_event: Event) -> None: ... bus.subscribe(EventType.NOTIFICATION_SENT, _handler) with ( patch.object(StreamEventBus, "_listen_loop", _noop), patch.object(StreamEventBus, "_reclaim_loop", _noop), ): await bus.start_listening() try: assert bus._listen_task is not None assert bus._reclaim_task is not None finally: await bus.disconnect() # --- cancellation mid-handler must clear the idempotency marker --- @pytest.mark.asyncio async def test_cancelled_handler_clears_idempotency_marker() -> None: """A handler cancelled mid-flight (shutdown / sibling gather cancellation) is BaseException-cancelled, not Exception-raised, so the old ``except Exception`` left the SET-NX marker set: the message stayed pending but the guard then suppressed the very redelivery that would complete the work. The cleanup must catch BaseException so the marker is cleared and reclaim re-runs the handler.""" bus = StreamEventBus() fake = _FakeRedis() bus._redis = cast("Redis", fake) started = asyncio.Event() proceed = asyncio.Event() async def _blocking(_event: Event) -> None: started.set() await proceed.wait() # block until the dispatch task is cancelled bus.subscribe(EventType.NOTIFICATION_SENT, _blocking) event = Event(type=EventType.NOTIFICATION_SENT, data={"task_id": "tc"}) task = asyncio.create_task(bus._dispatch_event(event)) await started.wait() # handler is now blocked → marker is set key = f"bus:processed:{event.id}:_blocking" assert key in fake.keys # marker acquired before the handler blocked task.cancel() with contextlib.suppress(asyncio.CancelledError): await task # Marker cleared despite cancellation → a replay re-runs the handler. assert key not in fake.keys