Files
konstruct/tests/integration/test_ratelimit.py
Adolfo Delorenzo 74326dfc3d feat(01-03): integration tests for Slack flow, rate limiting, and agent persona
- 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
2026-03-23 10:32:48 -06:00

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"])