""" Integration tests for rate limiting in the Slack event flow (CHAN-05). Tests verify: 1. Over-limit Slack events result in an ephemeral "too many requests" message being posted via the Slack client 2. Over-limit events do NOT dispatch to Celery These tests exercise the full gateway handler code path with: - fakeredis for rate limit state - mocked Slack client (no real Slack workspace) - mocked Celery task (no real Celery broker) """ 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, check_rate_limit @pytest_asyncio.fixture async def fake_redis(): """Fake async Redis for rate limit state.""" r = fakeredis.aioredis.FakeRedis(decode_responses=True) yield r await r.aclose() def _make_slack_event(user_id: str = "U12345", channel: str = "C99999") -> dict: """Minimal Slack app_mention event payload.""" return { "type": "app_mention", "user": user_id, "text": "<@UBOT123> hello", "ts": "1711234567.000100", "channel": channel, "channel_type": "channel", "_workspace_id": "T-WORKSPACE-X", "_bot_user_id": "UBOT123", } def _make_mock_session_factory(tenant_id: str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"): """Build a mock session factory that returns the given tenant_id.""" mock_session = AsyncMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) # resolve_tenant will call session methods — we patch at the function level mock_factory = MagicMock() mock_factory.return_value = mock_session return mock_factory class TestRateLimitIntegration: """CHAN-05: Integration tests for rate limit behavior in Slack handler.""" async def test_over_limit_sends_ephemeral_rejection(self, fake_redis) -> None: """ When rate limit is exceeded, an ephemeral 'too many requests' message must be posted to the user, not dispatched to Celery. """ tenant_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" mock_client = AsyncMock() mock_client.chat_postEphemeral = AsyncMock(return_value={"ok": True}) mock_client.chat_postMessage = AsyncMock(return_value={"ts": "999.000", "ok": True}) mock_say = AsyncMock() # Exhaust the rate limit for _ in range(30): await check_rate_limit(tenant_id, "slack", fake_redis, limit=30) event = _make_slack_event() with ( patch("gateway.channels.slack.resolve_tenant", new_callable=AsyncMock, return_value=tenant_id), patch("orchestrator.tasks.handle_message") as mock_celery_task, ): await _handle_slack_event( event=event, say=mock_say, client=mock_client, redis=fake_redis, get_session=_make_mock_session_factory(tenant_id), event_type="app_mention", ) # Ephemeral rejection must be sent mock_client.chat_postEphemeral.assert_called_once() call_kwargs = mock_client.chat_postEphemeral.call_args text = call_kwargs.kwargs.get("text", "") assert "too many requests" in text.lower() or "please try again" in text.lower() # Celery task must NOT be dispatched mock_celery_task.delay.assert_not_called() async def test_over_limit_does_not_post_placeholder(self, fake_redis) -> None: """ When rate limited, no 'Thinking...' placeholder message should be posted (the request is rejected before reaching the placeholder step). """ tenant_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" mock_client = AsyncMock() mock_client.chat_postEphemeral = AsyncMock(return_value={"ok": True}) mock_client.chat_postMessage = AsyncMock(return_value={"ts": "999.000", "ok": True}) mock_say = AsyncMock() # Exhaust the rate limit for _ in range(30): await check_rate_limit(tenant_id, "slack", fake_redis, limit=30) event = _make_slack_event() with ( patch("gateway.channels.slack.resolve_tenant", new_callable=AsyncMock, return_value=tenant_id), patch("orchestrator.tasks.handle_message"), ): await _handle_slack_event( event=event, say=mock_say, client=mock_client, redis=fake_redis, get_session=_make_mock_session_factory(tenant_id), event_type="app_mention", ) # Placeholder message must NOT be posted mock_client.chat_postMessage.assert_not_called() async def test_within_limit_dispatches_to_celery(self, fake_redis) -> None: """ Requests within the rate limit must dispatch to Celery (not rejected). """ tenant_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" mock_client = AsyncMock() mock_client.chat_postMessage = AsyncMock(return_value={"ts": "999.001", "ok": True}) mock_say = AsyncMock() event = _make_slack_event() mock_task = MagicMock() mock_task.delay = MagicMock() with ( patch("gateway.channels.slack.resolve_tenant", new_callable=AsyncMock, return_value=tenant_id), patch("router.idempotency.is_duplicate", new_callable=AsyncMock, return_value=False), patch("gateway.channels.slack._mark_thread_engaged", new_callable=AsyncMock), patch("gateway.channels.slack.handle_message_task", mock_task, create=True), ): # Patch the import inside the function 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(tenant_id), event_type="app_mention", ) # Ephemeral rejection must NOT be sent mock_client.chat_postEphemeral.assert_not_called() async def test_ephemeral_message_includes_retry_hint(self, fake_redis) -> None: """ The ephemeral rate limit rejection must mention when to retry. """ tenant_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" mock_client = AsyncMock() mock_client.chat_postEphemeral = AsyncMock(return_value={"ok": True}) mock_say = AsyncMock() # Exhaust the rate limit for _ in range(30): await check_rate_limit(tenant_id, "slack", fake_redis, limit=30) event = _make_slack_event() with ( patch("gateway.channels.slack.resolve_tenant", new_callable=AsyncMock, return_value=tenant_id), patch("orchestrator.tasks.handle_message"), ): await _handle_slack_event( event=event, say=mock_say, client=mock_client, redis=fake_redis, get_session=_make_mock_session_factory(tenant_id), event_type="app_mention", ) call_kwargs = mock_client.chat_postEphemeral.call_args text = call_kwargs.kwargs.get("text", "") # Message should give actionable guidance ("try again", "seconds", etc.) assert any(word in text.lower() for word in ["again", "second", "moment"])