""" Integration tests for the end-to-end Slack event flow (CHAN-02). Tests verify: 1. app_mention event -> normalize -> tenant resolve -> Celery dispatch -> LLM -> thread reply 2. DM (message with channel_type="im") follows the same pipeline 3. Placeholder "Thinking..." is posted before Celery dispatch 4. Placeholder is replaced with real response (via chat.update in orchestrator task) 5. Bot messages are ignored (no infinite response loop) 6. Unknown workspace_id events are silently ignored All tests mock the Slack client and Celery task — no live Slack workspace or Celery broker required. """ from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import fakeredis import pytest import pytest_asyncio from gateway.channels.slack import _handle_slack_event from gateway.normalize import normalize_slack_event from shared.models.message import ChannelType @pytest_asyncio.fixture async def fake_redis(): """Fake async Redis.""" r = fakeredis.aioredis.FakeRedis(decode_responses=True) yield r await r.aclose() TENANT_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" WORKSPACE_ID = "T-WORKSPACE-TEST" def _make_mention_event( user: str = "U12345", text: str = "<@UBOT123> can you help me?", channel: str = "C99999", ) -> dict: """Build a Slack app_mention event.""" return { "type": "app_mention", "user": user, "text": text, "ts": "1711234567.000100", "channel": channel, "channel_type": "channel", "_workspace_id": WORKSPACE_ID, "_bot_user_id": "UBOT123", } def _make_dm_event( user: str = "U12345", text: str = "help me please", channel: str = "D11111", ) -> dict: """Build a Slack DM (im) event.""" return { "type": "message", "user": user, "text": text, "ts": "1711234567.000200", "channel": channel, "channel_type": "im", "_workspace_id": WORKSPACE_ID, "_bot_user_id": "UBOT123", } def _make_bot_event() -> dict: """A Slack bot_message event (must be ignored).""" return { "type": "message", "bot_id": "B11111", "subtype": "bot_message", "text": "I replied to something", "ts": "1711234567.000300", "channel": "C99999", "_workspace_id": WORKSPACE_ID, "_bot_user_id": "UBOT123", } class TestNormalization: """normalize_slack_event unit coverage for CHAN-02 gateway code path.""" def test_mention_text_strips_bot_token(self) -> None: """Bot @mention token must be stripped from text before sending to agent.""" event = {"user": "U1", "text": "<@UBOT123> hello there", "ts": "123.456"} msg = normalize_slack_event(event, workspace_id="T-WS", bot_user_id="UBOT123") assert msg.content.text == "hello there" assert "<@UBOT123>" not in msg.content.text def test_channel_type_is_slack(self) -> None: """Normalized message must have channel=SLACK.""" event = {"user": "U1", "text": "hi", "ts": "123.456"} msg = normalize_slack_event(event, workspace_id="T-WS") assert msg.channel == ChannelType.SLACK def test_tenant_id_none_after_normalization(self) -> None: """tenant_id must be None — Router populates it, not the normalizer.""" event = {"user": "U1", "text": "hi", "ts": "123.456"} msg = normalize_slack_event(event, workspace_id="T-WS") assert msg.tenant_id is None def test_thread_id_set_from_thread_ts(self) -> None: """thread_id must be set from thread_ts when present.""" event = { "user": "U1", "text": "reply", "ts": "123.999", "thread_ts": "123.000", } msg = normalize_slack_event(event, workspace_id="T-WS") assert msg.thread_id == "123.000" def test_workspace_id_in_channel_metadata(self) -> None: """workspace_id must be stored in channel_metadata.""" event = {"user": "U1", "text": "hi", "ts": "123.456"} msg = normalize_slack_event(event, workspace_id="T-WORKSPACE-X") assert msg.channel_metadata["workspace_id"] == "T-WORKSPACE-X" class TestSlackMentionFlow: """CHAN-02: End-to-end app_mention event pipeline.""" async def test_mention_posts_thinking_placeholder(self, fake_redis) -> None: """ When a valid @mention arrives, a 'Thinking...' placeholder must be posted in-thread before Celery dispatch. """ mock_client = AsyncMock() mock_client.chat_postMessage = AsyncMock(return_value={"ts": "999.001", "ok": True}) mock_say = AsyncMock() event = _make_mention_event() with ( patch("gateway.channels.slack.resolve_tenant", new_callable=AsyncMock, return_value=TENANT_ID), patch("gateway.channels.slack.is_duplicate", new_callable=AsyncMock, return_value=False), patch("gateway.channels.slack._mark_thread_engaged", new_callable=AsyncMock), patch("orchestrator.tasks.handle_message") as celery_mock, ): celery_mock.delay = MagicMock() await _handle_slack_event( event=event, say=mock_say, client=mock_client, redis=fake_redis, get_session=_make_mock_session_factory(), event_type="app_mention", ) # Placeholder must have been posted mock_client.chat_postMessage.assert_called_once() call_kwargs = mock_client.chat_postMessage.call_args placeholder_text = call_kwargs.kwargs.get("text", "") assert "thinking" in placeholder_text.lower() async def test_mention_posts_placeholder_in_thread(self, fake_redis) -> None: """ The placeholder must be posted with thread_ts set (in-thread reply). """ mock_client = AsyncMock() mock_client.chat_postMessage = AsyncMock(return_value={"ts": "999.001", "ok": True}) mock_say = AsyncMock() event = _make_mention_event() event["thread_ts"] = "1711234567.000000" # Set thread context with ( patch("gateway.channels.slack.resolve_tenant", new_callable=AsyncMock, return_value=TENANT_ID), patch("gateway.channels.slack.is_duplicate", new_callable=AsyncMock, return_value=False), patch("gateway.channels.slack._mark_thread_engaged", new_callable=AsyncMock), patch("orchestrator.tasks.handle_message") as celery_mock, ): celery_mock.delay = MagicMock() await _handle_slack_event( event=event, say=mock_say, client=mock_client, redis=fake_redis, get_session=_make_mock_session_factory(), event_type="app_mention", ) call_kwargs = mock_client.chat_postMessage.call_args # thread_ts must be set in the placeholder post assert call_kwargs.kwargs.get("thread_ts") is not None async def test_mention_dispatches_celery_task(self, fake_redis) -> None: """ After the placeholder is posted, the Celery task must be dispatched with the message data. """ mock_client = AsyncMock() mock_client.chat_postMessage = AsyncMock(return_value={"ts": "999.001", "ok": True}) mock_say = AsyncMock() event = _make_mention_event() dispatched_payloads: list[dict] = [] def capture_delay(payload: dict) -> MagicMock: dispatched_payloads.append(payload) return MagicMock() with ( patch("gateway.channels.slack.resolve_tenant", new_callable=AsyncMock, return_value=TENANT_ID), patch("gateway.channels.slack.is_duplicate", new_callable=AsyncMock, return_value=False), patch("gateway.channels.slack._mark_thread_engaged", new_callable=AsyncMock), patch("orchestrator.tasks.handle_message") as celery_mock, ): celery_mock.delay = MagicMock(side_effect=capture_delay) await _handle_slack_event( event=event, say=mock_say, client=mock_client, redis=fake_redis, get_session=_make_mock_session_factory(), event_type="app_mention", ) assert len(dispatched_payloads) == 1 payload = dispatched_payloads[0] assert payload["tenant_id"] == TENANT_ID assert payload["channel"] == "slack" # placeholder_ts and channel_id must be present assert "placeholder_ts" in payload assert "channel_id" in payload async def test_celery_payload_has_placeholder_ts(self, fake_redis) -> None: """Celery payload must include placeholder_ts for post-LLM chat.update.""" mock_client = AsyncMock() placeholder_ts = "1234567890.111111" mock_client.chat_postMessage = AsyncMock( return_value={"ts": placeholder_ts, "ok": True} ) mock_say = AsyncMock() event = _make_mention_event() dispatched_payloads: list[dict] = [] with ( patch("gateway.channels.slack.resolve_tenant", new_callable=AsyncMock, return_value=TENANT_ID), patch("gateway.channels.slack.is_duplicate", new_callable=AsyncMock, return_value=False), patch("gateway.channels.slack._mark_thread_engaged", new_callable=AsyncMock), patch("orchestrator.tasks.handle_message") as celery_mock, ): celery_mock.delay = MagicMock(side_effect=lambda p: dispatched_payloads.append(p)) await _handle_slack_event( event=event, say=mock_say, client=mock_client, redis=fake_redis, get_session=_make_mock_session_factory(), event_type="app_mention", ) assert dispatched_payloads[0]["placeholder_ts"] == placeholder_ts class TestDMFlow: """CHAN-02: Direct message event pipeline.""" async def test_dm_triggers_same_pipeline(self, fake_redis) -> None: """ A DM (channel_type='im') must trigger the same handler pipeline as an @mention. """ mock_client = AsyncMock() mock_client.chat_postMessage = AsyncMock(return_value={"ts": "999.002", "ok": True}) mock_say = AsyncMock() event = _make_dm_event() with ( patch("gateway.channels.slack.resolve_tenant", new_callable=AsyncMock, return_value=TENANT_ID), patch("gateway.channels.slack.is_duplicate", new_callable=AsyncMock, return_value=False), patch("gateway.channels.slack._mark_thread_engaged", new_callable=AsyncMock), patch("orchestrator.tasks.handle_message") as celery_mock, ): celery_mock.delay = MagicMock() await _handle_slack_event( event=event, say=mock_say, client=mock_client, redis=fake_redis, get_session=_make_mock_session_factory(), event_type="dm", ) # Pipeline should proceed: placeholder posted + Celery dispatched mock_client.chat_postMessage.assert_called_once() celery_mock.delay.assert_called_once() async def test_dm_payload_channel_is_slack(self, fake_redis) -> None: """DM normalized message must still have channel=slack.""" mock_client = AsyncMock() mock_client.chat_postMessage = AsyncMock(return_value={"ts": "999.002", "ok": True}) mock_say = AsyncMock() event = _make_dm_event() dispatched_payloads: list[dict] = [] with ( patch("gateway.channels.slack.resolve_tenant", new_callable=AsyncMock, return_value=TENANT_ID), patch("gateway.channels.slack.is_duplicate", new_callable=AsyncMock, return_value=False), patch("gateway.channels.slack._mark_thread_engaged", new_callable=AsyncMock), patch("orchestrator.tasks.handle_message") as celery_mock, ): celery_mock.delay = MagicMock(side_effect=lambda p: dispatched_payloads.append(p)) await _handle_slack_event( event=event, say=mock_say, client=mock_client, redis=fake_redis, get_session=_make_mock_session_factory(), event_type="dm", ) assert dispatched_payloads[0]["channel"] == "slack" class TestBotIgnoring: """Verify bot messages are silently ignored to prevent infinite loops.""" async def test_bot_message_is_ignored(self, fake_redis) -> None: """ Events with bot_id must be silently dropped — no placeholder, no Celery. """ mock_client = AsyncMock() mock_say = AsyncMock() event = _make_bot_event() with patch("orchestrator.tasks.handle_message") as celery_mock: celery_mock.delay = MagicMock() await _handle_slack_event( event=event, say=mock_say, client=mock_client, redis=fake_redis, get_session=_make_mock_session_factory(), event_type="dm", ) # Nothing should happen mock_client.chat_postMessage.assert_not_called() celery_mock.delay.assert_not_called() async def test_bot_message_subtype_is_ignored(self, fake_redis) -> None: """Events with subtype=bot_message must also be ignored.""" mock_client = AsyncMock() mock_say = AsyncMock() event = { "subtype": "bot_message", "text": "automated message", "ts": "123.456", "channel": "C99999", "_workspace_id": WORKSPACE_ID, } with patch("orchestrator.tasks.handle_message") as celery_mock: celery_mock.delay = MagicMock() await _handle_slack_event( event=event, say=mock_say, client=mock_client, redis=fake_redis, get_session=_make_mock_session_factory(), event_type="dm", ) mock_client.chat_postMessage.assert_not_called() celery_mock.delay.assert_not_called() class TestUnknownWorkspace: """Verify unknown workspace_id events are silently ignored.""" async def test_unknown_workspace_silently_ignored(self, fake_redis) -> None: """ If workspace_id maps to no tenant, the event must be silently dropped — no placeholder, no Celery dispatch, no exception raised. """ mock_client = AsyncMock() mock_say = AsyncMock() event = _make_mention_event() with ( patch("gateway.channels.slack.resolve_tenant", new_callable=AsyncMock, return_value=None), patch("orchestrator.tasks.handle_message") as celery_mock, ): celery_mock.delay = MagicMock() # Must not raise await _handle_slack_event( event=event, say=mock_say, client=mock_client, redis=fake_redis, get_session=_make_mock_session_factory(), event_type="app_mention", ) mock_client.chat_postMessage.assert_not_called() celery_mock.delay.assert_not_called() class TestIdempotency: """Verify duplicate Slack events (retries) are not double-dispatched.""" async def test_duplicate_event_is_skipped(self, fake_redis) -> None: """ If a message was already processed (Slack retry), no placeholder is posted and Celery is not called again. """ mock_client = AsyncMock() mock_say = AsyncMock() event = _make_mention_event() with ( patch("gateway.channels.slack.resolve_tenant", new_callable=AsyncMock, return_value=TENANT_ID), patch("gateway.channels.slack.is_duplicate", new_callable=AsyncMock, return_value=True), patch("orchestrator.tasks.handle_message") as celery_mock, ): celery_mock.delay = MagicMock() await _handle_slack_event( event=event, say=mock_say, client=mock_client, redis=fake_redis, get_session=_make_mock_session_factory(), event_type="app_mention", ) mock_client.chat_postMessage.assert_not_called() celery_mock.delay.assert_not_called() # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- def _make_mock_session_factory(tenant_id: str = TENANT_ID) -> MagicMock: """Return a mock async context manager factory for DB sessions.""" mock_session = AsyncMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) mock_factory = MagicMock() mock_factory.return_value = mock_session return mock_factory