- tests/unit/test_ratelimit.py: 11 tests for Redis token bucket (CHAN-05) - allows requests under limit, rejects 31st request - per-tenant isolation, per-channel isolation - TTL key expiry and window reset - tests/integration/test_slack_flow.py: 15 tests for end-to-end Slack flow (CHAN-02) - normalization: bot token stripped, channel=slack, thread_id set - @mention: placeholder posted in-thread, Celery dispatched with placeholder_ts - DM flow: same pipeline triggered for channel_type=im - bot messages silently ignored (no infinite loop) - unknown workspace_id silently ignored - duplicate events (Slack retries) skipped via idempotency - tests/integration/test_agent_persona.py: 15 tests for persona in prompts (AGNT-01) - system prompt contains name, role, persona, AI transparency clause - model_preference forwarded to LLM pool - full messages array: [system, user] structure verified - tests/integration/test_ratelimit.py: 4 tests for rate limit integration - over-limit -> ephemeral rejection posted - over-limit -> Celery NOT dispatched, placeholder NOT posted - within-limit -> no rejection - ephemeral message includes actionable retry hint All 45 tests pass
202 lines
7.5 KiB
Python
202 lines
7.5 KiB
Python
"""
|
|
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"])
|