219 lines
7.7 KiB
Python
219 lines
7.7 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import time
|
||
|
|
from unittest.mock import AsyncMock, MagicMock
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from yuxi.channels.adapters.mattermost.adapter import MattermostAdapter
|
||
|
|
from yuxi.channels.adapters.mattermost.cache import LRUCache, TTLCache
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def adapter():
|
||
|
|
config = {
|
||
|
|
"bot_token": "test-token",
|
||
|
|
"server_url": "https://mattermost.example.com",
|
||
|
|
}
|
||
|
|
return MattermostAdapter(config=config)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def mock_driver():
|
||
|
|
driver = MagicMock()
|
||
|
|
driver.posts = MagicMock()
|
||
|
|
driver.posts.create_post = MagicMock(return_value={"id": "msg_001"})
|
||
|
|
driver.posts.update_post = AsyncMock()
|
||
|
|
driver.posts.delete_post = AsyncMock()
|
||
|
|
driver.posts.pin_post = AsyncMock()
|
||
|
|
driver.posts.unpin_post = AsyncMock()
|
||
|
|
driver.posts.get_pinned_posts = AsyncMock(return_value=[])
|
||
|
|
driver.posts.search_posts = AsyncMock(return_value={"posts": []})
|
||
|
|
driver.posts.get_thread = AsyncMock(return_value={"posts": []})
|
||
|
|
driver.reactions = MagicMock()
|
||
|
|
driver.reactions.create_reaction = AsyncMock()
|
||
|
|
driver.reactions.remove_reaction = AsyncMock()
|
||
|
|
driver.reactions.get_reactions = AsyncMock(return_value=[])
|
||
|
|
driver.users = MagicMock()
|
||
|
|
driver.users.create_user_typing = AsyncMock()
|
||
|
|
return driver
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def connected_adapter(adapter, mock_driver):
|
||
|
|
adapter._status = adapter._status.__class__.CONNECTED
|
||
|
|
adapter._drivers["default"] = mock_driver
|
||
|
|
adapter._bot_user_id = "bot_001"
|
||
|
|
adapter._bot_username = "testbot"
|
||
|
|
adapter._server_url = "https://mattermost.example.com"
|
||
|
|
return adapter
|
||
|
|
|
||
|
|
|
||
|
|
class TestMattermostOperations:
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_pin_message(self, connected_adapter):
|
||
|
|
result = await connected_adapter.pin_message("post_001")
|
||
|
|
assert result is True
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_unpin_message(self, connected_adapter):
|
||
|
|
result = await connected_adapter.unpin_message("post_001")
|
||
|
|
assert result is True
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_list_pinned_messages(self, connected_adapter, mock_driver):
|
||
|
|
mock_driver.posts.get_pinned_posts.return_value = [
|
||
|
|
{"id": "p1", "message": "pinned post"}
|
||
|
|
]
|
||
|
|
result = await connected_adapter.list_pinned_messages("channel_001")
|
||
|
|
assert len(result) == 1
|
||
|
|
assert result[0]["id"] == "p1"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_get_reactions(self, connected_adapter, mock_driver):
|
||
|
|
mock_driver.reactions.get_reactions.return_value = [
|
||
|
|
{"emoji_name": "thumbsup", "user_id": "u1"}
|
||
|
|
]
|
||
|
|
result = await connected_adapter.get_reactions("post_001")
|
||
|
|
assert len(result) == 1
|
||
|
|
assert result[0]["emoji_name"] == "thumbsup"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_search_messages(self, connected_adapter, mock_driver):
|
||
|
|
mock_driver.posts.search_posts.return_value = {
|
||
|
|
"posts": [{"id": "p1", "message": "hello world"}]
|
||
|
|
}
|
||
|
|
result = await connected_adapter.search_messages("team_001", "hello")
|
||
|
|
assert len(result) == 1
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_get_thread_replies(self, connected_adapter, mock_driver):
|
||
|
|
mock_driver.posts.get_thread.return_value = {
|
||
|
|
"posts": [{"id": "p1"}, {"id": "p2"}]
|
||
|
|
}
|
||
|
|
result = await connected_adapter.get_thread_replies("post_001")
|
||
|
|
assert len(result) == 2
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_pin_message_not_connected(self, adapter):
|
||
|
|
assert await adapter.pin_message("post_001") is False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_unpin_message_not_connected(self, adapter):
|
||
|
|
assert await adapter.unpin_message("post_001") is False
|
||
|
|
|
||
|
|
|
||
|
|
class TestMattermostSupportsAction:
|
||
|
|
def test_supports_pin_unpin(self, adapter):
|
||
|
|
assert adapter.supports_action("pin") is True
|
||
|
|
assert adapter.supports_action("unpin") is True
|
||
|
|
|
||
|
|
def test_supports_existing_actions(self, adapter):
|
||
|
|
assert adapter.supports_action("send") is True
|
||
|
|
assert adapter.supports_action("react") is True
|
||
|
|
assert adapter.supports_action("edit") is True
|
||
|
|
assert adapter.supports_action("delete") is True
|
||
|
|
|
||
|
|
def test_does_not_support_unknown(self, adapter):
|
||
|
|
assert adapter.supports_action("unknown_action") is False
|
||
|
|
|
||
|
|
|
||
|
|
class TestCache:
|
||
|
|
def test_ttlcache_get_set(self):
|
||
|
|
cache = TTLCache(ttl_s=300, max_size=100)
|
||
|
|
cache.set("key1", "value1")
|
||
|
|
assert cache.get("key1") == "value1"
|
||
|
|
|
||
|
|
def test_ttlcache_delete(self):
|
||
|
|
cache = TTLCache(ttl_s=300, max_size=100)
|
||
|
|
cache.set("key1", "value1")
|
||
|
|
assert cache.delete("key1") is True
|
||
|
|
assert cache.get("key1") is None
|
||
|
|
assert cache.delete("nonexistent") is False
|
||
|
|
|
||
|
|
def test_lrucache_eviction(self):
|
||
|
|
cache = LRUCache(max_size=2, ttl_s=300)
|
||
|
|
cache.set("a", 1)
|
||
|
|
cache.set("b", 2)
|
||
|
|
cache.set("c", 3)
|
||
|
|
assert cache.size() == 2
|
||
|
|
assert cache.get("a") is None
|
||
|
|
assert cache.get("b") == 2
|
||
|
|
assert cache.get("c") == 3
|
||
|
|
|
||
|
|
|
||
|
|
class TestMattermostCapabilities:
|
||
|
|
def test_pin_capabilities_enabled(self, adapter):
|
||
|
|
assert adapter.capabilities.pin is True
|
||
|
|
assert adapter.capabilities.unpin is True
|
||
|
|
assert adapter.capabilities.list_pins is True
|
||
|
|
|
||
|
|
def test_ephemeral_capability_enabled(self, adapter):
|
||
|
|
assert adapter.capabilities.send_ephemeral is True
|
||
|
|
|
||
|
|
def test_typing_capability_enabled(self, adapter):
|
||
|
|
assert adapter.capabilities.typing is True
|
||
|
|
|
||
|
|
|
||
|
|
class TestMattermostWSConfig:
|
||
|
|
def test_default_ws_config(self):
|
||
|
|
adapter = MattermostAdapter(config={})
|
||
|
|
assert adapter._ws_ping_interval_s == 30.0
|
||
|
|
assert adapter._ws_pong_timeout_s == 10.0
|
||
|
|
assert adapter._ws_reconnect_jitter == 0.2
|
||
|
|
assert adapter._ws_max_reconnect_attempts == 10
|
||
|
|
assert adapter._ws_reconnect_max_delay_s == 120.0
|
||
|
|
|
||
|
|
def test_custom_ws_config(self):
|
||
|
|
adapter = MattermostAdapter(
|
||
|
|
config={
|
||
|
|
"bot_token": "test-token",
|
||
|
|
"server_url": "https://mattermost.example.com",
|
||
|
|
"ws_ping_interval_s": 15.0,
|
||
|
|
"ws_pong_timeout_s": 5.0,
|
||
|
|
"ws_reconnect_jitter": 0.5,
|
||
|
|
"ws_max_reconnect_attempts": 5,
|
||
|
|
"ws_reconnect_max_delay_s": 60.0,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
assert adapter._ws_ping_interval_s == 15.0
|
||
|
|
assert adapter._ws_pong_timeout_s == 5.0
|
||
|
|
assert adapter._ws_reconnect_jitter == 0.5
|
||
|
|
assert adapter._ws_max_reconnect_attempts == 5
|
||
|
|
assert adapter._ws_reconnect_max_delay_s == 60.0
|
||
|
|
|
||
|
|
|
||
|
|
class TestDedupObservability:
|
||
|
|
def test_dedup_counters_initialized(self, adapter):
|
||
|
|
assert adapter._dedup_hit_count == 0
|
||
|
|
assert adapter._dedup_total_count == 0
|
||
|
|
|
||
|
|
def test_dedup_total_increments(self, adapter):
|
||
|
|
adapter._seen_posts["post_1"] = time.monotonic()
|
||
|
|
adapter._dedup_check("post_2")
|
||
|
|
assert adapter._dedup_total_count == 1
|
||
|
|
|
||
|
|
def test_dedup_hit_increments(self, adapter):
|
||
|
|
adapter._seen_posts["post_1"] = time.monotonic()
|
||
|
|
adapter._dedup_check("post_1")
|
||
|
|
assert adapter._dedup_hit_count == 1
|
||
|
|
assert adapter._dedup_total_count == 1
|
||
|
|
|
||
|
|
|
||
|
|
class TestDedupSnapshot:
|
||
|
|
def test_snapshot_includes_dedup_stats(self, adapter):
|
||
|
|
adapter._dedup_total_count = 10
|
||
|
|
adapter._dedup_hit_count = 3
|
||
|
|
snapshot = adapter.snapshot()
|
||
|
|
assert snapshot["dedup_hit_count"] == 3
|
||
|
|
assert snapshot["dedup_total_count"] == 10
|
||
|
|
assert snapshot["dedup_hit_rate"] == 0.3
|
||
|
|
|
||
|
|
def test_snapshot_dedup_zero_division(self, adapter):
|
||
|
|
snapshot = adapter.snapshot()
|
||
|
|
assert snapshot["dedup_hit_rate"] == 0.0
|
||
|
|
|
||
|
|
def test_snapshot_includes_ws_config(self, adapter):
|
||
|
|
snapshot = adapter.snapshot()
|
||
|
|
assert "ws_ping_interval_s" in snapshot
|
||
|
|
assert "ws_pong_timeout_s" in snapshot
|