"""Event handler coverage — fanout to notification service.""" from __future__ import annotations from dataclasses import dataclass from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 import pytest from roboco.events.bus import Event, EventType from roboco.events.handlers import ( _get_doc_id, _get_pm_id, _get_qa_id, get_event_context, handle_blocker_resolved, handle_handoff_created, handle_qa_result, handle_question_answered, handle_session_boundary, handle_task_status_change, register_default_handlers, set_event_context, ) def _make_event(event_type: EventType, **data) -> Event: return Event( type=event_type, data=data, source_agent="be-dev-1", ) @pytest.fixture(autouse=True) def reset_context(): """Reset event context after each test. set_event_context only updates attrs when truthy, so we need to directly clear the underlying singleton EventContext to avoid leaking state between tests. """ yield ctx = get_event_context() ctx.notification_service = None ctx.orchestrator = None # --------------------------------------------------------------------------- # ID builders # --------------------------------------------------------------------------- def test_get_pm_id() -> None: assert _get_pm_id("backend") == "ba-pm" def test_get_qa_id() -> None: assert _get_qa_id("frontend") == "fr-qa" def test_get_doc_id() -> None: assert _get_doc_id("backend") == "ba-doc" # --------------------------------------------------------------------------- # Task status handlers — no-op when no notification service # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_handle_task_blocked_without_context_is_noop() -> None: event = _make_event( EventType.TASK_BLOCKED, task_id=str(uuid4()), team="backend", reason="x" ) # No notification_service set — does nothing. await handle_task_status_change(event) @pytest.mark.asyncio async def test_handle_task_blocked_calls_send_blocker() -> None: notif = MagicMock() notif.send_blocker_notification = AsyncMock() set_event_context(notification_service=notif) event = _make_event( EventType.TASK_BLOCKED, task_id=str(uuid4()), team="backend", reason="x" ) await handle_task_status_change(event) notif.send_blocker_notification.assert_called_once() @pytest.mark.asyncio async def test_handle_task_blocked_skips_when_no_team() -> None: notif = MagicMock() notif.send_blocker_notification = AsyncMock() set_event_context(notification_service=notif) event = _make_event(EventType.TASK_BLOCKED, task_id=str(uuid4())) await handle_task_status_change(event) notif.send_blocker_notification.assert_not_called() @pytest.mark.asyncio async def test_handle_task_awaiting_qa() -> None: notif = MagicMock() notif.send_qa_ready_notification = AsyncMock() set_event_context(notification_service=notif) event = _make_event( EventType.TASK_AWAITING_QA, task_id=str(uuid4()), team="backend" ) await handle_task_status_change(event) notif.send_qa_ready_notification.assert_called_once() @pytest.mark.asyncio async def test_handle_task_qa_failed() -> None: notif = MagicMock() notif.send_qa_failed_notification = AsyncMock() set_event_context(notification_service=notif) event = _make_event( EventType.TASK_QA_FAILED, task_id=str(uuid4()), assigned_to="be-dev-1", qa_notes="please fix", ) await handle_task_status_change(event) notif.send_qa_failed_notification.assert_called_once() @pytest.mark.asyncio async def test_handle_task_qa_failed_no_assigned_to_skips() -> None: notif = MagicMock() notif.send_qa_failed_notification = AsyncMock() set_event_context(notification_service=notif) event = _make_event(EventType.TASK_QA_FAILED, task_id=str(uuid4())) await handle_task_status_change(event) notif.send_qa_failed_notification.assert_not_called() @pytest.mark.asyncio async def test_handle_task_awaiting_docs() -> None: notif = MagicMock() notif.send_docs_ready_notification = AsyncMock() set_event_context(notification_service=notif) event = _make_event( EventType.TASK_AWAITING_DOCS, task_id=str(uuid4()), team="backend" ) await handle_task_status_change(event) notif.send_docs_ready_notification.assert_called_once() @pytest.mark.asyncio async def test_handle_task_status_change_unknown_type_noop() -> None: """No mapping for TASK_CREATED — does nothing.""" event = _make_event(EventType.TASK_CREATED, task_id=str(uuid4())) await handle_task_status_change(event) # No raise. # --------------------------------------------------------------------------- # Session and handoff handlers # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_handle_session_boundary_logs_and_returns() -> None: """Just exercises logging — no notification fanout in this handler.""" event = _make_event( EventType.SESSION_CLOSED, session_id=str(uuid4()), group_id=str(uuid4()), reason="timeout", ) await handle_session_boundary(event) @pytest.mark.asyncio async def test_handle_handoff_created_calls_notification() -> None: notif = MagicMock() notif.send_handoff_notification = AsyncMock() set_event_context(notification_service=notif) event = _make_event( EventType.HANDOFF_CREATED, task_id=str(uuid4()), handoff_id=str(uuid4()), team="backend", ) await handle_handoff_created(event) notif.send_handoff_notification.assert_called_once() @pytest.mark.asyncio async def test_handle_handoff_created_no_team_skips_notification() -> None: notif = MagicMock() notif.send_handoff_notification = AsyncMock() set_event_context(notification_service=notif) event = _make_event( EventType.HANDOFF_CREATED, task_id=str(uuid4()), handoff_id=str(uuid4()), ) await handle_handoff_created(event) notif.send_handoff_notification.assert_not_called() # --------------------------------------------------------------------------- # QA result + blocker resolved # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_handle_qa_result_passed() -> None: """QA passed event triggers wait resolution if dev is waiting.""" orch = MagicMock() orch.get_waiting_agents = MagicMock(return_value={}) orch.resolve_wait = AsyncMock() set_event_context(orchestrator=orch) event = _make_event( EventType.TASK_QA_PASSED, task_id=str(uuid4()), assigned_to="be-dev-1", ) await handle_qa_result(event) @pytest.mark.asyncio async def test_handle_blocker_resolved_logs() -> None: event = _make_event( EventType.TASK_UNBLOCKED, task_id=str(uuid4()), agent_id="be-dev-1", resolution="fixed", ) await handle_blocker_resolved(event) # --------------------------------------------------------------------------- # Awaiting docs handler — no-team early return. # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_handle_awaiting_docs_skips_when_no_team() -> None: notif = MagicMock() notif.send_docs_ready_notification = AsyncMock() set_event_context(notification_service=notif) event = _make_event(EventType.TASK_AWAITING_DOCS, task_id=str(uuid4())) await handle_task_status_change(event) notif.send_docs_ready_notification.assert_not_called() @pytest.mark.asyncio async def test_handle_awaiting_qa_skips_when_no_team() -> None: notif = MagicMock() notif.send_qa_ready_notification = AsyncMock() set_event_context(notification_service=notif) event = _make_event(EventType.TASK_AWAITING_QA, task_id=str(uuid4())) await handle_task_status_change(event) notif.send_qa_ready_notification.assert_not_called() @pytest.mark.asyncio async def test_handle_task_qa_failed_without_context_is_noop() -> None: """No notification service set — TASK_QA_FAILED handler returns early.""" event = _make_event( EventType.TASK_QA_FAILED, task_id=str(uuid4()), assigned_to="be-dev-1" ) await handle_task_status_change(event) @pytest.mark.asyncio async def test_handle_awaiting_qa_without_context_is_noop() -> None: event = _make_event( EventType.TASK_AWAITING_QA, task_id=str(uuid4()), team="backend" ) await handle_task_status_change(event) @pytest.mark.asyncio async def test_handle_awaiting_docs_without_context_is_noop() -> None: event = _make_event( EventType.TASK_AWAITING_DOCS, task_id=str(uuid4()), team="backend" ) await handle_task_status_change(event) # --------------------------------------------------------------------------- # QA result + waiting agent resolution # --------------------------------------------------------------------------- @dataclass class _FakeWaitRecord: waiting_for: str @pytest.mark.asyncio async def test_qa_result_resolves_waiting_developer() -> None: """When dev is waiting on `qa_result`, orchestrator.resolve_wait fires.""" orch = MagicMock() orch.get_waiting_agents = MagicMock( return_value={"be-dev-1": _FakeWaitRecord(waiting_for="qa_result")} ) orch.resolve_wait = AsyncMock() set_event_context(orchestrator=orch) event = _make_event( EventType.TASK_QA_PASSED, task_id=str(uuid4()), assigned_to="be-dev-1", qa_notes="lgtm", ) await handle_qa_result(event) orch.resolve_wait.assert_awaited_once() @pytest.mark.asyncio async def test_qa_result_does_not_resolve_when_not_waiting() -> None: orch = MagicMock() orch.get_waiting_agents = MagicMock(return_value={}) orch.resolve_wait = AsyncMock() set_event_context(orchestrator=orch) event = _make_event( EventType.TASK_QA_PASSED, task_id=str(uuid4()), assigned_to="be-dev-1", ) await handle_qa_result(event) orch.resolve_wait.assert_not_called() @pytest.mark.asyncio async def test_qa_result_skips_when_developer_id_missing() -> None: """Without `assigned_to`, _try_resolve_agent_wait short-circuits.""" orch = MagicMock() orch.get_waiting_agents = MagicMock(return_value={}) orch.resolve_wait = AsyncMock() set_event_context(orchestrator=orch) event = _make_event(EventType.TASK_QA_PASSED, task_id=str(uuid4())) await handle_qa_result(event) orch.resolve_wait.assert_not_called() @pytest.mark.asyncio async def test_qa_result_no_orchestrator_is_noop() -> None: event = _make_event( EventType.TASK_QA_PASSED, task_id=str(uuid4()), assigned_to="be-dev-1", ) # No orchestrator wired in. await handle_qa_result(event) @pytest.mark.asyncio async def test_qa_result_waiting_for_other_thing_is_noop() -> None: """Dev is waiting, but for blocker_resolution — qa_result event ignores them.""" orch = MagicMock() orch.get_waiting_agents = MagicMock( return_value={"be-dev-1": _FakeWaitRecord(waiting_for="blocker_resolution")} ) orch.resolve_wait = AsyncMock() set_event_context(orchestrator=orch) event = _make_event( EventType.TASK_QA_PASSED, task_id=str(uuid4()), assigned_to="be-dev-1", ) await handle_qa_result(event) orch.resolve_wait.assert_not_called() # --------------------------------------------------------------------------- # Question answered handler # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_handle_question_answered_resolves_waiting_agent() -> None: orch = MagicMock() orch.get_waiting_agents = MagicMock( return_value={"be-dev-1": _FakeWaitRecord(waiting_for="answer")} ) orch.resolve_wait = AsyncMock() set_event_context(orchestrator=orch) event = _make_event( EventType.QUESTION_ANSWERED, question_id=str(uuid4()), asking_agent="be-dev-1", answer="42", ) await handle_question_answered(event) orch.resolve_wait.assert_awaited_once() @pytest.mark.asyncio async def test_handle_blocker_resolved_resolves_waiting_agent() -> None: orch = MagicMock() orch.get_waiting_agents = MagicMock( return_value={"be-dev-1": _FakeWaitRecord(waiting_for="blocker_resolution")} ) orch.resolve_wait = AsyncMock() set_event_context(orchestrator=orch) event = _make_event( EventType.BLOCKER_RESOLVED, task_id=str(uuid4()), agent_id="be-dev-1", resolution="fixed", ) await handle_blocker_resolved(event) orch.resolve_wait.assert_awaited_once() # --------------------------------------------------------------------------- # get_event_context + register_default_handlers # --------------------------------------------------------------------------- def test_get_event_context_returns_singleton() -> None: a = get_event_context() b = get_event_context() assert a is b def test_register_default_handlers_subscribes_each_event() -> None: """register_default_handlers wires every documented EventType.""" bus = MagicMock() bus.subscribe = MagicMock() register_default_handlers(bus=bus) subscribed = [call.args[0] for call in bus.subscribe.call_args_list] assert EventType.TASK_BLOCKED in subscribed assert EventType.TASK_QA_PASSED in subscribed assert EventType.HANDOFF_CREATED in subscribed assert EventType.QUESTION_ANSWERED in subscribed def test_register_default_handlers_uses_global_bus_when_none_passed() -> None: """When `bus=None`, it falls back to `get_event_bus()`.""" fake_bus = MagicMock() fake_bus.subscribe = MagicMock() with patch("roboco.events.handlers.get_event_bus", return_value=fake_bus): register_default_handlers() fake_bus.subscribe.assert_called()