"""Tests for Mattermost platform adapter.""" import json import os import time import pytest from unittest.mock import MagicMock, patch, AsyncMock from gateway.config import Platform, PlatformConfig # --------------------------------------------------------------------------- # Platform & Config # --------------------------------------------------------------------------- class TestMattermostPlatformEnum: def test_mattermost_enum_exists(self): assert Platform.MATTERMOST.value == "mattermost" def test_mattermost_in_platform_list(self): platforms = [p.value for p in Platform] assert "mattermost" in platforms class TestMattermostConfigLoading: def test_apply_env_overrides_mattermost(self, monkeypatch): monkeypatch.setenv("MATTERMOST_TOKEN", "mm-tok-abc123") monkeypatch.setenv("MATTERMOST_URL", "https://mm.example.com") from gateway.config import GatewayConfig, _apply_env_overrides config = GatewayConfig() _apply_env_overrides(config) assert Platform.MATTERMOST in config.platforms mc = config.platforms[Platform.MATTERMOST] assert mc.enabled is True assert mc.token == "mm-tok-abc123" assert mc.extra.get("url") == "https://mm.example.com" def test_mattermost_not_loaded_without_token(self, monkeypatch): monkeypatch.delenv("MATTERMOST_TOKEN", raising=False) monkeypatch.delenv("MATTERMOST_URL", raising=False) from gateway.config import GatewayConfig, _apply_env_overrides config = GatewayConfig() _apply_env_overrides(config) assert Platform.MATTERMOST not in config.platforms def test_connected_platforms_includes_mattermost(self, monkeypatch): monkeypatch.setenv("MATTERMOST_TOKEN", "mm-tok-abc123") monkeypatch.setenv("MATTERMOST_URL", "https://mm.example.com") from gateway.config import GatewayConfig, _apply_env_overrides config = GatewayConfig() _apply_env_overrides(config) connected = config.get_connected_platforms() assert Platform.MATTERMOST in connected def test_mattermost_home_channel(self, monkeypatch): monkeypatch.setenv("MATTERMOST_TOKEN", "mm-tok-abc123") monkeypatch.setenv("MATTERMOST_URL", "https://mm.example.com") monkeypatch.setenv("MATTERMOST_HOME_CHANNEL", "ch_abc123") monkeypatch.setenv("MATTERMOST_HOME_CHANNEL_NAME", "General") from gateway.config import GatewayConfig, _apply_env_overrides config = GatewayConfig() _apply_env_overrides(config) home = config.get_home_channel(Platform.MATTERMOST) assert home is not None assert home.chat_id == "ch_abc123" assert home.name == "General" def test_mattermost_url_warning_without_url(self, monkeypatch): """MATTERMOST_TOKEN set but MATTERMOST_URL missing should still load.""" monkeypatch.setenv("MATTERMOST_TOKEN", "mm-tok-abc123") monkeypatch.delenv("MATTERMOST_URL", raising=False) from gateway.config import GatewayConfig, _apply_env_overrides config = GatewayConfig() _apply_env_overrides(config) assert Platform.MATTERMOST in config.platforms assert config.platforms[Platform.MATTERMOST].extra.get("url") == "" # --------------------------------------------------------------------------- # Adapter format / truncate # --------------------------------------------------------------------------- def _make_adapter(): """Create a MattermostAdapter with mocked config.""" from gateway.platforms.mattermost import MattermostAdapter config = PlatformConfig( enabled=True, token="test-token", extra={"url": "https://mm.example.com"}, ) adapter = MattermostAdapter(config) return adapter class TestMattermostFormatMessage: def setup_method(self): self.adapter = _make_adapter() def test_image_markdown_to_url(self): """![alt](url) should be converted to just the URL.""" result = self.adapter.format_message("![cat](https://img.example.com/cat.png)") assert result == "https://img.example.com/cat.png" def test_image_markdown_strips_alt_text(self): result = self.adapter.format_message("Here: ![my image](https://x.com/a.jpg) done") assert "![" not in result assert "https://x.com/a.jpg" in result def test_regular_markdown_preserved(self): """Regular markdown (bold, italic, code) should be kept as-is.""" content = "**bold** and *italic* and `code`" assert self.adapter.format_message(content) == content def test_regular_links_preserved(self): """Non-image links should be preserved.""" content = "[click](https://example.com)" assert self.adapter.format_message(content) == content def test_plain_text_unchanged(self): content = "Hello, world!" assert self.adapter.format_message(content) == content def test_multiple_images(self): content = "![a](http://a.com/1.png) text ![b](http://b.com/2.png)" result = self.adapter.format_message(content) assert "![" not in result assert "http://a.com/1.png" in result assert "http://b.com/2.png" in result class TestMattermostTruncateMessage: def setup_method(self): self.adapter = _make_adapter() def test_short_message_single_chunk(self): msg = "Hello, world!" chunks = self.adapter.truncate_message(msg, 4000) assert len(chunks) == 1 assert chunks[0] == msg def test_long_message_splits(self): msg = "a " * 2500 # 5000 chars chunks = self.adapter.truncate_message(msg, 4000) assert len(chunks) >= 2 for chunk in chunks: assert len(chunk) <= 4000 def test_custom_max_length(self): msg = "Hello " * 20 chunks = self.adapter.truncate_message(msg, max_length=50) assert all(len(c) <= 50 for c in chunks) def test_exactly_at_limit(self): msg = "x" * 4000 chunks = self.adapter.truncate_message(msg, 4000) assert len(chunks) == 1 # --------------------------------------------------------------------------- # Send # --------------------------------------------------------------------------- class TestMattermostSend: def setup_method(self): self.adapter = _make_adapter() self.adapter._session = MagicMock() @pytest.mark.asyncio async def test_send_calls_api_post(self): """send() should POST to /api/v4/posts with channel_id and message.""" mock_resp = AsyncMock() mock_resp.status = 200 mock_resp.json = AsyncMock(return_value={"id": "post123"}) mock_resp.text = AsyncMock(return_value="") mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) mock_resp.__aexit__ = AsyncMock(return_value=False) self.adapter._session.post = MagicMock(return_value=mock_resp) result = await self.adapter.send("channel_1", "Hello!") assert result.success is True assert result.message_id == "post123" # Verify post was called with correct URL call_args = self.adapter._session.post.call_args assert "/api/v4/posts" in call_args[0][0] # Verify payload payload = call_args[1]["json"] assert payload["channel_id"] == "channel_1" assert payload["message"] == "Hello!" @pytest.mark.asyncio async def test_send_empty_content_succeeds(self): """Empty content should return success without calling the API.""" result = await self.adapter.send("channel_1", "") assert result.success is True @pytest.mark.asyncio async def test_send_with_thread_reply(self): """When reply_mode is 'thread', reply_to should become root_id.""" self.adapter._reply_mode = "thread" mock_resp = AsyncMock() mock_resp.status = 200 mock_resp.json = AsyncMock(return_value={"id": "post456"}) mock_resp.text = AsyncMock(return_value="") mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) mock_resp.__aexit__ = AsyncMock(return_value=False) self.adapter._session.post = MagicMock(return_value=mock_resp) result = await self.adapter.send("channel_1", "Reply!", reply_to="root_post") assert result.success is True payload = self.adapter._session.post.call_args[1]["json"] assert payload["root_id"] == "root_post" @pytest.mark.asyncio async def test_send_without_thread_no_root_id(self): """When reply_mode is 'off', reply_to should NOT set root_id.""" self.adapter._reply_mode = "off" mock_resp = AsyncMock() mock_resp.status = 200 mock_resp.json = AsyncMock(return_value={"id": "post789"}) mock_resp.text = AsyncMock(return_value="") mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) mock_resp.__aexit__ = AsyncMock(return_value=False) self.adapter._session.post = MagicMock(return_value=mock_resp) result = await self.adapter.send("channel_1", "Reply!", reply_to="root_post") assert result.success is True payload = self.adapter._session.post.call_args[1]["json"] assert "root_id" not in payload @pytest.mark.asyncio async def test_send_api_failure(self): """When API returns error, send should return failure.""" mock_resp = AsyncMock() mock_resp.status = 500 mock_resp.json = AsyncMock(return_value={}) mock_resp.text = AsyncMock(return_value="Internal Server Error") mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) mock_resp.__aexit__ = AsyncMock(return_value=False) self.adapter._session.post = MagicMock(return_value=mock_resp) result = await self.adapter.send("channel_1", "Hello!") assert result.success is False # --------------------------------------------------------------------------- # WebSocket event parsing # --------------------------------------------------------------------------- class TestMattermostWebSocketParsing: def setup_method(self): self.adapter = _make_adapter() self.adapter._bot_user_id = "bot_user_id" self.adapter._bot_username = "hermes-bot" # Mock handle_message to capture the MessageEvent without processing self.adapter.handle_message = AsyncMock() @pytest.mark.asyncio async def test_parse_posted_event(self): """'posted' events should extract message from double-encoded post JSON.""" post_data = { "id": "post_abc", "user_id": "user_123", "channel_id": "chan_456", "message": "@bot_user_id Hello from Matrix!", } event = { "event": "posted", "data": { "post": json.dumps(post_data), # double-encoded JSON string "channel_type": "O", "sender_name": "@alice", }, } await self.adapter._handle_ws_event(event) assert self.adapter.handle_message.called msg_event = self.adapter.handle_message.call_args[0][0] # @mention is stripped from the message text assert msg_event.text == "Hello from Matrix!" assert msg_event.message_id == "post_abc" @pytest.mark.asyncio async def test_ignore_own_messages(self): """Messages from the bot's own user_id should be ignored.""" post_data = { "id": "post_self", "user_id": "bot_user_id", # same as bot "channel_id": "chan_456", "message": "Bot echo", } event = { "event": "posted", "data": { "post": json.dumps(post_data), "channel_type": "O", }, } await self.adapter._handle_ws_event(event) assert not self.adapter.handle_message.called @pytest.mark.asyncio async def test_ignore_non_posted_events(self): """Non-'posted' events should be ignored.""" event = { "event": "typing", "data": {"user_id": "user_123"}, } await self.adapter._handle_ws_event(event) assert not self.adapter.handle_message.called @pytest.mark.asyncio async def test_ignore_system_posts(self): """Posts with a 'type' field (system messages) should be ignored.""" post_data = { "id": "sys_post", "user_id": "user_123", "channel_id": "chan_456", "message": "user joined", "type": "system_join_channel", } event = { "event": "posted", "data": { "post": json.dumps(post_data), "channel_type": "O", }, } await self.adapter._handle_ws_event(event) assert not self.adapter.handle_message.called @pytest.mark.asyncio async def test_channel_type_mapping(self): """channel_type 'D' should map to 'dm'.""" post_data = { "id": "post_dm", "user_id": "user_123", "channel_id": "chan_dm", "message": "DM message", } event = { "event": "posted", "data": { "post": json.dumps(post_data), "channel_type": "D", "sender_name": "@bob", }, } await self.adapter._handle_ws_event(event) assert self.adapter.handle_message.called msg_event = self.adapter.handle_message.call_args[0][0] assert msg_event.source.chat_type == "dm" @pytest.mark.asyncio async def test_thread_id_from_root_id(self): """Post with root_id should have thread_id set.""" post_data = { "id": "post_reply", "user_id": "user_123", "channel_id": "chan_456", "message": "@bot_user_id Thread reply", "root_id": "root_post_123", } event = { "event": "posted", "data": { "post": json.dumps(post_data), "channel_type": "O", "sender_name": "@alice", }, } await self.adapter._handle_ws_event(event) assert self.adapter.handle_message.called msg_event = self.adapter.handle_message.call_args[0][0] assert msg_event.source.thread_id == "root_post_123" @pytest.mark.asyncio async def test_invalid_post_json_ignored(self): """Invalid JSON in data.post should be silently ignored.""" event = { "event": "posted", "data": { "post": "not-valid-json{{{", "channel_type": "O", }, } await self.adapter._handle_ws_event(event) assert not self.adapter.handle_message.called # --------------------------------------------------------------------------- # Mention behavior (require_mention + free_response_channels) # --------------------------------------------------------------------------- class TestMattermostMentionBehavior: def setup_method(self): self.adapter = _make_adapter() self.adapter._bot_user_id = "bot_user_id" self.adapter._bot_username = "hermes-bot" self.adapter.handle_message = AsyncMock() def _make_event(self, message, channel_type="O", channel_id="chan_456"): post_data = { "id": "post_mention", "user_id": "user_123", "channel_id": channel_id, "message": message, } return { "event": "posted", "data": { "post": json.dumps(post_data), "channel_type": channel_type, "sender_name": "@alice", }, } @pytest.mark.asyncio async def test_require_mention_true_skips_without_mention(self): """Default: messages without @mention in channels are skipped.""" with patch.dict(os.environ, {}, clear=False): os.environ.pop("MATTERMOST_REQUIRE_MENTION", None) os.environ.pop("MATTERMOST_FREE_RESPONSE_CHANNELS", None) await self.adapter._handle_ws_event(self._make_event("hello")) assert not self.adapter.handle_message.called @pytest.mark.asyncio async def test_require_mention_false_responds_to_all(self): """MATTERMOST_REQUIRE_MENTION=false: respond to all channel messages.""" with patch.dict(os.environ, {"MATTERMOST_REQUIRE_MENTION": "false"}): await self.adapter._handle_ws_event(self._make_event("hello")) assert self.adapter.handle_message.called @pytest.mark.asyncio async def test_free_response_channel_responds_without_mention(self): """Messages in free-response channels don't need @mention.""" with patch.dict(os.environ, {"MATTERMOST_FREE_RESPONSE_CHANNELS": "chan_456,chan_789"}): os.environ.pop("MATTERMOST_REQUIRE_MENTION", None) await self.adapter._handle_ws_event(self._make_event("hello", channel_id="chan_456")) assert self.adapter.handle_message.called @pytest.mark.asyncio async def test_non_free_channel_still_requires_mention(self): """Channels NOT in free-response list still require @mention.""" with patch.dict(os.environ, {"MATTERMOST_FREE_RESPONSE_CHANNELS": "chan_789"}): os.environ.pop("MATTERMOST_REQUIRE_MENTION", None) await self.adapter._handle_ws_event(self._make_event("hello", channel_id="chan_456")) assert not self.adapter.handle_message.called @pytest.mark.asyncio async def test_dm_always_responds(self): """DMs (channel_type=D) always respond regardless of mention settings.""" with patch.dict(os.environ, {}, clear=False): os.environ.pop("MATTERMOST_REQUIRE_MENTION", None) await self.adapter._handle_ws_event(self._make_event("hello", channel_type="D")) assert self.adapter.handle_message.called @pytest.mark.asyncio async def test_mention_stripped_from_text(self): """@mention is stripped from message text.""" with patch.dict(os.environ, {}, clear=False): os.environ.pop("MATTERMOST_REQUIRE_MENTION", None) await self.adapter._handle_ws_event( self._make_event("@hermes-bot what is 2+2") ) assert self.adapter.handle_message.called msg = self.adapter.handle_message.call_args[0][0] assert "@hermes-bot" not in msg.text assert "2+2" in msg.text # --------------------------------------------------------------------------- # File upload (send_image) # --------------------------------------------------------------------------- class TestMattermostFileUpload: def setup_method(self): self.adapter = _make_adapter() self.adapter._session = MagicMock() @pytest.mark.asyncio async def test_send_image_downloads_and_uploads(self): """send_image should download the URL, upload via /api/v4/files, then post.""" # Mock the download (GET) mock_dl_resp = AsyncMock() mock_dl_resp.status = 200 mock_dl_resp.read = AsyncMock(return_value=b"\x89PNG\x00fake-image-data") mock_dl_resp.content_type = "image/png" mock_dl_resp.__aenter__ = AsyncMock(return_value=mock_dl_resp) mock_dl_resp.__aexit__ = AsyncMock(return_value=False) # Mock the upload (POST to /files) mock_upload_resp = AsyncMock() mock_upload_resp.status = 200 mock_upload_resp.json = AsyncMock(return_value={ "file_infos": [{"id": "file_abc123"}] }) mock_upload_resp.text = AsyncMock(return_value="") mock_upload_resp.__aenter__ = AsyncMock(return_value=mock_upload_resp) mock_upload_resp.__aexit__ = AsyncMock(return_value=False) # Mock the post (POST to /posts) mock_post_resp = AsyncMock() mock_post_resp.status = 200 mock_post_resp.json = AsyncMock(return_value={"id": "post_with_file"}) mock_post_resp.text = AsyncMock(return_value="") mock_post_resp.__aenter__ = AsyncMock(return_value=mock_post_resp) mock_post_resp.__aexit__ = AsyncMock(return_value=False) # Route calls: first GET (download), then POST (upload), then POST (create post) self.adapter._session.get = MagicMock(return_value=mock_dl_resp) post_call_count = 0 original_post_returns = [mock_upload_resp, mock_post_resp] def post_side_effect(*args, **kwargs): nonlocal post_call_count resp = original_post_returns[min(post_call_count, len(original_post_returns) - 1)] post_call_count += 1 return resp self.adapter._session.post = MagicMock(side_effect=post_side_effect) result = await self.adapter.send_image( "channel_1", "https://img.example.com/cat.png", caption="A cat" ) assert result.success is True assert result.message_id == "post_with_file" # --------------------------------------------------------------------------- # Dedup cache # --------------------------------------------------------------------------- class TestMattermostDedup: def setup_method(self): self.adapter = _make_adapter() self.adapter._bot_user_id = "bot_user_id" # Mock handle_message to capture calls without processing self.adapter.handle_message = AsyncMock() @pytest.mark.asyncio async def test_duplicate_post_ignored(self): """The same post_id within the TTL window should be ignored.""" post_data = { "id": "post_dup", "user_id": "user_123", "channel_id": "chan_456", "message": "@bot_user_id Hello!", } event = { "event": "posted", "data": { "post": json.dumps(post_data), "channel_type": "O", "sender_name": "@alice", }, } # First time: should process await self.adapter._handle_ws_event(event) assert self.adapter.handle_message.call_count == 1 # Second time (same post_id): should be deduped await self.adapter._handle_ws_event(event) assert self.adapter.handle_message.call_count == 1 # still 1 @pytest.mark.asyncio async def test_different_post_ids_both_processed(self): """Different post IDs should both be processed.""" for i, pid in enumerate(["post_a", "post_b"]): post_data = { "id": pid, "user_id": "user_123", "channel_id": "chan_456", "message": f"@bot_user_id Message {i}", } event = { "event": "posted", "data": { "post": json.dumps(post_data), "channel_type": "O", "sender_name": "@alice", }, } await self.adapter._handle_ws_event(event) assert self.adapter.handle_message.call_count == 2 def test_prune_seen_clears_expired(self): """_prune_seen should remove entries older than _SEEN_TTL.""" now = time.time() # Fill with enough expired entries to trigger pruning for i in range(self.adapter._SEEN_MAX + 10): self.adapter._seen_posts[f"old_{i}"] = now - 600 # 10 min ago # Add a fresh one self.adapter._seen_posts["fresh"] = now self.adapter._prune_seen() # Old entries should be pruned, fresh one kept assert "fresh" in self.adapter._seen_posts assert len(self.adapter._seen_posts) < self.adapter._SEEN_MAX def test_seen_cache_tracks_post_ids(self): """Posts are tracked in _seen_posts dict.""" self.adapter._seen_posts["test_post"] = time.time() assert "test_post" in self.adapter._seen_posts # --------------------------------------------------------------------------- # Requirements check # --------------------------------------------------------------------------- class TestMattermostRequirements: def test_check_requirements_with_token_and_url(self, monkeypatch): monkeypatch.setenv("MATTERMOST_TOKEN", "test-token") monkeypatch.setenv("MATTERMOST_URL", "https://mm.example.com") from gateway.platforms.mattermost import check_mattermost_requirements assert check_mattermost_requirements() is True def test_check_requirements_without_token(self, monkeypatch): monkeypatch.delenv("MATTERMOST_TOKEN", raising=False) monkeypatch.delenv("MATTERMOST_URL", raising=False) from gateway.platforms.mattermost import check_mattermost_requirements assert check_mattermost_requirements() is False def test_check_requirements_without_url(self, monkeypatch): monkeypatch.setenv("MATTERMOST_TOKEN", "test-token") monkeypatch.delenv("MATTERMOST_URL", raising=False) from gateway.platforms.mattermost import check_mattermost_requirements assert check_mattermost_requirements() is False # --------------------------------------------------------------------------- # Media type propagation (MIME types, not bare strings) # --------------------------------------------------------------------------- class TestMattermostMediaTypes: """Verify that media_types contains actual MIME types (e.g. 'image/png') rather than bare category strings ('image'), so downstream ``mtype.startswith("image/")`` checks in run.py work correctly.""" def setup_method(self): self.adapter = _make_adapter() self.adapter._bot_user_id = "bot_user_id" self.adapter.handle_message = AsyncMock() def _make_event(self, file_ids): post_data = { "id": "post_media", "user_id": "user_123", "channel_id": "chan_456", "message": "@bot_user_id file attached", "file_ids": file_ids, } return { "event": "posted", "data": { "post": json.dumps(post_data), "channel_type": "O", "sender_name": "@alice", }, } @pytest.mark.asyncio async def test_image_media_type_is_full_mime(self): """An image attachment should produce 'image/png', not 'image'.""" file_info = {"name": "photo.png", "mime_type": "image/png"} self.adapter._api_get = AsyncMock(return_value=file_info) mock_resp = AsyncMock() mock_resp.status = 200 mock_resp.read = AsyncMock(return_value=b"\x89PNG fake") mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) mock_resp.__aexit__ = AsyncMock(return_value=False) self.adapter._session = MagicMock() self.adapter._session.get = MagicMock(return_value=mock_resp) with patch("gateway.platforms.base.cache_image_from_bytes", return_value="/tmp/photo.png"): await self.adapter._handle_ws_event(self._make_event(["file1"])) msg = self.adapter.handle_message.call_args[0][0] assert msg.media_types == ["image/png"] assert msg.media_types[0].startswith("image/") @pytest.mark.asyncio async def test_audio_media_type_is_full_mime(self): """An audio attachment should produce 'audio/ogg', not 'audio'.""" file_info = {"name": "voice.ogg", "mime_type": "audio/ogg"} self.adapter._api_get = AsyncMock(return_value=file_info) mock_resp = AsyncMock() mock_resp.status = 200 mock_resp.read = AsyncMock(return_value=b"OGG fake") mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) mock_resp.__aexit__ = AsyncMock(return_value=False) self.adapter._session = MagicMock() self.adapter._session.get = MagicMock(return_value=mock_resp) with patch("gateway.platforms.base.cache_audio_from_bytes", return_value="/tmp/voice.ogg"), \ patch("gateway.platforms.base.cache_image_from_bytes"), \ patch("gateway.platforms.base.cache_document_from_bytes"): await self.adapter._handle_ws_event(self._make_event(["file2"])) msg = self.adapter.handle_message.call_args[0][0] assert msg.media_types == ["audio/ogg"] assert msg.media_types[0].startswith("audio/") @pytest.mark.asyncio async def test_document_media_type_is_full_mime(self): """A document attachment should produce 'application/pdf', not 'document'.""" file_info = {"name": "report.pdf", "mime_type": "application/pdf"} self.adapter._api_get = AsyncMock(return_value=file_info) mock_resp = AsyncMock() mock_resp.status = 200 mock_resp.read = AsyncMock(return_value=b"PDF fake") mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) mock_resp.__aexit__ = AsyncMock(return_value=False) self.adapter._session = MagicMock() self.adapter._session.get = MagicMock(return_value=mock_resp) with patch("gateway.platforms.base.cache_document_from_bytes", return_value="/tmp/report.pdf"), \ patch("gateway.platforms.base.cache_image_from_bytes"): await self.adapter._handle_ws_event(self._make_event(["file3"])) msg = self.adapter.handle_message.call_args[0][0] assert msg.media_types == ["application/pdf"] assert not msg.media_types[0].startswith("image/") assert not msg.media_types[0].startswith("audio/")