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