feat: add Mattermost channel support
This commit is contained in:
@@ -0,0 +1,757 @@
|
||||
"""Tests for the Mattermost channel implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.mattermost import (
|
||||
MATTERMOST_MAX_MESSAGE_LEN,
|
||||
MattermostChannel,
|
||||
MattermostConfig,
|
||||
)
|
||||
|
||||
|
||||
class _FakeHTTPClient:
|
||||
"""Mock httpx.AsyncClient that records calls and returns canned responses."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.get_calls: list[dict[str, Any]] = []
|
||||
self.post_calls: list[dict[str, Any]] = []
|
||||
self.put_calls: list[dict[str, Any]] = []
|
||||
self.delete_calls: list[dict[str, Any]] = []
|
||||
self._get_responses: dict[str, Any] = {}
|
||||
self._post_responses: dict[str, Any] = {}
|
||||
self._put_responses: dict[str, Any] = {}
|
||||
self._delete_status: int | None = None
|
||||
|
||||
def _req(self, method: str, path: str) -> httpx.Request:
|
||||
return httpx.Request(method, f"https://chat.example.com{path}")
|
||||
|
||||
def _resp(self, status: int, json_data: Any, method: str = "GET", path: str = "/") -> httpx.Response:
|
||||
return httpx.Response(status, json=json_data, request=self._req(method, path))
|
||||
|
||||
def set_get_response(self, path: str, data: Any) -> None:
|
||||
self._get_responses[path] = data
|
||||
|
||||
def set_post_response(self, path: str, data: Any) -> None:
|
||||
self._post_responses[path] = data
|
||||
|
||||
def set_put_response(self, path: str, data: Any) -> None:
|
||||
self._put_responses[path] = data
|
||||
|
||||
def set_delete_status(self, status: int) -> None:
|
||||
self._delete_status = status
|
||||
|
||||
async def get(self, path: str, **kwargs) -> httpx.Response:
|
||||
self.get_calls.append({"path": path, **kwargs})
|
||||
data = self._get_responses.get(path, {"id": "resp_" + path.split("/")[-1]})
|
||||
return self._resp(200, data, "GET", path)
|
||||
|
||||
async def post(self, path: str, *, json: dict[str, Any] | None = None, data: Any = None, files: Any = None, **kwargs) -> httpx.Response:
|
||||
call: dict[str, Any] = {"path": path}
|
||||
if json is not None:
|
||||
call["json"] = json
|
||||
if data is not None:
|
||||
call["data"] = data
|
||||
if files is not None:
|
||||
call["files"] = files
|
||||
self.post_calls.append(call)
|
||||
data = self._post_responses.get(path, {"id": "new_id"})
|
||||
return self._resp(201, data, "POST", path)
|
||||
|
||||
async def put(self, path: str, *, json: dict[str, Any] | None = None, **kwargs) -> httpx.Response:
|
||||
self.put_calls.append({"path": path, "json": json})
|
||||
data = self._put_responses.get(path, {"id": path.split("/")[-1]})
|
||||
return self._resp(200, data, "PUT", path)
|
||||
|
||||
async def delete(self, path: str, **kwargs) -> httpx.Response:
|
||||
self.delete_calls.append({"path": path})
|
||||
status = self._delete_status if self._delete_status is not None else 200
|
||||
return self._resp(status, {}, "DELETE", path)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _make_channel(
|
||||
overrides: dict[str, Any] | None = None,
|
||||
bus: MessageBus | None = None,
|
||||
) -> tuple[MattermostChannel, _FakeHTTPClient]:
|
||||
config_dict: dict[str, Any] = {
|
||||
"enabled": True,
|
||||
"serverUrl": "https://chat.example.com",
|
||||
"token": "test_token",
|
||||
**(overrides or {}),
|
||||
}
|
||||
config = MattermostConfig.model_validate(config_dict)
|
||||
if bus is None:
|
||||
bus = MessageBus()
|
||||
channel = MattermostChannel(config, bus)
|
||||
fake = _FakeHTTPClient()
|
||||
fake.set_get_response("/api/v4/users/me", {
|
||||
"id": "botuserid123",
|
||||
"username": "nanobot",
|
||||
"email": "bot@example.com",
|
||||
})
|
||||
fake.set_post_response("/api/v4/posts", {"id": "post_new_id"})
|
||||
channel._http_client = fake
|
||||
return channel, fake
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_config_defaults():
|
||||
config = MattermostConfig()
|
||||
assert config.enabled is False
|
||||
assert config.server_url == ""
|
||||
assert config.token == ""
|
||||
assert config.streaming is True
|
||||
assert config.streaming_max_chars == 16000
|
||||
assert config.dm.enabled is True
|
||||
assert config.dm.policy == "open"
|
||||
assert config.reply_in_thread is True
|
||||
|
||||
|
||||
def test_config_camelcase_aliases():
|
||||
raw = {
|
||||
"serverUrl": "https://mm.example.com",
|
||||
"token": "abc123",
|
||||
"allowFromMatchMode": "username",
|
||||
"streamingMaxChars": 8000,
|
||||
"replyInThread": False,
|
||||
}
|
||||
config = MattermostConfig.model_validate(raw)
|
||||
assert config.server_url == "https://mm.example.com"
|
||||
assert config.token == "abc123"
|
||||
assert config.allow_from_match_mode == "username"
|
||||
assert config.streaming_max_chars == 8000
|
||||
assert config.reply_in_thread is False
|
||||
|
||||
|
||||
def test_config_default_config_classmethod():
|
||||
d = MattermostChannel.default_config()
|
||||
assert d["enabled"] is False
|
||||
assert d["serverUrl"] == ""
|
||||
assert d["token"] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Self-identification on start
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_identifies_bot():
|
||||
channel, fake = _make_channel({"serverUrl": "https://chat.example.com", "token": "tok"})
|
||||
calls_before = len(fake.get_calls)
|
||||
with patch("websockets.connect", AsyncMock(side_effect=Exception("no-op"))):
|
||||
await channel.start()
|
||||
assert channel._self_id == "botuserid123"
|
||||
assert channel._self_username == "nanobot"
|
||||
assert channel._self_email == "bot@example.com"
|
||||
user_me_calls = [c for c in fake.get_calls[calls_before:] if "/api/v4/users/me" in c["path"]]
|
||||
assert len(user_me_calls) == 1
|
||||
await channel.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_missing_config():
|
||||
channel, fake = _make_channel({"serverUrl": "", "token": ""})
|
||||
await channel.start()
|
||||
assert channel._self_id is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server URL normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_server_url_normalization():
|
||||
config = MattermostConfig.model_validate({
|
||||
"serverUrl": "https://chat.example.com/",
|
||||
"token": "tok",
|
||||
})
|
||||
channel = MattermostChannel(config, MessageBus())
|
||||
assert channel._server_url == "https://chat.example.com"
|
||||
assert "/api/v4/websocket" in channel._ws_url
|
||||
assert channel._ws_url.startswith("wss://")
|
||||
|
||||
|
||||
def test_server_url_no_trailing_slash():
|
||||
config = MattermostConfig.model_validate({
|
||||
"serverUrl": "https://chat.example.com",
|
||||
"token": "tok",
|
||||
})
|
||||
channel = MattermostChannel(config, MessageBus())
|
||||
assert channel._server_url == "https://chat.example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inbound routing: posted event
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_posted_event_routes_to_handle_message():
|
||||
channel, fake = _make_channel()
|
||||
channel._self_id = "botuserid123"
|
||||
channel._self_username = "nanobot"
|
||||
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
|
||||
ws_msg = {
|
||||
"event": "posted",
|
||||
"data": {
|
||||
"channel_type": "D",
|
||||
"post": json.dumps({
|
||||
"id": "post_abc",
|
||||
"user_id": "user_42",
|
||||
"channel_id": "chan_1",
|
||||
"message": "hello",
|
||||
"root_id": "",
|
||||
}),
|
||||
},
|
||||
"broadcast": {"channel_id": "chan_1", "team_id": ""},
|
||||
}
|
||||
await channel._handle_ws_message(ws_msg)
|
||||
mock_handle.assert_awaited_once()
|
||||
args, kwargs = mock_handle.call_args
|
||||
assert kwargs["sender_id"] == "user_42"
|
||||
assert kwargs["chat_id"] == "chan_1"
|
||||
assert kwargs["content"] == "hello"
|
||||
assert kwargs["is_dm"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_posted_event_self_message_ignored():
|
||||
channel, fake = _make_channel()
|
||||
channel._self_id = "bot_id"
|
||||
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
|
||||
ws_msg = {
|
||||
"event": "posted",
|
||||
"data": {
|
||||
"channel_type": "D",
|
||||
"post": json.dumps({
|
||||
"id": "p1", "user_id": "bot_id",
|
||||
"channel_id": "c1", "message": "ignore me", "root_id": "",
|
||||
}),
|
||||
},
|
||||
"broadcast": {},
|
||||
}
|
||||
await channel._handle_ws_message(ws_msg)
|
||||
mock_handle.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_posted_event_channel_type_detection():
|
||||
channel, fake = _make_channel()
|
||||
channel._self_id = "bot_id"
|
||||
|
||||
for code, expected_dm in [("D", True), ("O", False), ("P", False), ("G", False)]:
|
||||
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
|
||||
with patch.object(channel, "_should_respond_in_channel", return_value=True):
|
||||
with patch.object(channel, "_is_allowed", AsyncMock(return_value=True)):
|
||||
ws_msg = {
|
||||
"event": "posted",
|
||||
"data": {
|
||||
"channel_type": code,
|
||||
"post": json.dumps({
|
||||
"id": "p1", "user_id": "u1",
|
||||
"channel_id": "c1", "message": "hi", "root_id": "",
|
||||
}),
|
||||
},
|
||||
"broadcast": {},
|
||||
}
|
||||
await channel._handle_ws_message(ws_msg)
|
||||
mock_handle.assert_called_once()
|
||||
assert mock_handle.call_args[1]["is_dm"] == expected_dm
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bot @mention stripping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strip_bot_mention_from_incoming():
|
||||
channel, fake = _make_channel()
|
||||
channel._self_id = "bot_id"
|
||||
channel._self_username = "nanobot"
|
||||
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
|
||||
with patch.object(channel, "_is_allowed", AsyncMock(return_value=True)):
|
||||
with patch.object(channel, "_should_respond_in_channel", return_value=True):
|
||||
ws_msg = {
|
||||
"event": "posted",
|
||||
"data": {
|
||||
"channel_type": "O",
|
||||
"post": json.dumps({
|
||||
"id": "p1", "user_id": "u1",
|
||||
"channel_id": "c1", "message": "@nanobot hello there", "root_id": "",
|
||||
}),
|
||||
},
|
||||
"broadcast": {},
|
||||
}
|
||||
await channel._handle_ws_message(ws_msg)
|
||||
assert mock_handle.call_args[1]["content"] == "hello there"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DM policy: open / allowlist
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_policy_open():
|
||||
channel, fake = _make_channel({"dm": {"policy": "open"}})
|
||||
result = await channel._is_allowed("any_user", "dm_chan", "dm")
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_policy_allowlist_match():
|
||||
channel, fake = _make_channel({"dm": {"policy": "allowlist", "allowFrom": ["user_1", "user_2"]}})
|
||||
assert await channel._is_allowed("user_1", "dm_chan", "dm") is True
|
||||
assert await channel._is_allowed("user_3", "dm_chan", "dm") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_disabled():
|
||||
channel, fake = _make_channel({"dm": {"enabled": False}})
|
||||
assert await channel._is_allowed("u1", "dm_chan", "dm") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Group policy: mention / open / allowlist
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_policy_mention():
|
||||
channel, fake = _make_channel({"groupPolicy": "mention"})
|
||||
channel._self_username = "nanobot"
|
||||
assert channel._should_respond_in_channel("hello", "c1") is False
|
||||
assert channel._should_respond_in_channel("@nanobot hello", "c1") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_policy_open():
|
||||
channel, fake = _make_channel({"groupPolicy": "open"})
|
||||
assert channel._should_respond_in_channel("anything", "c1") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_policy_allowlist():
|
||||
channel, fake = _make_channel({"groupPolicy": "allowlist", "groupAllowFrom": ["c1"]})
|
||||
assert channel._should_respond_in_channel("msg", "c1") is True
|
||||
assert channel._should_respond_in_channel("msg", "c2") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Match mode: id / username / email
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_match_mode_id():
|
||||
channel, fake = _make_channel({"allowFromMatchMode": "id", "allowFrom": ["u1", "u2"]})
|
||||
assert await channel._match_sender("u1", ["u1", "u2"]) is True
|
||||
assert await channel._match_sender("u3", ["u1", "u2"]) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_match_mode_username():
|
||||
channel, fake = _make_channel({"allowFromMatchMode": "username", "allowFrom": ["alice"]})
|
||||
fake.set_get_response("/api/v4/users/u1", {"id": "u1", "username": "alice", "email": "alice@x.com"})
|
||||
assert await channel._match_sender("u1", ["alice"]) is True
|
||||
assert await channel._match_sender("u2", ["alice"]) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_match_mode_email():
|
||||
channel, fake = _make_channel({"allowFromMatchMode": "email", "allowFrom": ["alice@x.com"]})
|
||||
fake.set_get_response("/api/v4/users/u1", {"id": "u1", "username": "alice", "email": "alice@x.com"})
|
||||
assert await channel._match_sender("u1", ["alice@x.com"]) is True
|
||||
assert await channel._match_sender("u2", ["alice@x.com"]) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_identity_cache_username():
|
||||
channel, fake = _make_channel({"allowFromMatchMode": "username", "allowFrom": ["alice"]})
|
||||
fake.set_get_response("/api/v4/users/u1", {"id": "u1", "username": "alice", "email": ""})
|
||||
|
||||
calls_before = len(fake.get_calls)
|
||||
await channel._match_sender("u1", ["alice"])
|
||||
assert len(fake.get_calls) == calls_before + 1
|
||||
|
||||
await channel._match_sender("u1", ["alice"])
|
||||
assert len(fake.get_calls) == calls_before + 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Send
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_creates_post():
|
||||
channel, fake = _make_channel()
|
||||
channel._self_id = "bot_id"
|
||||
msg = OutboundMessage(
|
||||
channel="mattermost",
|
||||
chat_id="chan_1",
|
||||
content="hello world",
|
||||
)
|
||||
await channel.send(msg)
|
||||
posts = [c for c in fake.post_calls if c["path"] == "/api/v4/posts"]
|
||||
assert len(posts) == 1
|
||||
assert posts[0]["json"]["channel_id"] == "chan_1"
|
||||
assert posts[0]["json"]["message"] == "hello world"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_with_file_upload():
|
||||
channel, fake = _make_channel()
|
||||
channel._self_id = "bot_id"
|
||||
fake.set_post_response("/api/v4/files", {
|
||||
"file_infos": [{"id": "file_abc", "name": "test.txt"}],
|
||||
})
|
||||
|
||||
with patch("nanobot.channels.mattermost.Path.exists", return_value=True):
|
||||
with patch("nanobot.channels.mattermost.Path.read_bytes", return_value=b"data"):
|
||||
msg = OutboundMessage(
|
||||
channel="mattermost",
|
||||
chat_id="chan_1",
|
||||
content="with file",
|
||||
media=["/tmp/test.txt"],
|
||||
)
|
||||
await channel.send(msg)
|
||||
|
||||
file_uploads = [c for c in fake.post_calls if c["path"] == "/api/v4/files"]
|
||||
assert len(file_uploads) == 1
|
||||
|
||||
posts = [c for c in fake.post_calls if c["path"] == "/api/v4/posts"]
|
||||
assert len(posts) == 1
|
||||
assert posts[0]["json"]["file_ids"] == ["file_abc"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_with_thread_root_id():
|
||||
channel, fake = _make_channel({"replyInThread": True})
|
||||
channel._self_id = "bot_id"
|
||||
msg = OutboundMessage(
|
||||
channel="mattermost",
|
||||
chat_id="chan_1",
|
||||
content="reply in thread",
|
||||
metadata={"mattermost": {"root_id": "root_42"}},
|
||||
)
|
||||
await channel.send(msg)
|
||||
posts = [c for c in fake.post_calls if c["path"] == "/api/v4/posts"]
|
||||
assert len(posts) == 1
|
||||
assert posts[0]["json"]["root_id"] == "root_42"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_reaction_on_completion():
|
||||
channel, fake = _make_channel()
|
||||
channel._self_id = "bot_id"
|
||||
msg = OutboundMessage(
|
||||
channel="mattermost",
|
||||
chat_id="chan_1",
|
||||
content="done",
|
||||
metadata={"message_id": "orig_post_1"},
|
||||
)
|
||||
await channel.send(msg)
|
||||
reactions = [c for c in fake.post_calls if c["path"] == "/api/v4/reactions"]
|
||||
assert len(reactions) == 1
|
||||
assert reactions[0]["json"]["emoji_name"] == "white_check_mark"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streaming
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_first_delta_creates_post():
|
||||
channel, fake = _make_channel()
|
||||
channel._self_id = "bot_id"
|
||||
fake.set_post_response("/api/v4/posts", {"id": "stream_post_1"})
|
||||
|
||||
await channel.send_delta("chan_1", "Hello", {"_stream_id": "s1"})
|
||||
posts = [c for c in fake.post_calls if c["path"] == "/api/v4/posts"]
|
||||
assert len(posts) == 1
|
||||
assert "Hello" in posts[0]["json"]["message"]
|
||||
assert channel._stream_posts["s1"] == "stream_post_1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_subsequent_delta_edits_post():
|
||||
channel, fake = _make_channel()
|
||||
channel._self_id = "bot_id"
|
||||
fake.set_post_response("/api/v4/posts", {"id": "stream_post_1"})
|
||||
|
||||
await channel.send_delta("chan_1", "Hello", {"_stream_id": "s1"})
|
||||
assert channel._stream_buffers["s1"] == "Hello"
|
||||
|
||||
await channel.send_delta("chan_1", " world", {"_stream_id": "s1"})
|
||||
edits = [c for c in fake.put_calls if c["path"] == "/api/v4/posts/stream_post_1"]
|
||||
assert len(edits) == 1
|
||||
assert edits[0]["json"]["message"] == "Hello world"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_end_adds_done_emoji():
|
||||
channel, fake = _make_channel()
|
||||
channel._self_id = "bot_id"
|
||||
fake.set_post_response("/api/v4/posts", {"id": "stream_post_1"})
|
||||
|
||||
await channel.send_delta("chan_1", "Hello", {"_stream_id": "s1"})
|
||||
await channel.send_delta("chan_1", "", {"_stream_id": "s1", "_stream_end": True})
|
||||
reactions = [c for c in fake.post_calls if c["path"] == "/api/v4/reactions" and c["json"]["emoji_name"] == "white_check_mark"]
|
||||
assert len(reactions) >= 1
|
||||
assert channel._stream_posts.get("s1") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_chunk_boundary_finalizes_and_creates_new():
|
||||
channel, fake = _make_channel({"streamingMaxChars": 10})
|
||||
channel._self_id = "bot_id"
|
||||
fake.set_post_response("/api/v4/posts", {"id": "post_1"})
|
||||
|
||||
await channel.send_delta("chan_1", "Hello ", {"_stream_id": "s1"})
|
||||
await channel.send_delta("chan_1", "world", {"_stream_id": "s1"})
|
||||
|
||||
posts = [c for c in fake.post_calls if c["path"] == "/api/v4/posts"]
|
||||
assert len(posts) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reactions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaction_add_on_receipt():
|
||||
channel, fake = _make_channel()
|
||||
channel._self_id = "bot_id"
|
||||
await channel._add_reaction("chan_1", "post_1", "eyes")
|
||||
reactions = [c for c in fake.post_calls if c["path"] == "/api/v4/reactions"]
|
||||
assert len(reactions) >= 1
|
||||
assert reactions[-1]["json"]["emoji_name"] == "eyes"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaction_remove():
|
||||
channel, fake = _make_channel()
|
||||
channel._self_id = "bot_id"
|
||||
await channel._remove_reaction("post_1", "eyes")
|
||||
assert len(fake.delete_calls) >= 1
|
||||
assert "post_1" in fake.delete_calls[-1]["path"]
|
||||
assert "eyes" in fake.delete_calls[-1]["path"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Team filtering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_filtering_rejects_wrong_team():
|
||||
channel, fake = _make_channel({"teamId": "team_a"})
|
||||
channel._self_id = "bot_id"
|
||||
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
|
||||
ws_msg = {
|
||||
"event": "posted",
|
||||
"data": {
|
||||
"channel_type": "O",
|
||||
"post": json.dumps({
|
||||
"id": "p1", "user_id": "u1",
|
||||
"channel_id": "c1", "message": "hi", "root_id": "",
|
||||
}),
|
||||
},
|
||||
"broadcast": {"channel_id": "c1", "team_id": "team_b"},
|
||||
}
|
||||
await channel._handle_ws_message(ws_msg)
|
||||
mock_handle.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_filtering_allows_correct_team():
|
||||
channel, fake = _make_channel({"teamId": "team_a"})
|
||||
channel._self_id = "bot_id"
|
||||
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
|
||||
with patch.object(channel, "_is_allowed", AsyncMock(return_value=True)):
|
||||
with patch.object(channel, "_should_respond_in_channel", return_value=True):
|
||||
ws_msg = {
|
||||
"event": "posted",
|
||||
"data": {
|
||||
"channel_type": "O",
|
||||
"post": json.dumps({
|
||||
"id": "p1", "user_id": "u1",
|
||||
"channel_id": "c1", "message": "hi", "root_id": "",
|
||||
}),
|
||||
},
|
||||
"broadcast": {"channel_id": "c1", "team_id": "team_a"},
|
||||
}
|
||||
await channel._handle_ws_message(ws_msg)
|
||||
mock_handle.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_filtering_dm_bypass():
|
||||
channel, fake = _make_channel({"teamId": "team_a"})
|
||||
channel._self_id = "bot_id"
|
||||
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
|
||||
ws_msg = {
|
||||
"event": "posted",
|
||||
"data": {
|
||||
"channel_type": "D",
|
||||
"post": json.dumps({
|
||||
"id": "p1", "user_id": "u1",
|
||||
"channel_id": "dm_chan", "message": "hi", "root_id": "",
|
||||
}),
|
||||
},
|
||||
"broadcast": {"channel_id": "dm_chan", "team_id": ""},
|
||||
}
|
||||
await channel._handle_ws_message(ws_msg)
|
||||
mock_handle.assert_awaited_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thread session key
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_session_key():
|
||||
channel, fake = _make_channel()
|
||||
channel._self_id = "bot_id"
|
||||
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
|
||||
with patch.object(channel, "_is_allowed", AsyncMock(return_value=True)):
|
||||
with patch.object(channel, "_should_respond_in_channel", return_value=True):
|
||||
ws_msg = {
|
||||
"event": "posted",
|
||||
"data": {
|
||||
"channel_type": "O",
|
||||
"post": json.dumps({
|
||||
"id": "post_1", "user_id": "u1",
|
||||
"channel_id": "c1", "message": "in thread",
|
||||
"root_id": "root_99",
|
||||
}),
|
||||
},
|
||||
"broadcast": {},
|
||||
}
|
||||
await channel._handle_ws_message(ws_msg)
|
||||
kwargs = mock_handle.call_args[1]
|
||||
assert kwargs["session_key"] == "mattermost:c1:root_99"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action event (interactive buttons)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_event():
|
||||
channel, fake = _make_channel()
|
||||
channel._self_id = "bot_id"
|
||||
with patch.object(channel, "_handle_message", AsyncMock()) as mock_handle:
|
||||
ws_msg = {
|
||||
"event": "action",
|
||||
"data": {
|
||||
"user_id": "u1",
|
||||
"channel_id": "c1",
|
||||
"context": {"selected_option": "Approve"},
|
||||
},
|
||||
"broadcast": {},
|
||||
}
|
||||
await channel._handle_ws_message(ws_msg)
|
||||
mock_handle.assert_awaited_once_with(
|
||||
sender_id="u1",
|
||||
chat_id="c1",
|
||||
content="Approve",
|
||||
metadata={"mattermost": {"channel_type": "public", "is_action": True}},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post deleted event
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_deleted_cleans_stream_state():
|
||||
channel, fake = _make_channel()
|
||||
channel._stream_posts["s1"] = "del_post_1"
|
||||
channel._stream_posts["s2"] = "keep_post_2"
|
||||
|
||||
ws_msg = {
|
||||
"event": "post_deleted",
|
||||
"data": {
|
||||
"channel_id": "c1",
|
||||
"post": json.dumps({"id": "del_post_1", "delete_at": 123}),
|
||||
},
|
||||
"broadcast": {},
|
||||
}
|
||||
await channel._handle_ws_message(ws_msg)
|
||||
assert "s1" not in channel._stream_posts
|
||||
assert channel._stream_posts["s2"] == "keep_post_2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth failure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_failure_prevents_start():
|
||||
channel, fake = _make_channel()
|
||||
fake.set_get_response("/api/v4/users/me", {"id": "", "username": ""})
|
||||
with patch.object(fake, "get", side_effect=Exception("401 Unauthorized")):
|
||||
await channel.start()
|
||||
assert channel._self_id is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DM allowlist with match mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_allowlist_with_username_match():
|
||||
channel, fake = _make_channel({
|
||||
"allowFromMatchMode": "username",
|
||||
"dm": {"policy": "allowlist", "allowFrom": ["alice"]},
|
||||
})
|
||||
fake.set_get_response("/api/v4/users/u1", {"id": "u1", "username": "alice", "email": ""})
|
||||
assert await channel._is_allowed("u1", "dm_chan", "dm") is True
|
||||
assert await channel._is_allowed("u2", "dm_chan", "dm") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# split_message helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_message_splitting():
|
||||
from nanobot.utils.helpers import split_message
|
||||
short = "short message"
|
||||
assert split_message(short, MATTERMOST_MAX_MESSAGE_LEN) == [short]
|
||||
|
||||
long_text = "A" * (MATTERMOST_MAX_MESSAGE_LEN + 100)
|
||||
chunks = split_message(long_text, MATTERMOST_MAX_MESSAGE_LEN)
|
||||
assert all(len(c) <= MATTERMOST_MAX_MESSAGE_LEN for c in chunks)
|
||||
assert "".join(chunks) == long_text
|
||||
Reference in New Issue
Block a user