Files
konstruct/tests/unit/test_slack_media.py
Adolfo Delorenzo 9dd7c481a3 feat(02-05): Slack file_share extraction and channel-aware outbound routing
- 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)
2026-03-23 15:06:45 -06:00

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={},
)