Explorar o código

Fix the drifted tests, and the bug one of them was pointing at

MAX_MESSAGE_LENGTH: BasePlatformAdapter.max_message_length_for_chat()
reads the adapter-scalar MAX_MESSAGE_LENGTH and silently falls back to
4096 when it is absent — which it was. gateway/run.py and
stream_consumer.py chunk outgoing messages through that method, so
Chatto messages were split at 4096 although send() truncates at 10000
and register() advertises 10000. Declaring the class attribute makes all
three agree (verified: 10000 with it, 4096 without).

Test fixes:
- platform name: the tests expected "chatto", the plugin registers
  "chatto-platform". Assert against ChattoConstants.PLATFORM_NAME, and
  compare Platform.value — a dynamically created enum member upper-cases
  its .name.
- create_room/start_dm: the client was mocked with MagicMock, so
  awaiting it raised TypeError, which both methods swallow into a None
  return. AsyncMock plus a real chattolib Room; also assert the room
  name/kind caches get populated.
- supports_threads(): no such API exists, on this adapter or in the base
  class. Replaced by what threading actually depends on here — the
  auto_thread setting.
- _get_env_or_extra_str(): the test demanded None for a missing value,
  but the helper deliberately coerces to "" and _get_env_or_extra_str_opt
  is the variant that preserves None. Assert both.

Removed the tests for get_user/set_presence/set_custom_status: those are
not adapter responsibilities, callers use the chattolib client directly.
Left a note so they do not get "restored" later.

edit_message/delete_message are xfail(strict) rather than deleted: the
adapter does not override them, so the base class reports "not
supported" and the gateway sends a new message instead of editing —
while chattolib.update_message()/delete_message() do exist. Strict, so
the marker fails loudly once someone implements the overrides.

Also: HERMES_ROOT env var instead of a hardcoded /opt/hermes, so the
suite is runnable against a hermes-agent checkout.

38 passed, 2 xfailed.
Paul Klumpp hai 1 semana
pai
achega
e45491c898
Modificáronse 3 ficheiros con 78 adicións e 135 borrados
  1. 5 0
      adapter.py
  2. 59 132
      test_adapter.py
  3. 14 3
      test_platform_config.py

+ 5 - 0
adapter.py

@@ -107,6 +107,11 @@ class ChattoAdapter(BasePlatformAdapter):
 
 
     
     
     _SPLIT_THRESHOLD = 9900
     _SPLIT_THRESHOLD = 9900
+    # Read by BasePlatformAdapter.max_message_length_for_chat(), which the
+    # gateway and the stream consumer use to chunk outgoing messages. Without
+    # it they fall back to 4096 and split Chatto messages far earlier than
+    # necessary — send() itself already truncates at 10000.
+    MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
     splits_long_messages = True
     splits_long_messages = True
     supports_code_blocks: bool = True
     supports_code_blocks: bool = True
     supports_status_text: bool = True # client.update_custom_status
     supports_status_text: bool = True # client.update_custom_status

+ 59 - 132
test_adapter.py

@@ -25,9 +25,11 @@ import pytest_asyncio
 # Import chattolib types for tests - using vendored chattolib from adapter
 # Import chattolib types for tests - using vendored chattolib from adapter
 
 
 # -- Path setup --
 # -- Path setup --
+# The Hermes agent itself is not a dependency of this plugin; point HERMES_ROOT
+# at a checkout to run these tests outside a deployed agent.
 PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
 PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
 sys.path.insert(0, PLUGIN_ROOT)
 sys.path.insert(0, PLUGIN_ROOT)
-sys.path.insert(0, "/opt/hermes")
+sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes"))
 sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
 sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
 
 
 from adapter import (
 from adapter import (
@@ -36,6 +38,7 @@ from adapter import (
     hermes_validate_config as validate_config,
     hermes_validate_config as validate_config,
     register,
     register,
 )
 )
+from chattolib.types import Room, RoomKind
 from platform_config import ChattoConstants
 from platform_config import ChattoConstants
 from gateway.config import PlatformConfig
 from gateway.config import PlatformConfig
 from gateway.platforms.base import SendResult, MessageEvent, MessageType
 from gateway.platforms.base import SendResult, MessageEvent, MessageType
@@ -72,9 +75,9 @@ class _MockPluginContext:
 
 
 
 
 def _ensure_chatto_registered():
 def _ensure_chatto_registered():
-    """Register chatto in the platform registry so Platform('chatto') works."""
+    """Register the platform so Platform(PLATFORM_NAME) resolves."""
     from gateway.platform_registry import platform_registry
     from gateway.platform_registry import platform_registry
-    if not platform_registry.is_registered("chatto"):
+    if not platform_registry.is_registered(ChattoConstants.PLATFORM_NAME):
         ctx = _MockPluginContext()
         ctx = _MockPluginContext()
         register(ctx)
         register(ctx)
 
 
@@ -105,6 +108,12 @@ def _make_config(**extra_overrides):
     return PlatformConfig(enabled=True, extra=extra)
     return PlatformConfig(enabled=True, extra=extra)
 
 
 
 
+def _make_room(room_id, name, kind):
+    """Build a real chattolib Room, as the client would return."""
+    return Room(id=room_id, name=name, kind=kind, description="",
+                archived=False, group_id="", universal=kind != RoomKind.DM)
+
+
 def _make_adapter(**extra_overrides):
 def _make_adapter(**extra_overrides):
     """Create a ChattoAdapter with mocked config."""
     """Create a ChattoAdapter with mocked config."""
     _clear_chatto_env()
     _clear_chatto_env()
@@ -145,22 +154,30 @@ class TestAdapterInstantiation:
         cfg = _make_config()
         cfg = _make_config()
         adapter = ChattoAdapter(cfg)
         adapter = ChattoAdapter(cfg)
         assert adapter is not None
         assert adapter is not None
-        assert adapter.platform.name == "chatto"
+        # Platform members created dynamically from a plugin name carry the
+        # name upper-cased; the registered identity is the value.
+        assert adapter.platform.value == ChattoConstants.PLATFORM_NAME
 
 
     def test_adapter_max_message_length(self):
     def test_adapter_max_message_length(self):
+        """The framework chunks via max_message_length_for_chat(), which reads
+        the adapter-scalar MAX_MESSAGE_LENGTH and silently falls back to 4096
+        when it is missing."""
         cfg = _make_config()
         cfg = _make_config()
         adapter = ChattoAdapter(cfg)
         adapter = ChattoAdapter(cfg)
         assert adapter.MAX_MESSAGE_LENGTH == _MAX_MESSAGE_LENGTH
         assert adapter.MAX_MESSAGE_LENGTH == _MAX_MESSAGE_LENGTH
+        assert adapter.max_message_length_for_chat("room-1") == _MAX_MESSAGE_LENGTH
 
 
     def test_adapter_splits_long_messages(self):
     def test_adapter_splits_long_messages(self):
         cfg = _make_config()
         cfg = _make_config()
         adapter = ChattoAdapter(cfg)
         adapter = ChattoAdapter(cfg)
         assert adapter.splits_long_messages is True
         assert adapter.splits_long_messages is True
 
 
-    def test_adapter_supports_threads(self):
+    def test_adapter_threads_enabled_by_default(self):
+        """There is no capability flag for threads — Chatto threading is driven
+        by the auto_thread setting, which defaults to on."""
         cfg = _make_config()
         cfg = _make_config()
         adapter = ChattoAdapter(cfg)
         adapter = ChattoAdapter(cfg)
-        assert adapter.supports_threads() is True
+        assert adapter.chatto_config.auto_thread.value is True
 
 
 
 
 # -- Registration and requirements --
 # -- Registration and requirements --
@@ -171,9 +188,10 @@ class TestRegistration:
     def test_register_called(self):
     def test_register_called(self):
         ctx = _MockPluginContext()
         ctx = _MockPluginContext()
         register(ctx)
         register(ctx)
-        assert "chatto" in ctx.registered_names
-        assert ctx.registered_kwargs["name"] == "chatto"
-        assert ctx.registered_kwargs["label"] == "Chatto"
+        assert ChattoConstants.PLATFORM_NAME in ctx.registered_names
+        assert ctx.registered_kwargs["name"] == ChattoConstants.PLATFORM_NAME
+        assert ctx.registered_kwargs["label"] == ChattoConstants.PLATFORM_LABEL
+        assert ctx.registered_kwargs["max_message_length"] == _MAX_MESSAGE_LENGTH
 
 
     def test_check_requirements(self):
     def test_check_requirements(self):
         assert check_requirements() is True
         assert check_requirements() is True
@@ -297,115 +315,35 @@ class TestMessageEditing:
         adapter._token = "test-token"
         adapter._token = "test-token"
         return adapter
         return adapter
 
 
+    @pytest.mark.xfail(
+        strict=True,
+        reason="ChattoAdapter does not override edit_message yet, so the base "
+               "class reports 'Not supported' and callers send a new message "
+               "instead of editing. chattolib.update_message() exists — drop "
+               "this marker once the override lands.",
+    )
     async def test_edit_message(self, adapter):
     async def test_edit_message(self, adapter):
         result = await adapter.edit_message("room-1", "msg-1", "New content")
         result = await adapter.edit_message("room-1", "msg-1", "New content")
-        assert result is True
+        assert result.success is True
         adapter._chatto_client.update_message.assert_called_once()
         adapter._chatto_client.update_message.assert_called_once()
 
 
+    @pytest.mark.xfail(
+        strict=True,
+        reason="ChattoAdapter does not override delete_message yet, so the base "
+               "class returns False. chattolib.delete_message() exists — drop "
+               "this marker once the override lands.",
+    )
     async def test_delete_message(self, adapter):
     async def test_delete_message(self, adapter):
         result = await adapter.delete_message("room-1", "msg-1")
         result = await adapter.delete_message("room-1", "msg-1")
         assert result is True
         assert result is True
         adapter._chatto_client.delete_message.assert_called_once()
         adapter._chatto_client.delete_message.assert_called_once()
 
 
 
 
-# -- User lookup --
-
-class TestUserLookup:
-    """Test user lookup functionality."""
-
-    @pytest_asyncio.fixture
-    def adapter(self):
-        _clear_chatto_env()
-        cfg = _make_config()
-        adapter = ChattoAdapter(cfg)
-        adapter._chatto_client = MagicMock()
-        adapter._token = "test-token"
-        adapter._user_cache = {}
-        return adapter
-
-    async def test_get_user_calls_chattolib(self, adapter):
-        # Import chattolib types for testing (try vendored first)
-        try:
-            from chattolib_vendor.chattolib.types import User, GetUserResponse
-        except ImportError:
-            try:
-                from chattolib.types import User, GetUserResponse
-            except ImportError:
-                # Fallback to mocked types if chattolib not installed
-                from unittest.mock import MagicMock
-                User = MagicMock
-                GetUserResponse = MagicMock
-        mock_user = User(id="user-1", login="testuser", display_name="Test User")
-        adapter._chatto_client.get_user.return_value = GetUserResponse(user=mock_user)
-        result = await adapter.get_user("user-1")
-        assert result is not None
-        assert result["id"] == "user-1"
-        assert result["login"] == "testuser"
-
-    async def test_get_user_caching(self, adapter):
-        # Import chattolib types for testing (try vendored first)
-        try:
-            from chattolib_vendor.chattolib.types import User, GetUserResponse
-        except ImportError:
-            try:
-                from chattolib.types import User, GetUserResponse
-            except ImportError:
-                # Fallback to mocked types if chattolib not installed
-                from unittest.mock import MagicMock
-                User = MagicMock
-                GetUserResponse = MagicMock
-        mock_user = User(id="user-1", login="testuser", display_name="Test User")
-        adapter._chatto_client.get_user.return_value = GetUserResponse(user=mock_user)
-        result1 = await adapter.get_user("user-1")
-        result2 = await adapter.get_user("user-1")
-        assert result1 == result2
-        assert adapter._chatto_client.get_user.call_count == 1
-
-
-# -- Presence and Custom Status --
-
-class TestPresence:
-    """Test presence functionality."""
-
-    @pytest_asyncio.fixture
-    def adapter(self):
-        _clear_chatto_env()
-        cfg = _make_config()
-        adapter = ChattoAdapter(cfg)
-        adapter._chatto_client = MagicMock()
-        adapter._chatto_client.update_presence = AsyncMock()
-        adapter._token = "test-token"
-        return adapter
-
-    async def test_set_presence(self, adapter):
-        result = await adapter.set_presence("online")
-        assert result is True
-        adapter._chatto_client.update_presence.assert_called_once()
-
-
-class TestCustomStatus:
-    """Test custom status functionality."""
-
-    @pytest_asyncio.fixture
-    def adapter(self):
-        _clear_chatto_env()
-        cfg = _make_config()
-        adapter = ChattoAdapter(cfg)
-        adapter._chatto_client = MagicMock()
-        adapter._chatto_client.update_custom_status = AsyncMock()
-        adapter._chatto_client.delete_custom_status = AsyncMock()
-        adapter._token = "test-token"
-        return adapter
-
-    async def test_set_custom_status(self, adapter):
-        result = await adapter.set_custom_status("Processing...")
-        assert result is True
-        adapter._chatto_client.update_custom_status.assert_called_once()
-
-    async def test_clear_custom_status(self, adapter):
-        result = await adapter.clear_custom_status()
-        assert result is True
-        adapter._chatto_client.delete_custom_status.assert_called_once()
+# NOTE: there are deliberately no tests for get_user(), set_presence() or
+# set_custom_status() on the adapter. Those are not adapter responsibilities —
+# callers use the chattolib client directly, which exposes them (client.get_user,
+# client.update_presence, client.update_custom_status). The adapter only touches
+# presence in connect()/disconnect().
 
 
 
 
 # -- Room operations --
 # -- Room operations --
@@ -419,44 +357,33 @@ class TestRoomOperations:
         cfg = _make_config()
         cfg = _make_config()
         adapter = ChattoAdapter(cfg)
         adapter = ChattoAdapter(cfg)
         adapter._chatto_client = MagicMock()
         adapter._chatto_client = MagicMock()
+        # AsyncMock, not MagicMock: the adapter awaits these, and awaiting a
+        # plain MagicMock raises TypeError, which create_room()/start_dm()
+        # swallow into a None return.
+        adapter._chatto_client.create_room = AsyncMock()
+        adapter._chatto_client.start_dm = AsyncMock()
         adapter._token = "test-token"
         adapter._token = "test-token"
         adapter._room_names = {}
         adapter._room_names = {}
         adapter._room_kinds = {}
         adapter._room_kinds = {}
         return adapter
         return adapter
 
 
     async def test_create_room(self, adapter):
     async def test_create_room(self, adapter):
-        # Import chattolib types for testing (try vendored first)
-        try:
-            from chattolib_vendor.chattolib.types import Room
-        except ImportError:
-            try:
-                from chattolib.types import Room
-            except ImportError:
-                from unittest.mock import MagicMock
-                Room = MagicMock
-        mock_room = Room(id="room-123", name="Test Room", kind="ROOM_KIND_GROUP",
-                        description="", archived=False, group_id="", universal=True)
-        adapter._chatto_client.create_room.return_value = mock_room
+        adapter._chatto_client.create_room.return_value = _make_room(
+            "room-123", "Test Room", RoomKind.CHANNEL,
+        )
         result = await adapter.create_room("Test Room", "A test room")
         result = await adapter.create_room("Test Room", "A test room")
         assert result == "room-123"
         assert result == "room-123"
         adapter._chatto_client.create_room.assert_called_once()
         adapter._chatto_client.create_room.assert_called_once()
+        assert adapter._room_names["room-123"] == "Test Room"
 
 
     async def test_start_dm(self, adapter):
     async def test_start_dm(self, adapter):
-        # Import chattolib types for testing (try vendored first)
-        try:
-            from chattolib_vendor.chattolib.types import Room
-        except ImportError:
-            try:
-                from chattolib.types import Room
-            except ImportError:
-                from unittest.mock import MagicMock
-                Room = MagicMock
-        mock_room = Room(id="dm-123", name="DM with user", kind="ROOM_KIND_DM",
-                        description="", archived=False, group_id="", universal=False)
-        adapter._chatto_client.start_dm.return_value = mock_room
+        adapter._chatto_client.start_dm.return_value = _make_room(
+            "dm-123", "DM with user", RoomKind.DM,
+        )
         result = await adapter.start_dm("user-123")
         result = await adapter.start_dm("user-123")
         assert result == "dm-123"
         assert result == "dm-123"
         adapter._chatto_client.start_dm.assert_called_once()
         adapter._chatto_client.start_dm.assert_called_once()
+        assert adapter._room_kinds["dm-123"] == RoomKind.DM
 
 
 
 
 # -- Constants --
 # -- Constants --

+ 14 - 3
test_platform_config.py

@@ -6,11 +6,12 @@ import pytest
 
 
 PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
 PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
 sys.path.insert(0, PLUGIN_ROOT)
 sys.path.insert(0, PLUGIN_ROOT)
-sys.path.insert(0, "/opt/hermes")
+sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes"))
 sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
 sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
 
 
 from platform_config import (
 from platform_config import (
     _get_env_or_extra_str,
     _get_env_or_extra_str,
+    _get_env_or_extra_str_opt,
     _get_env_or_extra_truthy,
     _get_env_or_extra_truthy,
     _split_str_to_list,
     _split_str_to_list,
     _get_env_or_extra_list,
     _get_env_or_extra_list,
@@ -28,9 +29,19 @@ class TestPlatformConfigHelpers:
         monkeypatch.delenv("CHATTO_TEST", raising=False)
         monkeypatch.delenv("CHATTO_TEST", raising=False)
         assert _get_env_or_extra_str("CHATTO_TEST", " extra-value ") == "extra-value"
         assert _get_env_or_extra_str("CHATTO_TEST", " extra-value ") == "extra-value"
 
 
-    def test_get_env_or_extra_str_returns_none_when_missing(self, monkeypatch):
+    def test_get_env_or_extra_str_returns_empty_when_missing(self, monkeypatch):
+        """The non-optional helper coerces a missing value to "" — use
+        _get_env_or_extra_str_opt() when None has to stay distinguishable."""
         monkeypatch.delenv("CHATTO_TEST", raising=False)
         monkeypatch.delenv("CHATTO_TEST", raising=False)
-        assert _get_env_or_extra_str("CHATTO_TEST", None) is None
+        assert _get_env_or_extra_str("CHATTO_TEST", None) == ""
+
+    def test_get_env_or_extra_str_opt_returns_none_when_missing(self, monkeypatch):
+        monkeypatch.delenv("CHATTO_TEST", raising=False)
+        assert _get_env_or_extra_str_opt("CHATTO_TEST", None) is None
+
+    def test_get_env_or_extra_str_opt_prefers_env(self, monkeypatch):
+        monkeypatch.setenv("CHATTO_TEST", " env-value ")
+        assert _get_env_or_extra_str_opt("CHATTO_TEST", "extra-value") == "env-value"
 
 
     def test_get_env_or_extra_truthy_parses_truthy_values(self, monkeypatch):
     def test_get_env_or_extra_truthy_parses_truthy_values(self, monkeypatch):
         monkeypatch.setenv("CHATTO_TEST", " yes ")
         monkeypatch.setenv("CHATTO_TEST", " yes ")