Adds MATTERMOST_REQUIRE_MENTION and MATTERMOST_FREE_RESPONSE_CHANNELS env vars, matching Discord's existing mention gating pattern. - MATTERMOST_REQUIRE_MENTION=false: respond to all channel messages - MATTERMOST_FREE_RESPONSE_CHANNELS=id1,id2: specific channels where bot responds without @mention even when require_mention is true - DMs always respond regardless of mention settings - @mention is now stripped from message text (clean agent input) 7 new tests for mention gating, free-response channels, DM bypass, and mention stripping. Updated existing test for mention stripping. Docs: updated mattermost.md with Mention Behavior section, environment-variables.md with new vars, config.py with metadata.
758 lines
29 KiB
Python
758 lines
29 KiB
Python
"""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):
|
|
""" should be converted to just the URL."""
|
|
result = self.adapter.format_message("")
|
|
assert result == "https://img.example.com/cat.png"
|
|
|
|
def test_image_markdown_strips_alt_text(self):
|
|
result = self.adapter.format_message("Here:  done")
|
|
assert ""
|
|
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 = " text "
|
|
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/")
|