1977 lines
62 KiB
Python
1977 lines
62 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Mattermost Channel Unit Tests
|
|
|
|
Comprehensive unit tests for MattermostChannel covering:
|
|
- Initialization and configuration
|
|
- Factory methods (from_env, from_config)
|
|
- Session ID resolution and routing
|
|
- HTTP API interactions (mocked)
|
|
- Send methods (text and media)
|
|
- File upload and download
|
|
- History fetching (thread and channel)
|
|
- Typing indicators
|
|
- Message event handling
|
|
- Lifecycle (start/stop)
|
|
- Allowlist and ACL checks
|
|
|
|
Test Patterns:
|
|
- Uses MockHttpxClient for HTTP request mocking (httpx.AsyncClient)
|
|
- Async tests with @pytest.mark.asyncio (only on async methods)
|
|
- No global pytestmark
|
|
|
|
Run:
|
|
pytest tests/unit/channels/test_mattermost.py -v
|
|
pytest tests/unit/channels/test_mattermost.py::TestMattermostChannelInit -v
|
|
"""
|
|
# pylint: disable=redefined-outer-name,protected-access,unused-argument
|
|
# pylint: disable=broad-exception-raised,using-constant-test
|
|
from __future__ import annotations
|
|
|
|
|
|
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Generator, Optional
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from pineagents.app.channels.renderer import ChannelDisplayConfig
|
|
|
|
|
|
# =============================================================================
|
|
# Mock HTTP Classes for httpx
|
|
# =============================================================================
|
|
|
|
|
|
class MockHttpxResponse:
|
|
"""Mock httpx Response for testing."""
|
|
|
|
def __init__(
|
|
self,
|
|
status_code: int = 200,
|
|
json_data: Optional[dict] = None,
|
|
text_data: str = "",
|
|
headers: Optional[dict] = None,
|
|
):
|
|
self.status_code = status_code
|
|
self._json_data = json_data or {}
|
|
self._text_data = text_data
|
|
self.headers = headers or {}
|
|
|
|
def json(self) -> dict:
|
|
"""Return JSON data."""
|
|
return self._json_data
|
|
|
|
def text(self) -> str:
|
|
"""Return text data."""
|
|
return self._text_data
|
|
|
|
def read(self) -> bytes:
|
|
"""Return bytes data."""
|
|
return self._text_data.encode()
|
|
|
|
async def aiter_bytes(self, chunk_size: int = 65536):
|
|
"""Async iterator over response bytes."""
|
|
yield self._text_data.encode()
|
|
|
|
def raise_for_status(self):
|
|
"""Raise exception for error status codes."""
|
|
if self.status_code >= 400:
|
|
raise Exception(f"HTTP {self.status_code}")
|
|
|
|
|
|
class MockHttpxClient:
|
|
"""Mock httpx.AsyncClient for testing Mattermost channel."""
|
|
|
|
def __init__(self):
|
|
self._expectations: list[dict] = []
|
|
self._requests: list[dict] = []
|
|
self.call_count = 0
|
|
self.closed = False
|
|
|
|
def expect_post(
|
|
self,
|
|
url: Optional[str] = None,
|
|
response_status: int = 200,
|
|
response_json: Optional[dict] = None,
|
|
response_text: str = "",
|
|
) -> None:
|
|
"""Set up expected POST request."""
|
|
self._expectations.append(
|
|
{
|
|
"method": "POST",
|
|
"url": url,
|
|
"response": MockHttpxResponse(
|
|
status_code=response_status,
|
|
json_data=response_json,
|
|
text_data=response_text,
|
|
),
|
|
},
|
|
)
|
|
|
|
def expect_get(
|
|
self,
|
|
url: Optional[str] = None,
|
|
response_status: int = 200,
|
|
response_json: Optional[dict] = None,
|
|
response_text: str = "",
|
|
) -> None:
|
|
"""Set up expected GET request."""
|
|
self._expectations.append(
|
|
{
|
|
"method": "GET",
|
|
"url": url,
|
|
"response": MockHttpxResponse(
|
|
status_code=response_status,
|
|
json_data=response_json,
|
|
text_data=response_text,
|
|
),
|
|
},
|
|
)
|
|
|
|
async def post(self, url: str, **kwargs) -> MockHttpxResponse:
|
|
"""Mock POST request."""
|
|
self._requests.append({"method": "POST", "url": url, "kwargs": kwargs})
|
|
self.call_count += 1
|
|
|
|
for exp in self._expectations:
|
|
if exp["method"] == "POST":
|
|
if exp["url"] is None or exp["url"] in url:
|
|
return exp["response"]
|
|
return MockHttpxResponse(status_code=404, text_data="Not Found")
|
|
|
|
async def get(self, url: str, **kwargs) -> MockHttpxResponse:
|
|
"""Mock GET request."""
|
|
self._requests.append({"method": "GET", "url": url, "kwargs": kwargs})
|
|
self.call_count += 1
|
|
|
|
for exp in self._expectations:
|
|
if exp["method"] == "GET":
|
|
if exp["url"] is None or exp["url"] in url:
|
|
return exp["response"]
|
|
return MockHttpxResponse(status_code=404, text_data="Not Found")
|
|
|
|
def stream(self, method: str, url: str, **kwargs):
|
|
"""Mock stream context manager."""
|
|
self._requests.append({"method": method, "url": url, "kwargs": kwargs})
|
|
self.call_count += 1
|
|
|
|
class StreamContext:
|
|
def __init__(self, response):
|
|
self.response = response
|
|
|
|
async def __aenter__(self):
|
|
return self.response
|
|
|
|
async def __aexit__(self, *args):
|
|
pass
|
|
|
|
for exp in self._expectations:
|
|
if exp["method"] == method or exp["method"] == "GET":
|
|
if exp["url"] is None or exp["url"] in url:
|
|
return StreamContext(exp["response"])
|
|
return StreamContext(MockHttpxResponse(status_code=404))
|
|
|
|
async def aclose(self) -> None:
|
|
"""Mock close."""
|
|
self.closed = True
|
|
|
|
|
|
# =============================================================================
|
|
# Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_process_handler() -> AsyncMock:
|
|
"""Mock process handler that yields simple events."""
|
|
|
|
async def mock_process(*_args, **_kwargs):
|
|
mock_event = MagicMock()
|
|
mock_event.object = "message"
|
|
mock_event.status = "completed"
|
|
mock_event.type = "text"
|
|
yield mock_event
|
|
|
|
return AsyncMock(side_effect=mock_process)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_http_client() -> MockHttpxClient:
|
|
"""Create a mock httpx client."""
|
|
return MockHttpxClient()
|
|
|
|
|
|
@pytest.fixture
|
|
def mattermost_channel(
|
|
mock_process_handler,
|
|
tmp_path: Path,
|
|
) -> Generator:
|
|
"""Create a MattermostChannel instance for testing."""
|
|
from pineagents.app.channels.mattermost.channel import MattermostChannel
|
|
|
|
channel = MattermostChannel(
|
|
process=mock_process_handler,
|
|
enabled=True,
|
|
url="https://mattermost.example.com",
|
|
bot_token="test_token_123",
|
|
bot_prefix="[TestBot] ",
|
|
media_dir=str(tmp_path / "media"),
|
|
display_config=ChannelDisplayConfig(
|
|
show_tool_calls=False,
|
|
show_tool_results=False,
|
|
),
|
|
dm_policy="open",
|
|
group_policy="open",
|
|
)
|
|
yield channel
|
|
|
|
|
|
# =============================================================================
|
|
# P0: Initialization and Configuration
|
|
# =============================================================================
|
|
|
|
|
|
class TestMattermostChannelInit:
|
|
"""Tests for MattermostChannel initialization and factory methods."""
|
|
|
|
def test_init_stores_basic_config(
|
|
self,
|
|
mock_process_handler,
|
|
tmp_path: Path,
|
|
):
|
|
"""Constructor should store all basic configuration parameters."""
|
|
from pineagents.app.channels.mattermost.channel import MattermostChannel
|
|
|
|
channel = MattermostChannel(
|
|
process=mock_process_handler,
|
|
enabled=True,
|
|
url="https://mm.example.com",
|
|
bot_token="my_token_123",
|
|
bot_prefix="[Bot] ",
|
|
media_dir=str(tmp_path / "media"),
|
|
dm_policy="open",
|
|
group_policy="allowlist",
|
|
)
|
|
|
|
assert channel.enabled is True
|
|
assert channel._url == "https://mm.example.com"
|
|
assert channel._bot_token == "my_token_123"
|
|
assert channel.bot_prefix == "[Bot] "
|
|
assert channel.channel == "mattermost"
|
|
assert channel.dm_policy == "open"
|
|
assert channel.group_policy == "allowlist"
|
|
|
|
def test_init_stores_advanced_config(
|
|
self,
|
|
mock_process_handler,
|
|
tmp_path: Path,
|
|
):
|
|
"""Constructor should store advanced configuration parameters."""
|
|
from pineagents.app.channels.mattermost.channel import MattermostChannel
|
|
|
|
channel = MattermostChannel(
|
|
process=mock_process_handler,
|
|
enabled=False,
|
|
url="",
|
|
bot_token="",
|
|
bot_prefix="",
|
|
media_dir=str(tmp_path / "custom_media"),
|
|
show_typing=False,
|
|
thread_follow_without_mention=True,
|
|
display_config=ChannelDisplayConfig(
|
|
show_thinking=False,
|
|
show_tool_calls=False,
|
|
show_tool_results=False,
|
|
),
|
|
allow_from=["user1", "user2"],
|
|
deny_message="Access denied",
|
|
)
|
|
|
|
assert channel.enabled is False
|
|
assert channel._show_typing is False
|
|
assert channel._thread_follow is True
|
|
assert channel._display_config.show_tool_details is True
|
|
assert channel._display_config.show_tool_calls is False
|
|
assert channel._display_config.show_tool_results is False
|
|
assert not channel._display_config.show_thinking
|
|
assert channel.allow_from == {"user1", "user2"}
|
|
assert channel.deny_message == "Access denied"
|
|
|
|
def test_init_normalizes_url(self, mock_process_handler):
|
|
"""Constructor should normalize URL by stripping trailing slash."""
|
|
from pineagents.app.channels.mattermost.channel import MattermostChannel
|
|
|
|
channel = MattermostChannel(
|
|
process=mock_process_handler,
|
|
enabled=True,
|
|
url="https://mm.example.com/",
|
|
bot_token="token123",
|
|
)
|
|
|
|
assert channel._url == "https://mm.example.com"
|
|
|
|
def test_init_disables_when_empty_url(self, mock_process_handler):
|
|
"""Should disable channel when URL is empty but enabled=True."""
|
|
from pineagents.app.channels.mattermost.channel import MattermostChannel
|
|
|
|
channel = MattermostChannel(
|
|
process=mock_process_handler,
|
|
enabled=True,
|
|
url="",
|
|
bot_token="token123",
|
|
)
|
|
|
|
assert channel.enabled is False
|
|
|
|
def test_init_disables_when_empty_token(self, mock_process_handler):
|
|
"""Should disable channel when bot_token is empty but enabled=True."""
|
|
from pineagents.app.channels.mattermost.channel import MattermostChannel
|
|
|
|
channel = MattermostChannel(
|
|
process=mock_process_handler,
|
|
enabled=True,
|
|
url="https://mm.example.com",
|
|
bot_token="",
|
|
)
|
|
|
|
assert channel.enabled is False
|
|
|
|
def test_init_creates_required_data_structures(self, mock_process_handler):
|
|
"""Constructor should initialize required internal data structures."""
|
|
from pineagents.app.channels.mattermost.channel import MattermostChannel
|
|
|
|
channel = MattermostChannel(
|
|
process=mock_process_handler,
|
|
enabled=True,
|
|
url="https://mm.example.com",
|
|
bot_token="token123",
|
|
)
|
|
|
|
assert hasattr(channel, "_typing_tasks")
|
|
assert isinstance(channel._typing_tasks, dict)
|
|
# Bounded OrderedDict-backed sets (FIFO eviction) prevent unbounded
|
|
# growth of the lazy-context tracking maps.
|
|
from collections import OrderedDict
|
|
|
|
assert hasattr(channel, "_participated_threads")
|
|
assert isinstance(channel._participated_threads, OrderedDict)
|
|
assert hasattr(channel, "_seen_sessions")
|
|
assert isinstance(channel._seen_sessions, OrderedDict)
|
|
assert channel._bot_id == ""
|
|
assert channel._bot_username == ""
|
|
|
|
def test_init_creates_http_client(self, mock_process_handler):
|
|
"""Constructor should create httpx client with auth header."""
|
|
from pineagents.app.channels.mattermost.channel import MattermostChannel
|
|
|
|
channel = MattermostChannel(
|
|
process=mock_process_handler,
|
|
enabled=True,
|
|
url="https://mm.example.com",
|
|
bot_token="test_token",
|
|
)
|
|
|
|
assert channel._http is not None
|
|
assert hasattr(channel._http, "headers")
|
|
|
|
|
|
class TestMattermostChannelFromEnv:
|
|
"""Tests for from_env factory method."""
|
|
|
|
def test_from_env_reads_basic_env_vars(
|
|
self,
|
|
mock_process_handler,
|
|
monkeypatch,
|
|
):
|
|
"""from_env should read basic environment variables."""
|
|
from pineagents.app.channels.mattermost.channel import MattermostChannel
|
|
|
|
monkeypatch.setenv("MATTERMOST_CHANNEL_ENABLED", "1")
|
|
monkeypatch.setenv("MATTERMOST_URL", "https://env.mm.com")
|
|
monkeypatch.setenv("MATTERMOST_BOT_TOKEN", "env_token_123")
|
|
monkeypatch.setenv("MATTERMOST_BOT_PREFIX", "[EnvBot] ")
|
|
|
|
channel = MattermostChannel.from_env(mock_process_handler)
|
|
|
|
assert channel.enabled is True
|
|
assert channel._url == "https://env.mm.com"
|
|
assert channel._bot_token == "env_token_123"
|
|
assert channel.bot_prefix == "[EnvBot] "
|
|
|
|
def test_from_env_reads_advanced_env_vars(
|
|
self,
|
|
mock_process_handler,
|
|
monkeypatch,
|
|
):
|
|
"""from_env should read advanced environment variables."""
|
|
from pineagents.app.channels.mattermost.channel import MattermostChannel
|
|
|
|
monkeypatch.setenv("MATTERMOST_MEDIA_DIR", "/env/media")
|
|
monkeypatch.setenv("MATTERMOST_SHOW_TYPING", "0")
|
|
monkeypatch.setenv("MATTERMOST_THREAD_FOLLOW", "1")
|
|
monkeypatch.setenv("MATTERMOST_DM_POLICY", "allowlist")
|
|
monkeypatch.setenv("MATTERMOST_GROUP_POLICY", "allowlist")
|
|
monkeypatch.setenv("MATTERMOST_DENY_MESSAGE", "Env access denied")
|
|
|
|
channel = MattermostChannel.from_env(mock_process_handler)
|
|
|
|
assert channel._media_dir == Path("/env/media").expanduser()
|
|
assert channel._show_typing is False
|
|
assert channel._thread_follow is True
|
|
assert channel.dm_policy == "allowlist"
|
|
assert channel.group_policy == "allowlist"
|
|
assert channel.deny_message == "Env access denied"
|
|
|
|
def test_from_env_allow_from_parsing(
|
|
self,
|
|
mock_process_handler,
|
|
monkeypatch,
|
|
):
|
|
"""from_env should parse MATTERMOST_ALLOW_FROM correctly."""
|
|
from pineagents.app.channels.mattermost.channel import MattermostChannel
|
|
|
|
monkeypatch.setenv("MATTERMOST_ALLOW_FROM", "user1,user2,user3")
|
|
|
|
channel = MattermostChannel.from_env(mock_process_handler)
|
|
|
|
assert "user1" in channel.allow_from
|
|
assert "user2" in channel.allow_from
|
|
assert "user3" in channel.allow_from
|
|
|
|
def test_from_env_allow_from_empty(
|
|
self,
|
|
mock_process_handler,
|
|
monkeypatch,
|
|
):
|
|
"""from_env should handle empty MATTERMOST_ALLOW_FROM."""
|
|
from pineagents.app.channels.mattermost.channel import MattermostChannel
|
|
|
|
monkeypatch.setenv("MATTERMOST_ALLOW_FROM", "")
|
|
|
|
channel = MattermostChannel.from_env(mock_process_handler)
|
|
|
|
assert channel.allow_from == set()
|
|
|
|
def test_from_env_defaults(self, mock_process_handler, monkeypatch):
|
|
"""from_env should use sensible defaults."""
|
|
from pineagents.app.channels.mattermost.channel import MattermostChannel
|
|
|
|
monkeypatch.delenv("MATTERMOST_CHANNEL_ENABLED", raising=False)
|
|
monkeypatch.delenv("MATTERMOST_BOT_PREFIX", raising=False)
|
|
monkeypatch.delenv("MATTERMOST_DM_POLICY", raising=False)
|
|
monkeypatch.delenv("MATTERMOST_GROUP_POLICY", raising=False)
|
|
|
|
channel = MattermostChannel.from_env(mock_process_handler)
|
|
|
|
assert channel.enabled is False # Default disabled
|
|
assert channel.bot_prefix == "" # Default empty
|
|
assert channel.dm_policy == "open" # Default open
|
|
assert channel.group_policy == "open" # Default open
|
|
assert channel._show_typing is True # Default True
|
|
|
|
|
|
class TestMattermostChannelFromConfig:
|
|
"""Tests for from_config factory method."""
|
|
|
|
def test_from_config_uses_config_values(self, mock_process_handler):
|
|
"""from_config should use values from config object."""
|
|
from pineagents.app.channels.mattermost.channel import MattermostChannel
|
|
|
|
class MockConfig:
|
|
enabled = True
|
|
url = "https://config.mm.com"
|
|
bot_token = "config_token_123"
|
|
bot_prefix = "[ConfigBot] "
|
|
media_dir = "/config/media"
|
|
show_typing = False
|
|
thread_follow_without_mention = True
|
|
dm_policy = "allowlist"
|
|
group_policy = "allowlist"
|
|
allow_from = ["user1", "user2"]
|
|
deny_message = "Config denied"
|
|
|
|
def model_dump(self):
|
|
return {
|
|
"enabled": self.enabled,
|
|
"url": self.url,
|
|
"bot_token": self.bot_token,
|
|
"bot_prefix": self.bot_prefix,
|
|
"media_dir": self.media_dir,
|
|
"show_typing": self.show_typing,
|
|
"thread_follow_without_mention": (
|
|
self.thread_follow_without_mention
|
|
),
|
|
"dm_policy": self.dm_policy,
|
|
"group_policy": self.group_policy,
|
|
"allow_from": self.allow_from,
|
|
"deny_message": self.deny_message,
|
|
}
|
|
|
|
config = MockConfig()
|
|
channel = MattermostChannel.from_config(
|
|
process=mock_process_handler,
|
|
config=config,
|
|
)
|
|
|
|
assert channel.enabled is True
|
|
assert channel._url == "https://config.mm.com"
|
|
assert channel._bot_token == "config_token_123"
|
|
assert channel.bot_prefix == "[ConfigBot]"
|
|
assert channel.dm_policy == "allowlist"
|
|
assert channel.group_policy == "allowlist"
|
|
|
|
def test_from_config_handles_dict_config(self, mock_process_handler):
|
|
"""from_config should handle dict config."""
|
|
from pineagents.app.channels.mattermost.channel import MattermostChannel
|
|
|
|
config_dict = {
|
|
"enabled": True,
|
|
"url": "https://dict.mm.com",
|
|
"bot_token": "dict_token",
|
|
"bot_prefix": "[Dict] ",
|
|
"dm_policy": "open",
|
|
"group_policy": "open",
|
|
}
|
|
|
|
channel = MattermostChannel.from_config(
|
|
process=mock_process_handler,
|
|
config=config_dict,
|
|
)
|
|
|
|
assert channel.enabled is True
|
|
assert channel._url == "https://dict.mm.com"
|
|
assert channel._bot_token == "dict_token"
|
|
|
|
def test_from_config_handles_none_values(self, mock_process_handler):
|
|
"""from_config should handle None values gracefully."""
|
|
from pineagents.app.channels.mattermost.channel import MattermostChannel
|
|
|
|
config_dict = {
|
|
"enabled": None,
|
|
"url": None,
|
|
"bot_token": None,
|
|
"bot_prefix": None,
|
|
"dm_policy": None,
|
|
"group_policy": None,
|
|
}
|
|
|
|
channel = MattermostChannel.from_config(
|
|
process=mock_process_handler,
|
|
config=config_dict,
|
|
)
|
|
|
|
assert channel.enabled is False
|
|
assert channel._url == ""
|
|
assert channel._bot_token == ""
|
|
assert channel.bot_prefix == ""
|
|
assert channel.dm_policy == "open"
|
|
assert channel.group_policy == "open"
|
|
|
|
|
|
# =============================================================================
|
|
# P1: Session ID Resolution and Routing
|
|
# =============================================================================
|
|
|
|
|
|
class TestMattermostResolveSession:
|
|
"""Tests for session resolution and routing helpers."""
|
|
|
|
def test_resolve_session_id_dm(self, mattermost_channel):
|
|
"""resolve_session_id should format DM session ID."""
|
|
result = mattermost_channel.resolve_session_id(
|
|
sender_id="user123",
|
|
channel_meta={
|
|
"channel_type": "D",
|
|
"mm_channel_id": "dm_channel_456",
|
|
},
|
|
)
|
|
|
|
assert result == "mattermost_dm:dm_channel_456"
|
|
|
|
def test_resolve_session_id_thread(self, mattermost_channel):
|
|
"""resolve_session_id should format thread session ID."""
|
|
result = mattermost_channel.resolve_session_id(
|
|
sender_id="user123",
|
|
channel_meta={
|
|
"channel_type": "O",
|
|
"mm_channel_id": "channel_789",
|
|
"root_id": "root_post_abc",
|
|
"post_id": "post_def",
|
|
},
|
|
)
|
|
|
|
assert result == "mattermost_thread:root_post_abc"
|
|
|
|
def test_resolve_session_id_no_root_uses_post_id(self, mattermost_channel):
|
|
"""resolve_session_id should use post_id when root_id is empty."""
|
|
result = mattermost_channel.resolve_session_id(
|
|
sender_id="user123",
|
|
channel_meta={
|
|
"channel_type": "O",
|
|
"mm_channel_id": "channel_789",
|
|
"root_id": "",
|
|
"post_id": "post_123",
|
|
},
|
|
)
|
|
|
|
assert result == "mattermost_thread:post_123"
|
|
|
|
def test_get_to_handle_from_request_with_meta(self, mattermost_channel):
|
|
"""get_to_handle_from_request should extract handle from meta."""
|
|
mock_request = MagicMock()
|
|
mock_request.channel_meta = {"mm_channel_id": "channel_abc"}
|
|
mock_request.session_id = ""
|
|
|
|
result = mattermost_channel.get_to_handle_from_request(mock_request)
|
|
|
|
assert result == "channel_abc"
|
|
|
|
def test_get_to_handle_from_request_fallback_session(
|
|
self,
|
|
mattermost_channel,
|
|
):
|
|
"""get_to_handle_from_request should fallback to session_id."""
|
|
mock_request = MagicMock()
|
|
mock_request.channel_meta = {}
|
|
mock_request.session_id = "mattermost_dm:dm_channel_123"
|
|
|
|
result = mattermost_channel.get_to_handle_from_request(mock_request)
|
|
|
|
assert result == "dm_channel_123"
|
|
|
|
def test_get_to_handle_from_request_fallback_user(
|
|
self,
|
|
mattermost_channel,
|
|
):
|
|
"""get_to_handle_from_request should fallback to user_id."""
|
|
mock_request = MagicMock()
|
|
mock_request.channel_meta = {}
|
|
mock_request.session_id = ""
|
|
mock_request.user_id = "user123"
|
|
|
|
result = mattermost_channel.get_to_handle_from_request(mock_request)
|
|
|
|
assert result == "user123"
|
|
|
|
def test_to_handle_from_target_dm_session(self, mattermost_channel):
|
|
"""to_handle_from_target should extract from DM session_id."""
|
|
result = mattermost_channel.to_handle_from_target(
|
|
user_id="user123",
|
|
session_id="mattermost_dm:dm_channel_456",
|
|
)
|
|
|
|
assert result == "dm_channel_456"
|
|
|
|
def test_to_handle_from_target_fallback_user(self, mattermost_channel):
|
|
"""to_handle_from_target should fallback to user_id."""
|
|
result = mattermost_channel.to_handle_from_target(
|
|
user_id="user123",
|
|
session_id="mattermost_thread:root_abc",
|
|
)
|
|
|
|
assert result == "user123"
|
|
|
|
|
|
# =============================================================================
|
|
# P1: Build Agent Request
|
|
# =============================================================================
|
|
|
|
|
|
class TestMattermostBuildAgentRequest:
|
|
"""Tests for build_agent_request_from_native method."""
|
|
|
|
def test_build_agent_request_from_native(self, mattermost_channel):
|
|
"""Should create AgentRequest from native payload."""
|
|
from pineagents.app.channels.base import TextContent, ContentType
|
|
|
|
payload = {
|
|
"channel_id": "mattermost",
|
|
"sender_id": "user123",
|
|
"content_parts": [
|
|
TextContent(type=ContentType.TEXT, text="Hello"),
|
|
],
|
|
"meta": {
|
|
"mm_channel_id": "channel_abc",
|
|
"root_id": "root_123",
|
|
"channel_type": "O",
|
|
"post_id": "post_456",
|
|
},
|
|
}
|
|
|
|
request = mattermost_channel.build_agent_request_from_native(payload)
|
|
|
|
assert request.user_id == "user123"
|
|
assert request.channel == "mattermost"
|
|
assert request.session_id == "mattermost_thread:root_123"
|
|
assert hasattr(request, "channel_meta")
|
|
|
|
def test_build_agent_request_auto_session(self, mattermost_channel):
|
|
"""Should auto-generate session_id when not provided."""
|
|
from pineagents.app.channels.base import TextContent, ContentType
|
|
|
|
payload = {
|
|
"channel_id": "mattermost",
|
|
"sender_id": "user123",
|
|
"content_parts": [
|
|
TextContent(type=ContentType.TEXT, text="Hello"),
|
|
],
|
|
"meta": {
|
|
"mm_channel_id": "channel_abc",
|
|
"channel_type": "D",
|
|
},
|
|
}
|
|
|
|
request = mattermost_channel.build_agent_request_from_native(payload)
|
|
|
|
assert request.session_id == "mattermost_dm:channel_abc"
|
|
|
|
|
|
# =============================================================================
|
|
# P1: HTTP API Interactions
|
|
# =============================================================================
|
|
|
|
|
|
class TestMattermostHTTPBase:
|
|
"""Base class for HTTP-based tests."""
|
|
|
|
@pytest.fixture
|
|
def channel_with_mock_http(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Create channel with mocked HTTP client."""
|
|
mattermost_channel._http = mock_http_client
|
|
mattermost_channel._bot_id = "bot_123"
|
|
return mattermost_channel
|
|
|
|
|
|
class TestMattermostInitBotInfo:
|
|
"""Tests for _init_bot_info method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_init_bot_info_success(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should fetch and cache bot info on success."""
|
|
mock_http_client.expect_get(
|
|
url="/api/v4/users/me",
|
|
response_status=200,
|
|
response_json={
|
|
"id": "bot_user_123",
|
|
"username": "testbot",
|
|
},
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
result = await mattermost_channel._init_bot_info()
|
|
|
|
assert result is True
|
|
assert mattermost_channel._bot_id == "bot_user_123"
|
|
assert mattermost_channel._bot_username == "testbot"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_init_bot_info_failure(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should return False on API error."""
|
|
mock_http_client.expect_get(
|
|
url="/api/v4/users/me",
|
|
response_status=401,
|
|
response_text="Unauthorized",
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
result = await mattermost_channel._init_bot_info()
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_init_bot_info_exception(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should handle exception gracefully."""
|
|
mock_http_client.get = AsyncMock(
|
|
side_effect=Exception("Network error"),
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
result = await mattermost_channel._init_bot_info()
|
|
|
|
assert result is False
|
|
|
|
|
|
class TestMattermostPostMessage:
|
|
"""Tests for _post_message method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_message_success(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should return True on successful post."""
|
|
mock_http_client.expect_post(
|
|
url="/api/v4/posts",
|
|
response_status=201,
|
|
response_json={"id": "post_123"},
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
result = await mattermost_channel._post_message(
|
|
mm_channel_id="channel_123",
|
|
text="Hello World",
|
|
root_id="root_456",
|
|
)
|
|
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_message_failure(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should return False on API error."""
|
|
mock_http_client.expect_post(
|
|
url="/api/v4/posts",
|
|
response_status=403,
|
|
response_text="Forbidden",
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
result = await mattermost_channel._post_message(
|
|
mm_channel_id="channel_123",
|
|
text="Hello",
|
|
)
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_message_with_file_ids(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should include file_ids in payload when provided."""
|
|
mock_http_client.expect_post(
|
|
url="/api/v4/posts",
|
|
response_status=201,
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
await mattermost_channel._post_message(
|
|
mm_channel_id="channel_123",
|
|
text="",
|
|
root_id="",
|
|
file_ids=["file_1", "file_2"],
|
|
)
|
|
|
|
call_args = mock_http_client._requests[-1]
|
|
assert "file_ids" in call_args["kwargs"].get("json", {})
|
|
|
|
|
|
class TestMattermostFetchHistory:
|
|
"""Tests for history fetching methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_thread_history_success(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should fetch and format thread history."""
|
|
mock_http_client.expect_get(
|
|
url="/api/v4/posts/root_123/thread",
|
|
response_status=200,
|
|
response_json={
|
|
"order": ["post1", "post2", "post3"],
|
|
"posts": {
|
|
"post1": {
|
|
"id": "post1",
|
|
"user_id": "user_abc",
|
|
"message": "First message",
|
|
"create_at": 1000,
|
|
},
|
|
"post2": {
|
|
"id": "post2",
|
|
"user_id": "user_def",
|
|
"message": "Second message",
|
|
"create_at": 2000,
|
|
},
|
|
"post3": {
|
|
"id": "post3",
|
|
"user_id": "user_abc",
|
|
"message": "Third message",
|
|
"create_at": 3000,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
mattermost_channel._bot_id = "bot_123"
|
|
|
|
result = await mattermost_channel._fetch_thread_history(
|
|
root_id="root_123",
|
|
triggering_post_id="post3",
|
|
)
|
|
|
|
assert "[Thread history]" in result
|
|
assert "User: First message" in result
|
|
assert "User: Second message" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_thread_history_api_error(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should return empty string on API error."""
|
|
mock_http_client.expect_get(
|
|
url="/api/v4/posts/root_123/thread",
|
|
response_status=404,
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
result = await mattermost_channel._fetch_thread_history(
|
|
root_id="root_123",
|
|
)
|
|
|
|
assert result == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_channel_history_success(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should fetch and format channel history."""
|
|
mock_http_client.expect_get(
|
|
url="/api/v4/channels/channel_123/posts",
|
|
response_status=200,
|
|
response_json={
|
|
"order": ["post1", "post2"],
|
|
"posts": {
|
|
"post1": {
|
|
"id": "post1",
|
|
"user_id": "user_abc",
|
|
"message": "Hello",
|
|
},
|
|
"post2": {
|
|
"id": "post2",
|
|
"user_id": "bot_123",
|
|
"message": "Hi there",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
mattermost_channel._bot_id = "bot_123"
|
|
|
|
result = await mattermost_channel._fetch_channel_history(
|
|
mm_channel_id="channel_123",
|
|
per_page=10,
|
|
)
|
|
|
|
assert "[Recent 10 DM context messages]" in result
|
|
assert "User: Hello" in result
|
|
assert "Bot: Hi there" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_channel_history_api_error(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should return empty string on API error."""
|
|
mock_http_client.expect_get(
|
|
url="/api/v4/channels/channel_123/posts",
|
|
response_status=403,
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
result = await mattermost_channel._fetch_channel_history(
|
|
mm_channel_id="channel_123",
|
|
)
|
|
|
|
assert result == ""
|
|
|
|
|
|
# =============================================================================
|
|
# P1: Send Methods
|
|
# =============================================================================
|
|
|
|
|
|
class TestMattermostSend:
|
|
"""Tests for send and send_media methods."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_success(self, mattermost_channel, mock_http_client):
|
|
"""Should send text message successfully."""
|
|
mock_http_client.expect_post(
|
|
url="/api/v4/posts",
|
|
response_status=201,
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
await mattermost_channel.send(
|
|
to_handle="channel_123",
|
|
text="Hello World",
|
|
meta={"root_id": "root_456"},
|
|
)
|
|
|
|
call_args = mock_http_client._requests[-1]
|
|
assert call_args["kwargs"]["json"]["message"] == "Hello World"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_disabled_channel(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should do nothing when channel is disabled."""
|
|
mattermost_channel.enabled = False
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
await mattermost_channel.send(
|
|
to_handle="channel_123",
|
|
text="Hello",
|
|
)
|
|
|
|
assert mock_http_client.call_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_no_channel_id(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should do nothing when no channel ID."""
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
await mattermost_channel.send(
|
|
to_handle="",
|
|
text="Hello",
|
|
)
|
|
|
|
assert mock_http_client.call_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_chunks_long_text(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should split and send long text in chunks."""
|
|
mock_http_client.expect_post(
|
|
url="/api/v4/posts",
|
|
response_status=201,
|
|
)
|
|
mock_http_client.expect_post(
|
|
url="/api/v4/posts",
|
|
response_status=201,
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
long_text = "A" * 5000 # Exceeds MATTERMOST_POST_CHUNK_SIZE
|
|
await mattermost_channel.send(
|
|
to_handle="channel_123",
|
|
text=long_text,
|
|
)
|
|
|
|
assert mock_http_client.call_count >= 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_media_image(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
tmp_path: Path,
|
|
):
|
|
"""Should upload and send image."""
|
|
# Create a test image file
|
|
test_file = tmp_path / "test_image.png"
|
|
test_file.write_bytes(b"fake_image_data")
|
|
|
|
mock_http_client.expect_post(
|
|
url="/api/v4/files",
|
|
response_status=201,
|
|
response_json={"file_infos": [{"id": "file_123"}]},
|
|
)
|
|
mock_http_client.expect_post(
|
|
url="/api/v4/posts",
|
|
response_status=201,
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
from pineagents.app.channels.base import ImageContent, ContentType
|
|
|
|
part = ImageContent(type=ContentType.IMAGE, image_url=str(test_file))
|
|
|
|
await mattermost_channel.send_media(
|
|
to_handle="channel_123",
|
|
part=part,
|
|
meta={},
|
|
)
|
|
|
|
# Should upload file and then post
|
|
assert mock_http_client.call_count >= 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_media_file_not_found(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should handle missing file gracefully."""
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
from pineagents.app.channels.base import FileContent, ContentType
|
|
|
|
part = FileContent(
|
|
type=ContentType.FILE,
|
|
file_url="/nonexistent/file.txt",
|
|
)
|
|
|
|
await mattermost_channel.send_media(
|
|
to_handle="channel_123",
|
|
part=part,
|
|
meta={},
|
|
)
|
|
|
|
# Should not crash, file upload should fail but not raise
|
|
|
|
|
|
# =============================================================================
|
|
# P1: File Operations
|
|
# =============================================================================
|
|
|
|
|
|
class TestMattermostFileOperations:
|
|
"""Tests for file download and upload."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_success(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
tmp_path: Path,
|
|
):
|
|
"""Should upload file and return file_id."""
|
|
test_file = tmp_path / "upload.txt"
|
|
test_file.write_text("test content")
|
|
|
|
mock_http_client.expect_post(
|
|
url="/api/v4/files",
|
|
response_status=201,
|
|
response_json={"file_infos": [{"id": "file_abc"}]},
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
result = await mattermost_channel._upload_file(
|
|
mm_channel_id="channel_123",
|
|
local_path=str(test_file),
|
|
)
|
|
|
|
assert result == "file_abc"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_not_found(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should return None when file not found."""
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
result = await mattermost_channel._upload_file(
|
|
mm_channel_id="channel_123",
|
|
local_path="/nonexistent/file.txt",
|
|
)
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_file_api_error(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
tmp_path: Path,
|
|
):
|
|
"""Should return None on API error."""
|
|
test_file = tmp_path / "upload.txt"
|
|
test_file.write_text("test")
|
|
|
|
mock_http_client.expect_post(
|
|
url="/api/v4/files",
|
|
response_status=500,
|
|
response_text="Server Error",
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
result = await mattermost_channel._upload_file(
|
|
mm_channel_id="channel_123",
|
|
local_path=str(test_file),
|
|
)
|
|
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_file_success(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should download file and return local path."""
|
|
mock_http_client.expect_get(
|
|
url="/api/v4/files/file_123",
|
|
response_status=200,
|
|
response_text="file content",
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
result = await mattermost_channel._download_file(
|
|
file_id="file_123",
|
|
filename_hint="document.pdf",
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.endswith(".pdf")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_file_api_error(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should return None on API error."""
|
|
mock_http_client.expect_get(
|
|
url="/api/v4/files/file_123",
|
|
response_status=404,
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
result = await mattermost_channel._download_file(
|
|
file_id="file_123",
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
# =============================================================================
|
|
# P1: Typing Indicators
|
|
# =============================================================================
|
|
|
|
|
|
class TestMattermostTyping:
|
|
"""Tests for typing indicator functionality."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_typing_creates_task(self, mattermost_channel):
|
|
"""Should create typing task when started."""
|
|
mattermost_channel._bot_id = "bot_123"
|
|
mattermost_channel._typing_loop = AsyncMock()
|
|
|
|
mattermost_channel._start_typing("channel_123", "root_456")
|
|
|
|
assert "channel_123" in mattermost_channel._typing_tasks
|
|
task = mattermost_channel._typing_tasks["channel_123"]
|
|
assert task is not None
|
|
|
|
# Cleanup
|
|
mattermost_channel._stop_typing("channel_123")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_typing_cancels_task(self, mattermost_channel):
|
|
"""Should cancel typing task when stopped."""
|
|
mattermost_channel._bot_id = "bot_123"
|
|
|
|
# Create a typing task
|
|
async def mock_loop():
|
|
await asyncio.sleep(60)
|
|
|
|
task = asyncio.create_task(mock_loop())
|
|
mattermost_channel._typing_tasks["channel_123"] = task
|
|
|
|
mattermost_channel._stop_typing("channel_123")
|
|
|
|
# Give event loop a chance to process the cancellation
|
|
await asyncio.sleep(0)
|
|
|
|
assert "channel_123" not in mattermost_channel._typing_tasks
|
|
assert task.cancelled() or task.done()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_typing_loop_sends_request(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should send typing request."""
|
|
mock_http_client.expect_post(
|
|
url="/api/v4/users/bot_123/typing",
|
|
response_status=200,
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
mattermost_channel._bot_id = "bot_123"
|
|
|
|
# Run typing loop briefly
|
|
task = asyncio.create_task(
|
|
mattermost_channel._typing_loop("channel_123", "root_456"),
|
|
)
|
|
|
|
# Let it run once
|
|
await asyncio.sleep(0.1)
|
|
task.cancel()
|
|
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
# Should have made at least one typing request
|
|
assert mock_http_client.call_count >= 1
|
|
|
|
|
|
# =============================================================================
|
|
# P1: Message Event Handling
|
|
# =============================================================================
|
|
|
|
|
|
class TestMattermostIsTriggered:
|
|
"""Tests for _is_triggered method."""
|
|
|
|
def test_is_triggered_dm(self, mattermost_channel):
|
|
"""Should trigger on DM messages."""
|
|
mattermost_channel._bot_id = "bot_123"
|
|
mattermost_channel._bot_username = "testbot"
|
|
|
|
post = {
|
|
"user_id": "user_abc",
|
|
"message": "Hello",
|
|
"root_id": "",
|
|
}
|
|
|
|
result = mattermost_channel._is_triggered(post, channel_type="D")
|
|
|
|
assert result is True
|
|
|
|
def test_is_triggered_mention(self, mattermost_channel):
|
|
"""Should trigger on bot mention."""
|
|
mattermost_channel._bot_id = "bot_123"
|
|
mattermost_channel._bot_username = "testbot"
|
|
|
|
post = {
|
|
"user_id": "user_abc",
|
|
"message": "Hey @testbot help me",
|
|
"root_id": "",
|
|
}
|
|
|
|
result = mattermost_channel._is_triggered(post, channel_type="O")
|
|
|
|
assert result is True
|
|
|
|
def test_is_triggered_thread_follow(self, mattermost_channel):
|
|
"""Should trigger on thread participation."""
|
|
mattermost_channel._bot_id = "bot_123"
|
|
mattermost_channel._thread_follow = True
|
|
mattermost_channel._participated_threads["thread_abc"] = None
|
|
|
|
post = {
|
|
"user_id": "user_abc",
|
|
"message": "Hello",
|
|
"root_id": "thread_abc",
|
|
}
|
|
|
|
result = mattermost_channel._is_triggered(post, channel_type="O")
|
|
|
|
assert result is True
|
|
|
|
def test_is_triggered_skip_bot_message(self, mattermost_channel):
|
|
"""Should not trigger on bot's own messages."""
|
|
mattermost_channel._bot_id = "bot_123"
|
|
|
|
post = {
|
|
"user_id": "bot_123",
|
|
"message": "Hello",
|
|
}
|
|
|
|
result = mattermost_channel._is_triggered(post, channel_type="O")
|
|
|
|
assert result is False
|
|
|
|
def test_is_triggered_no_mention_not_dm(self, mattermost_channel):
|
|
"""Should not trigger without mention in non-DM."""
|
|
mattermost_channel._bot_id = "bot_123"
|
|
mattermost_channel._bot_username = "testbot"
|
|
|
|
post = {
|
|
"user_id": "user_abc",
|
|
"message": "Hello everyone",
|
|
"root_id": "",
|
|
}
|
|
|
|
result = mattermost_channel._is_triggered(post, channel_type="O")
|
|
|
|
assert result is False
|
|
|
|
|
|
class TestMattermostGetContextPrefix:
|
|
"""Tests for _get_context_prefix method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_context_prefix_first_dm(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should fetch channel history on first DM."""
|
|
mock_http_client.expect_get(
|
|
url="/api/v4/channels/dm_123/posts",
|
|
response_status=200,
|
|
response_json={
|
|
"order": ["post1"],
|
|
"posts": {
|
|
"post1": {
|
|
"user_id": "user_abc",
|
|
"message": "Previous message",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
mattermost_channel._bot_id = "bot_123"
|
|
|
|
result = await mattermost_channel._get_context_prefix(
|
|
session_id="mattermost_dm:dm_123",
|
|
mm_channel_id="dm_123",
|
|
original_root_id="",
|
|
post_id="post_123",
|
|
is_dm=True,
|
|
)
|
|
|
|
assert "Previous message" in result
|
|
assert "mattermost_dm:dm_123" in mattermost_channel._seen_sessions
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_context_prefix_thread(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should fetch thread history for threads."""
|
|
mock_http_client.expect_get(
|
|
url="/api/v4/posts/root_123/thread",
|
|
response_status=200,
|
|
response_json={
|
|
"order": ["post1"],
|
|
"posts": {
|
|
"post1": {
|
|
"user_id": "user_abc",
|
|
"message": "Thread message",
|
|
"create_at": 1000,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
mattermost_channel._bot_id = "bot_123"
|
|
|
|
result = await mattermost_channel._get_context_prefix(
|
|
session_id="mattermost_thread:root_123",
|
|
mm_channel_id="channel_abc",
|
|
original_root_id="root_123",
|
|
post_id="new_post",
|
|
is_dm=False,
|
|
)
|
|
|
|
assert "Thread message" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_context_prefix_cached_session(self, mattermost_channel):
|
|
"""Should return empty string for cached session."""
|
|
mattermost_channel._seen_sessions["mattermost_dm:dm_123"] = None
|
|
|
|
result = await mattermost_channel._get_context_prefix(
|
|
session_id="mattermost_dm:dm_123",
|
|
mm_channel_id="dm_123",
|
|
original_root_id="",
|
|
post_id="post_123",
|
|
is_dm=True,
|
|
)
|
|
|
|
assert result == ""
|
|
|
|
|
|
class TestMattermostProcessAttachments:
|
|
"""Tests for _process_attachments method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_attachments_image(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should process image attachment."""
|
|
mock_http_client.expect_get(
|
|
url="/api/v4/files/file_123",
|
|
response_status=200,
|
|
response_text="fake_image",
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
post = {
|
|
"file_ids": ["file_123"],
|
|
"metadata": {
|
|
"files": [{"id": "file_123", "name": "image.png"}],
|
|
},
|
|
}
|
|
|
|
parts = await mattermost_channel._process_attachments(post)
|
|
|
|
assert len(parts) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_attachments_document(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should process document attachment."""
|
|
mock_http_client.expect_get(
|
|
url="/api/v4/files/file_456",
|
|
response_status=200,
|
|
response_text="document content",
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
post = {
|
|
"file_ids": ["file_456"],
|
|
"metadata": {
|
|
"files": [{"id": "file_456", "name": "document.pdf"}],
|
|
},
|
|
}
|
|
|
|
parts = await mattermost_channel._process_attachments(post)
|
|
|
|
assert len(parts) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_attachments_download_failure(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should handle download failure gracefully."""
|
|
mock_http_client.expect_get(
|
|
url="/api/v4/files/file_789",
|
|
response_status=404,
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
post = {
|
|
"file_ids": ["file_789"],
|
|
"metadata": {
|
|
"files": [{"id": "file_789", "name": "missing.pdf"}],
|
|
},
|
|
}
|
|
|
|
parts = await mattermost_channel._process_attachments(post)
|
|
|
|
assert len(parts) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_attachments_empty(self, mattermost_channel):
|
|
"""Should handle post with no attachments."""
|
|
post = {"file_ids": []}
|
|
|
|
parts = await mattermost_channel._process_attachments(post)
|
|
|
|
assert len(parts) == 0
|
|
|
|
|
|
class TestMattermostOnPostedEvent:
|
|
"""Tests for _on_posted_event method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_posted_event_dm(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should handle DM event."""
|
|
mock_http_client.expect_get(
|
|
url="/api/v4/channels/dm_123/posts",
|
|
response_status=200,
|
|
response_json={"order": [], "posts": {}},
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
mattermost_channel._bot_id = "bot_123"
|
|
mattermost_channel._bot_username = "testbot"
|
|
mattermost_channel._enqueue = MagicMock()
|
|
|
|
event_data = {
|
|
"event": "posted",
|
|
"data": {
|
|
"channel_type": "D",
|
|
"post": json.dumps(
|
|
{
|
|
"id": "post_123",
|
|
"user_id": "user_abc",
|
|
"channel_id": "dm_123",
|
|
"message": "Hello bot",
|
|
"root_id": "",
|
|
},
|
|
),
|
|
},
|
|
}
|
|
|
|
await mattermost_channel._on_posted_event(event_data)
|
|
|
|
assert mattermost_channel._enqueue.called
|
|
call_args = mattermost_channel._enqueue.call_args[0][0]
|
|
assert call_args["sender_id"] == "user_abc"
|
|
assert call_args["meta"]["mm_channel_id"] == "dm_123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_posted_event_with_mention(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should handle event with bot mention."""
|
|
mock_http_client.expect_get(
|
|
url="/api/v4/channels/channel_123/posts",
|
|
response_status=200,
|
|
response_json={"order": [], "posts": {}},
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
mattermost_channel._bot_id = "bot_123"
|
|
mattermost_channel._bot_username = "testbot"
|
|
mattermost_channel._enqueue = MagicMock()
|
|
|
|
event_data = {
|
|
"event": "posted",
|
|
"data": {
|
|
"channel_type": "O",
|
|
"post": json.dumps(
|
|
{
|
|
"id": "post_123",
|
|
"user_id": "user_abc",
|
|
"channel_id": "channel_123",
|
|
"message": "Hey @testbot help",
|
|
"root_id": "",
|
|
},
|
|
),
|
|
},
|
|
}
|
|
|
|
await mattermost_channel._on_posted_event(event_data)
|
|
|
|
assert mattermost_channel._enqueue.called
|
|
# Should have cleaned the mention from text
|
|
call_args = mattermost_channel._enqueue.call_args[0][0]
|
|
content_text = str(call_args["content_parts"])
|
|
assert "@testbot" not in content_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_posted_event_not_triggered(self, mattermost_channel):
|
|
"""Should skip non-triggered events."""
|
|
mattermost_channel._enqueue = MagicMock()
|
|
mattermost_channel._bot_id = "bot_123"
|
|
mattermost_channel._bot_username = "testbot"
|
|
|
|
event_data = {
|
|
"event": "posted",
|
|
"data": {
|
|
"channel_type": "O",
|
|
"post": json.dumps(
|
|
{
|
|
"id": "post_123",
|
|
"user_id": "user_abc",
|
|
"channel_id": "channel_123",
|
|
"message": "Just a regular message", # No mention
|
|
"root_id": "",
|
|
},
|
|
),
|
|
},
|
|
}
|
|
|
|
await mattermost_channel._on_posted_event(event_data)
|
|
|
|
assert not mattermost_channel._enqueue.called
|
|
|
|
|
|
# =============================================================================
|
|
# P1: Text Chunking
|
|
# =============================================================================
|
|
|
|
|
|
class TestMattermostChunkText:
|
|
"""Tests for _chunk_text method."""
|
|
|
|
def test_chunk_text_short(self, mattermost_channel):
|
|
"""Should not chunk short text."""
|
|
text = "Short message"
|
|
|
|
chunks = mattermost_channel._chunk_text(text)
|
|
|
|
assert len(chunks) == 1
|
|
assert chunks[0] == text
|
|
|
|
def test_chunk_text_empty(self, mattermost_channel):
|
|
"""Should return empty list for empty text."""
|
|
chunks = mattermost_channel._chunk_text("")
|
|
|
|
assert chunks == []
|
|
|
|
def test_chunk_text_long(self, mattermost_channel):
|
|
"""Should chunk long text at boundaries."""
|
|
# Create text longer than MATTERMOST_POST_CHUNK_SIZE
|
|
from pineagents.app.channels.mattermost.channel import (
|
|
MATTERMOST_POST_CHUNK_SIZE,
|
|
)
|
|
|
|
text = "A" * (MATTERMOST_POST_CHUNK_SIZE + 1000)
|
|
|
|
chunks = mattermost_channel._chunk_text(text)
|
|
|
|
assert len(chunks) > 1
|
|
# Each chunk should be within limit
|
|
for chunk in chunks:
|
|
assert len(chunk) <= MATTERMOST_POST_CHUNK_SIZE
|
|
|
|
def test_chunk_text_breaks_at_newline(self, mattermost_channel):
|
|
"""Should prefer to break at newlines."""
|
|
from pineagents.app.channels.mattermost.channel import (
|
|
MATTERMOST_POST_CHUNK_SIZE,
|
|
)
|
|
|
|
# Create text with newlines over the limit
|
|
line = "A" * 100 + "\n"
|
|
repeats = (MATTERMOST_POST_CHUNK_SIZE // 100) + 5
|
|
text = line * repeats
|
|
|
|
chunks = mattermost_channel._chunk_text(text)
|
|
|
|
assert len(chunks) >= 1
|
|
# Chunks should prefer newline boundaries when possible
|
|
|
|
|
|
# =============================================================================
|
|
# P2: Lifecycle (Start/Stop)
|
|
# =============================================================================
|
|
|
|
|
|
class TestMattermostLifecycle:
|
|
"""Tests for channel lifecycle management."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_disabled_channel(self, mattermost_channel):
|
|
"""Starting disabled channel should succeed without action."""
|
|
mattermost_channel.enabled = False
|
|
|
|
await mattermost_channel.start()
|
|
|
|
assert mattermost_channel._task is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_creates_task(self, mattermost_channel):
|
|
"""Start should create websocket task."""
|
|
with patch.object(
|
|
mattermost_channel,
|
|
"_init_bot_info",
|
|
AsyncMock(return_value=True),
|
|
):
|
|
with patch.object(
|
|
mattermost_channel,
|
|
"_websocket_loop",
|
|
AsyncMock(),
|
|
):
|
|
await mattermost_channel.start()
|
|
|
|
assert mattermost_channel._task is not None
|
|
|
|
# Cancel task to clean up
|
|
mattermost_channel._task.cancel()
|
|
try:
|
|
await mattermost_channel._task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_disabled_channel(self, mattermost_channel):
|
|
"""Stopping disabled channel should succeed."""
|
|
mattermost_channel.enabled = False
|
|
|
|
await mattermost_channel.stop()
|
|
|
|
# Should not raise
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_cancels_task(self, mattermost_channel):
|
|
"""Stop should cancel running task."""
|
|
|
|
async def mock_task():
|
|
await asyncio.sleep(60)
|
|
|
|
mattermost_channel.enabled = True
|
|
mattermost_channel._task = asyncio.create_task(mock_task())
|
|
mattermost_channel._http = MockHttpxClient()
|
|
|
|
await mattermost_channel.stop()
|
|
|
|
assert mattermost_channel._task is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_closes_http(self, mattermost_channel):
|
|
"""Stop should close HTTP client."""
|
|
mock_http = MockHttpxClient()
|
|
mattermost_channel.enabled = True
|
|
mattermost_channel._http = mock_http
|
|
|
|
await mattermost_channel.stop()
|
|
|
|
assert mock_http.closed is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_typing_tasks(self, mattermost_channel):
|
|
"""Stop should cancel typing tasks."""
|
|
|
|
async def mock_typing():
|
|
await asyncio.sleep(60)
|
|
|
|
mattermost_channel.enabled = True
|
|
mattermost_channel._typing_tasks["channel_123"] = asyncio.create_task(
|
|
mock_typing(),
|
|
)
|
|
mattermost_channel._http = MockHttpxClient()
|
|
|
|
await mattermost_channel.stop()
|
|
|
|
assert "channel_123" not in mattermost_channel._typing_tasks
|
|
|
|
|
|
# =============================================================================
|
|
# P2: Allowlist Check
|
|
# =============================================================================
|
|
|
|
|
|
class TestMattermostAccessControl:
|
|
"""Tests for access control logic (inherited from BaseChannel)."""
|
|
|
|
def test_access_control_disabled_by_default(self, mattermost_channel):
|
|
"""Access control should be disabled by default."""
|
|
assert mattermost_channel.access_control_enabled is False
|
|
|
|
def test_access_control_dm_enables(self, mattermost_channel):
|
|
"""access_control_dm=True enables access control."""
|
|
mattermost_channel.access_control_dm = True
|
|
assert mattermost_channel.access_control_enabled is True
|
|
|
|
def test_access_control_group_enables(self, mattermost_channel):
|
|
"""access_control_group=True enables access control."""
|
|
mattermost_channel.access_control_group = True
|
|
assert mattermost_channel.access_control_enabled is True
|
|
|
|
def test_legacy_allowlist_migrates_to_dm(self, mattermost_channel):
|
|
"""dm_policy=allowlist should have migrated at init."""
|
|
# The fixture creates with dm_policy="open" by default
|
|
assert mattermost_channel.access_control_dm is False
|
|
|
|
|
|
# =============================================================================
|
|
# P2: Edge Cases
|
|
# =============================================================================
|
|
|
|
|
|
class TestMattermostEdgeCases:
|
|
"""Additional edge case tests."""
|
|
|
|
def test_default_media_dir(self, mock_process_handler):
|
|
"""Should use default media dir when not specified."""
|
|
from pineagents.app.channels.mattermost.channel import (
|
|
MattermostChannel,
|
|
_DEFAULT_MEDIA_DIR,
|
|
)
|
|
|
|
channel = MattermostChannel(
|
|
process=mock_process_handler,
|
|
enabled=True,
|
|
url="https://mm.example.com",
|
|
bot_token="token123",
|
|
)
|
|
|
|
assert channel._media_dir == _DEFAULT_MEDIA_DIR
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_init_bot_info_partial_response(
|
|
self,
|
|
mattermost_channel,
|
|
mock_http_client,
|
|
):
|
|
"""Should handle partial response from users/me."""
|
|
mock_http_client.expect_get(
|
|
url="/api/v4/users/me",
|
|
response_status=200,
|
|
response_json={"id": "user_123"}, # Missing username
|
|
)
|
|
mattermost_channel._http = mock_http_client
|
|
|
|
result = await mattermost_channel._init_bot_info()
|
|
|
|
assert result is True
|
|
assert mattermost_channel._bot_id == "user_123"
|
|
assert mattermost_channel._bot_username == ""
|
|
|
|
def test_get_thread_target_order_with_bot_replies(
|
|
self,
|
|
mattermost_channel,
|
|
):
|
|
"""Should correctly identify gap when bot has replied."""
|
|
mattermost_channel._bot_id = "bot_123"
|
|
|
|
order = ["post1", "post2", "post3", "post4"]
|
|
posts = {
|
|
"post1": {"user_id": "user_abc", "message": "Hello"},
|
|
"post2": {"user_id": "bot_123", "message": "Bot reply"},
|
|
"post3": {"user_id": "user_abc", "message": "Follow up"},
|
|
"post4": {"user_id": "user_abc", "message": "Another message"},
|
|
}
|
|
|
|
target_order, label = mattermost_channel._get_thread_target_order(
|
|
order,
|
|
posts,
|
|
last_bot_idx=1,
|
|
)
|
|
|
|
assert len(target_order) == 2
|
|
assert target_order == ["post3", "post4"]
|
|
assert "supplement" in label
|
|
|
|
def test_get_thread_target_order_first_time(self, mattermost_channel):
|
|
"""Should return full history when bot hasn't replied."""
|
|
order = ["post1", "post2"]
|
|
posts = {
|
|
"post1": {"user_id": "user_abc", "message": "Hello"},
|
|
"post2": {"user_id": "user_def", "message": "Hi"},
|
|
}
|
|
|
|
target_order, label = mattermost_channel._get_thread_target_order(
|
|
order,
|
|
posts,
|
|
last_bot_idx=-1,
|
|
)
|
|
|
|
assert target_order == order
|
|
assert "history" in label
|