- 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
463 lines
17 KiB
Python
463 lines
17 KiB
Python
"""
|
|
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
|