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
This commit is contained in:
2026-03-23 10:32:48 -06:00
parent 6f30705e1a
commit 74326dfc3d
4 changed files with 1127 additions and 0 deletions

View File

@@ -0,0 +1,462 @@
"""
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