- Add gateway/channels/slack_media.py with is_file_share_event, media_type_from_mime, build_slack_storage_key, build_attachment_from_slack_file, download_and_store_slack_file - Add _send_response() helper to orchestrator/tasks.py for channel-aware dispatch (Slack -> chat.update, WhatsApp -> send_whatsapp_message) - Add send_whatsapp_message import to orchestrator/tasks.py for WhatsApp outbound - Add boto3>=1.35.0 to gateway dependencies for MinIO S3 client - Add 23 unit tests in test_slack_media.py (TDD)
429 lines
16 KiB
Python
429 lines
16 KiB
Python
"""
|
|
Unit tests for Slack file_share media extraction and channel-aware outbound routing.
|
|
|
|
Tests:
|
|
- file_share event detection (file_share subtype identified)
|
|
- MediaAttachment creation from Slack file metadata (correct media_type, filename, mime_type)
|
|
- MinIO upload key format: {tenant_id}/{agent_id}/{message_id}/{filename}
|
|
- Channel-aware outbound routing helper (Slack vs WhatsApp)
|
|
- Mock httpx/boto3 calls to avoid real API hits
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from shared.models.message import MediaType
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers to build Slack event payloads
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def make_file_share_event(
|
|
file_id: str = "F12345",
|
|
file_name: str = "screenshot.png",
|
|
mime_type: str = "image/png",
|
|
size: int = 4096,
|
|
workspace_id: str = "T-WORKSPACE-1",
|
|
channel: str = "C-CHANNEL-1",
|
|
user: str = "U-USER-1",
|
|
ts: str = "1711234567.000100",
|
|
thread_ts: str | None = None,
|
|
) -> dict:
|
|
"""Build a minimal Slack file_share event dict."""
|
|
event: dict = {
|
|
"type": "message",
|
|
"subtype": "file_share",
|
|
"channel": channel,
|
|
"channel_type": "channel",
|
|
"user": user,
|
|
"ts": ts,
|
|
"files": [
|
|
{
|
|
"id": file_id,
|
|
"name": file_name,
|
|
"mimetype": mime_type,
|
|
"size": size,
|
|
"url_private_download": f"https://files.slack.com/files-pri/T-xxx/{file_name}",
|
|
}
|
|
],
|
|
"_workspace_id": workspace_id,
|
|
"_bot_user_id": "U-BOT-1",
|
|
"text": "",
|
|
}
|
|
if thread_ts:
|
|
event["thread_ts"] = thread_ts
|
|
return event
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: file_share detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestFileShareDetection:
|
|
"""Tests that file_share subtypes are correctly identified."""
|
|
|
|
def test_is_file_share_event_true(self):
|
|
"""Event with subtype='file_share' returns True."""
|
|
from gateway.channels.slack_media import is_file_share_event
|
|
|
|
event = make_file_share_event()
|
|
assert is_file_share_event(event) is True
|
|
|
|
def test_is_file_share_event_false_for_regular_message(self):
|
|
"""Regular message event (no subtype) returns False."""
|
|
from gateway.channels.slack_media import is_file_share_event
|
|
|
|
event = {"type": "message", "text": "Hello", "user": "U123"}
|
|
assert is_file_share_event(event) is False
|
|
|
|
def test_is_file_share_event_false_for_bot_message(self):
|
|
"""Bot message subtype returns False."""
|
|
from gateway.channels.slack_media import is_file_share_event
|
|
|
|
event = {"type": "message", "subtype": "bot_message", "text": "Bot says hi"}
|
|
assert is_file_share_event(event) is False
|
|
|
|
def test_is_file_share_event_false_for_empty_event(self):
|
|
"""Empty event dict returns False."""
|
|
from gateway.channels.slack_media import is_file_share_event
|
|
|
|
assert is_file_share_event({}) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: MediaAttachment creation from Slack file metadata
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMediaAttachmentFromSlackFile:
|
|
"""Tests that Slack file metadata is correctly mapped to MediaAttachment."""
|
|
|
|
def test_image_file_produces_image_media_type(self):
|
|
"""image/png MIME type maps to MediaType.IMAGE."""
|
|
from gateway.channels.slack_media import media_type_from_mime
|
|
|
|
assert media_type_from_mime("image/png") == MediaType.IMAGE
|
|
|
|
def test_jpeg_file_produces_image_media_type(self):
|
|
"""image/jpeg MIME type maps to MediaType.IMAGE."""
|
|
from gateway.channels.slack_media import media_type_from_mime
|
|
|
|
assert media_type_from_mime("image/jpeg") == MediaType.IMAGE
|
|
|
|
def test_gif_file_produces_image_media_type(self):
|
|
"""image/gif MIME type maps to MediaType.IMAGE."""
|
|
from gateway.channels.slack_media import media_type_from_mime
|
|
|
|
assert media_type_from_mime("image/gif") == MediaType.IMAGE
|
|
|
|
def test_pdf_produces_document_media_type(self):
|
|
"""application/pdf MIME type maps to MediaType.DOCUMENT."""
|
|
from gateway.channels.slack_media import media_type_from_mime
|
|
|
|
assert media_type_from_mime("application/pdf") == MediaType.DOCUMENT
|
|
|
|
def test_docx_produces_document_media_type(self):
|
|
"""application/vnd.openxmlformats-officedocument... maps to MediaType.DOCUMENT."""
|
|
from gateway.channels.slack_media import media_type_from_mime
|
|
|
|
result = media_type_from_mime(
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
)
|
|
assert result == MediaType.DOCUMENT
|
|
|
|
def test_mp3_produces_audio_media_type(self):
|
|
"""audio/mpeg MIME type maps to MediaType.AUDIO."""
|
|
from gateway.channels.slack_media import media_type_from_mime
|
|
|
|
assert media_type_from_mime("audio/mpeg") == MediaType.AUDIO
|
|
|
|
def test_mp4_produces_video_media_type(self):
|
|
"""video/mp4 MIME type maps to MediaType.VIDEO."""
|
|
from gateway.channels.slack_media import media_type_from_mime
|
|
|
|
assert media_type_from_mime("video/mp4") == MediaType.VIDEO
|
|
|
|
def test_unknown_mime_defaults_to_document(self):
|
|
"""Unknown MIME type falls back to MediaType.DOCUMENT."""
|
|
from gateway.channels.slack_media import media_type_from_mime
|
|
|
|
assert media_type_from_mime("application/x-custom") == MediaType.DOCUMENT
|
|
|
|
def test_build_attachment_from_slack_file(self):
|
|
"""MediaAttachment is correctly built from Slack file metadata."""
|
|
from gateway.channels.slack_media import build_attachment_from_slack_file
|
|
|
|
file_info = {
|
|
"id": "F12345",
|
|
"name": "screenshot.png",
|
|
"mimetype": "image/png",
|
|
"size": 4096,
|
|
"url_private_download": "https://files.slack.com/files-pri/T-xxx/screenshot.png",
|
|
}
|
|
storage_key = "tenant-1/agent-1/msg-1/screenshot.png"
|
|
|
|
attachment = build_attachment_from_slack_file(file_info, storage_key)
|
|
|
|
assert attachment.media_type == MediaType.IMAGE
|
|
assert attachment.filename == "screenshot.png"
|
|
assert attachment.mime_type == "image/png"
|
|
assert attachment.size_bytes == 4096
|
|
assert attachment.storage_key == storage_key
|
|
|
|
def test_build_attachment_pdf(self):
|
|
"""PDF file produces DOCUMENT media type attachment."""
|
|
from gateway.channels.slack_media import build_attachment_from_slack_file
|
|
|
|
file_info = {
|
|
"id": "F99999",
|
|
"name": "report.pdf",
|
|
"mimetype": "application/pdf",
|
|
"size": 102400,
|
|
"url_private_download": "https://files.slack.com/files-pri/T-xxx/report.pdf",
|
|
}
|
|
storage_key = "tenant-2/agent-2/msg-2/report.pdf"
|
|
|
|
attachment = build_attachment_from_slack_file(file_info, storage_key)
|
|
|
|
assert attachment.media_type == MediaType.DOCUMENT
|
|
assert attachment.filename == "report.pdf"
|
|
assert attachment.mime_type == "application/pdf"
|
|
assert attachment.size_bytes == 102400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: MinIO storage key format
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMinIOStorageKey:
|
|
"""Tests that MinIO storage keys follow the correct format."""
|
|
|
|
def test_storage_key_format(self):
|
|
"""Storage key follows {tenant_id}/{agent_id}/{message_id}/{filename} format."""
|
|
from gateway.channels.slack_media import build_slack_storage_key
|
|
|
|
key = build_slack_storage_key(
|
|
tenant_id="tenant-abc",
|
|
agent_id="agent-xyz",
|
|
message_id="msg-123",
|
|
filename="photo.jpg",
|
|
)
|
|
|
|
assert key == "tenant-abc/agent-xyz/msg-123/photo.jpg"
|
|
|
|
def test_storage_key_with_all_parts(self):
|
|
"""Storage key includes all four parts."""
|
|
from gateway.channels.slack_media import build_slack_storage_key
|
|
|
|
key = build_slack_storage_key(
|
|
tenant_id="t1",
|
|
agent_id="a1",
|
|
message_id="m1",
|
|
filename="document.pdf",
|
|
)
|
|
|
|
parts = key.split("/")
|
|
assert len(parts) == 4
|
|
assert parts[0] == "t1"
|
|
assert parts[1] == "a1"
|
|
assert parts[2] == "m1"
|
|
assert parts[3] == "document.pdf"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Download and upload to MinIO (mocked)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSlackFileDownloadAndUpload:
|
|
"""Tests for Slack file download and MinIO upload flow."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_and_store_slack_file_calls_minio(self):
|
|
"""download_and_store_slack_file downloads from Slack and uploads to MinIO."""
|
|
from gateway.channels.slack_media import download_and_store_slack_file
|
|
|
|
file_info = {
|
|
"id": "F12345",
|
|
"name": "image.png",
|
|
"mimetype": "image/png",
|
|
"size": 2048,
|
|
"url_private_download": "https://files.slack.com/files-pri/T-xxx/image.png",
|
|
}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.content = b"fake-image-bytes"
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
mock_s3 = MagicMock()
|
|
mock_s3.put_object = MagicMock()
|
|
mock_s3.generate_presigned_url = MagicMock(
|
|
return_value="http://minio:9000/konstruct-media/tenant-1/agent-1/msg-1/image.png?X-Amz-Signature=abc"
|
|
)
|
|
|
|
with patch("boto3.client") as mock_boto3_client:
|
|
mock_boto3_client.return_value = mock_s3
|
|
with patch("httpx.AsyncClient") as mock_httpx:
|
|
mock_client = AsyncMock()
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
mock_client.get = AsyncMock(return_value=mock_response)
|
|
mock_httpx.return_value = mock_client
|
|
|
|
storage_key, presigned_url = await download_and_store_slack_file(
|
|
tenant_id="tenant-1",
|
|
agent_id="agent-1",
|
|
message_id="msg-1",
|
|
file_info=file_info,
|
|
bot_token="xoxb-test-token",
|
|
)
|
|
|
|
assert storage_key == "tenant-1/agent-1/msg-1/image.png"
|
|
assert "minio:9000" in presigned_url
|
|
mock_s3.put_object.assert_called_once()
|
|
mock_s3.generate_presigned_url.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_uses_bot_token_for_authorization(self):
|
|
"""Download request includes Slack bot token in Authorization header."""
|
|
from gateway.channels.slack_media import download_and_store_slack_file
|
|
|
|
file_info = {
|
|
"id": "F99999",
|
|
"name": "doc.pdf",
|
|
"mimetype": "application/pdf",
|
|
"size": 5000,
|
|
"url_private_download": "https://files.slack.com/files-pri/T-xxx/doc.pdf",
|
|
}
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.content = b"pdf-content"
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
captured_headers: list[dict] = []
|
|
|
|
async def fake_get(url: str, **kwargs: object) -> MagicMock:
|
|
captured_headers.append(dict(kwargs.get("headers", {})))
|
|
return mock_response
|
|
|
|
mock_s3 = MagicMock()
|
|
mock_s3.put_object = MagicMock()
|
|
mock_s3.generate_presigned_url = MagicMock(
|
|
return_value="http://minio:9000/key?sig=xyz"
|
|
)
|
|
|
|
with patch("boto3.client") as mock_boto3_client:
|
|
mock_boto3_client.return_value = mock_s3
|
|
with patch("httpx.AsyncClient") as mock_httpx:
|
|
mock_client = AsyncMock()
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
mock_client.get = AsyncMock(side_effect=fake_get)
|
|
mock_httpx.return_value = mock_client
|
|
|
|
await download_and_store_slack_file(
|
|
tenant_id="tenant-1",
|
|
agent_id="agent-1",
|
|
message_id="msg-1",
|
|
file_info=file_info,
|
|
bot_token="xoxb-my-bot-token",
|
|
)
|
|
|
|
# At least one request should include the Authorization header
|
|
auth_headers = [h.get("Authorization", "") for h in captured_headers]
|
|
assert any("xoxb-my-bot-token" in h for h in auth_headers), (
|
|
f"Expected bot token in headers, got: {auth_headers}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Channel-aware outbound routing helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestChannelAwareOutbound:
|
|
"""Tests for the send_response helper that routes to Slack or WhatsApp."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slack_channel_calls_chat_update(self):
|
|
"""Slack channel routes to _update_slack_placeholder."""
|
|
from orchestrator.tasks import _send_response
|
|
|
|
with patch("orchestrator.tasks._update_slack_placeholder") as mock_update:
|
|
mock_update.return_value = None
|
|
await _send_response(
|
|
channel="slack",
|
|
text="Hello from agent",
|
|
extras={
|
|
"bot_token": "xoxb-test",
|
|
"channel_id": "C-123",
|
|
"placeholder_ts": "1711234567.000001",
|
|
},
|
|
)
|
|
mock_update.assert_called_once_with(
|
|
bot_token="xoxb-test",
|
|
channel_id="C-123",
|
|
placeholder_ts="1711234567.000001",
|
|
text="Hello from agent",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_whatsapp_channel_calls_send_whatsapp_message(self):
|
|
"""WhatsApp channel routes to send_whatsapp_message."""
|
|
from orchestrator.tasks import _send_response
|
|
|
|
with patch("orchestrator.tasks.send_whatsapp_message") as mock_wa:
|
|
mock_wa.return_value = None
|
|
await _send_response(
|
|
channel="whatsapp",
|
|
text="Reply via WhatsApp",
|
|
extras={
|
|
"phone_number_id": "12345678901",
|
|
"bot_token": "EAAx...",
|
|
"wa_id": "5511987654321",
|
|
},
|
|
)
|
|
mock_wa.assert_called_once_with(
|
|
phone_number_id="12345678901",
|
|
access_token="EAAx...",
|
|
recipient_wa_id="5511987654321",
|
|
text="Reply via WhatsApp",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_channel_logs_warning_no_exception(self):
|
|
"""Unknown channel type logs a warning but does not raise."""
|
|
from orchestrator.tasks import _send_response
|
|
|
|
# Should not raise, just log and return
|
|
await _send_response(
|
|
channel="mattermost",
|
|
text="Reply via Mattermost",
|
|
extras={},
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slack_channel_missing_extras_does_not_raise(self):
|
|
"""Slack routing with empty extras logs warning but does not crash."""
|
|
from orchestrator.tasks import _send_response
|
|
|
|
# Should fall back gracefully when extras are missing
|
|
await _send_response(
|
|
channel="slack",
|
|
text="Hello",
|
|
extras={},
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_whatsapp_channel_missing_extras_does_not_raise(self):
|
|
"""WhatsApp routing with empty extras logs warning but does not crash."""
|
|
from orchestrator.tasks import _send_response
|
|
|
|
# Should fall back gracefully when wa_id is missing
|
|
await _send_response(
|
|
channel="whatsapp",
|
|
text="Hello",
|
|
extras={},
|
|
)
|