|
|
@@ -16,9 +16,7 @@ All network calls are mocked — no real HTTP or WebSocket connections.
|
|
|
import asyncio
|
|
|
import os
|
|
|
import sys
|
|
|
-import tempfile
|
|
|
-from unittest.mock import AsyncMock, MagicMock, patch, call
|
|
|
-from collections import OrderedDict
|
|
|
+from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
|
import pytest
|
|
|
import pytest_asyncio
|
|
|
@@ -31,23 +29,20 @@ import pytest_asyncio
|
|
|
PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
|
|
|
sys.path.insert(0, PLUGIN_ROOT)
|
|
|
sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes"))
|
|
|
-sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
|
|
|
|
|
|
-from adapter import (
|
|
|
- ChattoAdapter,
|
|
|
- _capabilities,
|
|
|
- HermesChatType,
|
|
|
- chat_type_for_room_kind,
|
|
|
- hermes_check_fn as check_requirements,
|
|
|
- hermes_validate_config as validate_config,
|
|
|
- register,
|
|
|
-)
|
|
|
+# Importing ``adapter`` puts the vendored dependencies on sys.path as a side
|
|
|
+# effect, but import sorting may legally move that import after the chattolib
|
|
|
+# and gateway ones — so bootstrap the vendor paths explicitly instead.
|
|
|
+from vendor_path import setup_vendor_path
|
|
|
+
|
|
|
+setup_vendor_path()
|
|
|
+
|
|
|
from chattolib.realtime_types import ReactionPayload
|
|
|
from chattolib.types import (
|
|
|
Asset,
|
|
|
- DirectoryMember,
|
|
|
AssetUpload,
|
|
|
AssetUrl,
|
|
|
+ DirectoryMember,
|
|
|
Message,
|
|
|
MessageAttachment,
|
|
|
PresenceStatus,
|
|
|
@@ -57,17 +52,30 @@ from chattolib.types import (
|
|
|
RoomWithViewerState,
|
|
|
User,
|
|
|
)
|
|
|
-from platform_config import ChattoConstants
|
|
|
from gateway.config import PlatformConfig
|
|
|
from gateway.platforms.base import (
|
|
|
BasePlatformAdapter,
|
|
|
CachedMedia,
|
|
|
- MessageEvent,
|
|
|
MessageType,
|
|
|
SendResult,
|
|
|
get_inbound_media_max_bytes,
|
|
|
)
|
|
|
|
|
|
+from adapter import (
|
|
|
+ ChattoAdapter,
|
|
|
+ HermesChatType,
|
|
|
+ _capabilities,
|
|
|
+ chat_type_for_room_kind,
|
|
|
+ register,
|
|
|
+)
|
|
|
+from adapter import (
|
|
|
+ hermes_check_fn as check_requirements,
|
|
|
+)
|
|
|
+from adapter import (
|
|
|
+ hermes_validate_config as validate_config,
|
|
|
+)
|
|
|
+from platform_config import ChattoConstants
|
|
|
+
|
|
|
_EMOJI_TO_SHORTCODE = ChattoConstants.EMOJI_TO_SHORTCODE
|
|
|
_MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
|
|
|
_SEEN_CAP = ChattoConstants.SEEN_CAP
|
|
|
@@ -75,6 +83,7 @@ _SEEN_CAP = ChattoConstants.SEEN_CAP
|
|
|
|
|
|
# -- Helpers --
|
|
|
|
|
|
+
|
|
|
class _MockPluginContext:
|
|
|
"""Minimal mock for the plugin registration context."""
|
|
|
|
|
|
@@ -83,7 +92,8 @@ class _MockPluginContext:
|
|
|
self.registered_kwargs = None
|
|
|
|
|
|
def register_platform(self, **kwargs):
|
|
|
- from gateway.platform_registry import platform_registry, PlatformEntry
|
|
|
+ from gateway.platform_registry import PlatformEntry, platform_registry
|
|
|
+
|
|
|
entry = PlatformEntry(
|
|
|
name=kwargs["name"],
|
|
|
label=kwargs.get("label", kwargs["name"]),
|
|
|
@@ -102,16 +112,21 @@ class _MockPluginContext:
|
|
|
def _ensure_chatto_registered():
|
|
|
"""Register the platform so Platform(PLATFORM_NAME) resolves."""
|
|
|
from gateway.platform_registry import platform_registry
|
|
|
+
|
|
|
if not platform_registry.is_registered(ChattoConstants.PLATFORM_NAME):
|
|
|
ctx = _MockPluginContext()
|
|
|
register(ctx)
|
|
|
|
|
|
|
|
|
_CHATTO_ENV_KEYS = [
|
|
|
- "CHATTO_BASE_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD",
|
|
|
+ "CHATTO_BASE_URL",
|
|
|
+ "CHATTO_LOGIN",
|
|
|
+ "CHATTO_PASSWORD",
|
|
|
"CHATTO_HOME_CHANNEL",
|
|
|
- "CHATTO_REQUIRE_MENTION", "CHATTO_ALLOWED_USERS",
|
|
|
- "CHATTO_ALLOW_ALL_USERS", "CHATTO_AUTO_THREAD",
|
|
|
+ "CHATTO_REQUIRE_MENTION",
|
|
|
+ "CHATTO_ALLOWED_USERS",
|
|
|
+ "CHATTO_ALLOW_ALL_USERS",
|
|
|
+ "CHATTO_AUTO_THREAD",
|
|
|
"CHATTO_REACTIONS",
|
|
|
]
|
|
|
|
|
|
@@ -135,8 +150,15 @@ def _make_config(**extra_overrides):
|
|
|
|
|
|
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)
|
|
|
+ return Room(
|
|
|
+ id=room_id,
|
|
|
+ name=name,
|
|
|
+ kind=kind,
|
|
|
+ description="",
|
|
|
+ archived=False,
|
|
|
+ group_id="",
|
|
|
+ universal=kind != RoomKind.DM,
|
|
|
+ )
|
|
|
|
|
|
|
|
|
def _make_user(user_id, login):
|
|
|
@@ -195,6 +217,7 @@ def _make_adapter(**extra_overrides):
|
|
|
|
|
|
# -- Emoji shortcode conversion --
|
|
|
|
|
|
+
|
|
|
class TestEmojiShortcode:
|
|
|
"""Test emoji to shortcode mapping."""
|
|
|
|
|
|
@@ -216,6 +239,7 @@ class TestEmojiShortcode:
|
|
|
|
|
|
# -- Adapter instantiation and properties --
|
|
|
|
|
|
+
|
|
|
class TestAdapterInstantiation:
|
|
|
"""Test ChattoAdapter creation and basic properties."""
|
|
|
|
|
|
@@ -251,6 +275,7 @@ class TestAdapterInstantiation:
|
|
|
|
|
|
# -- Registration and requirements --
|
|
|
|
|
|
+
|
|
|
class TestRegistration:
|
|
|
"""Test plugin registration."""
|
|
|
|
|
|
@@ -310,6 +335,7 @@ class TestRegistration:
|
|
|
|
|
|
# -- Send functionality --
|
|
|
|
|
|
+
|
|
|
class TestSend:
|
|
|
"""Test message sending functionality."""
|
|
|
|
|
|
@@ -356,6 +382,7 @@ class TestSend:
|
|
|
|
|
|
# -- Reactions --
|
|
|
|
|
|
+
|
|
|
class TestReactions:
|
|
|
"""Test reaction functionality."""
|
|
|
|
|
|
@@ -410,6 +437,7 @@ class TestReactions:
|
|
|
|
|
|
# -- Edit and Delete Messages --
|
|
|
|
|
|
+
|
|
|
class TestMessageEditing:
|
|
|
"""Test message editing and deletion."""
|
|
|
|
|
|
@@ -445,7 +473,9 @@ class TestMessageEditing:
|
|
|
"""Overlong content must fall back to send() (which splits), not be
|
|
|
silently truncated into a lossy edit."""
|
|
|
result = await adapter.edit_message(
|
|
|
- "room-1", "msg-1", "x" * (_MAX_MESSAGE_LENGTH + 1),
|
|
|
+ "room-1",
|
|
|
+ "msg-1",
|
|
|
+ "x" * (_MAX_MESSAGE_LENGTH + 1),
|
|
|
)
|
|
|
assert result.success is False
|
|
|
adapter._chatto_client.update_message.assert_not_called()
|
|
|
@@ -485,6 +515,7 @@ class TestMessageEditing:
|
|
|
|
|
|
# -- Outgoing text formatting --
|
|
|
|
|
|
+
|
|
|
class TestFormatMessage:
|
|
|
"""format_message() only fixes what renders wrong in Chatto."""
|
|
|
|
|
|
@@ -508,6 +539,7 @@ class TestFormatMessage:
|
|
|
|
|
|
# -- Handoff threads --
|
|
|
|
|
|
+
|
|
|
class TestHandoffThread:
|
|
|
"""create_handoff_thread() anchors a handoff on a seed message."""
|
|
|
|
|
|
@@ -527,7 +559,9 @@ class TestHandoffThread:
|
|
|
result = await adapter.create_handoff_thread("room-1", "Refactor run")
|
|
|
|
|
|
assert result == "seed-1"
|
|
|
- assert adapter._chatto_client.post_message.call_args.kwargs["room_id"] == "room-1"
|
|
|
+ assert (
|
|
|
+ adapter._chatto_client.post_message.call_args.kwargs["room_id"] == "room-1"
|
|
|
+ )
|
|
|
adapter._chatto_client.follow_thread.assert_called_once_with("room-1", "seed-1")
|
|
|
# Our own seed must not come back in as inbound traffic.
|
|
|
assert adapter._is_seen("seed-1") is True
|
|
|
@@ -545,6 +579,7 @@ class TestHandoffThread:
|
|
|
|
|
|
# -- Native file / video / audio delivery --
|
|
|
|
|
|
+
|
|
|
class TestUploadAsset:
|
|
|
"""Drives the real _upload_asset against real chattolib result types.
|
|
|
|
|
|
@@ -560,10 +595,17 @@ class TestUploadAsset:
|
|
|
upload = AssetUpload(upload_id="up-1", room_id="room-1")
|
|
|
adapter._chatto_client.create_upload = AsyncMock(return_value=upload)
|
|
|
adapter._chatto_client.upload_chunk = AsyncMock(return_value=upload)
|
|
|
- adapter._chatto_client.complete_upload = AsyncMock(return_value=(
|
|
|
- upload,
|
|
|
- Asset(id="asset-9", filename="horse.jpg", content_type="image/jpeg", size=103),
|
|
|
- ))
|
|
|
+ adapter._chatto_client.complete_upload = AsyncMock(
|
|
|
+ return_value=(
|
|
|
+ upload,
|
|
|
+ Asset(
|
|
|
+ id="asset-9",
|
|
|
+ filename="horse.jpg",
|
|
|
+ content_type="image/jpeg",
|
|
|
+ size=103,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ )
|
|
|
return adapter
|
|
|
|
|
|
async def test_returns_the_asset_id(self, adapter):
|
|
|
@@ -573,11 +615,14 @@ class TestUploadAsset:
|
|
|
"""AssetUpload calls it upload_id, not id — reading the wrong field made
|
|
|
every upload fail with 'CreateUpload returned no upload ID'."""
|
|
|
await adapter._upload_asset("room-1", str(self.path))
|
|
|
- assert adapter._chatto_client.upload_chunk.await_args.kwargs["upload_id"] == "up-1"
|
|
|
+ assert (
|
|
|
+ adapter._chatto_client.upload_chunk.await_args.kwargs["upload_id"] == "up-1"
|
|
|
+ )
|
|
|
|
|
|
async def test_missing_upload_id_is_reported(self, adapter):
|
|
|
adapter._chatto_client.create_upload = AsyncMock(
|
|
|
- return_value=AssetUpload(upload_id="", room_id="room-1"))
|
|
|
+ return_value=AssetUpload(upload_id="", room_id="room-1")
|
|
|
+ )
|
|
|
assert await adapter._upload_asset("room-1", str(self.path)) is None
|
|
|
|
|
|
|
|
|
@@ -606,7 +651,9 @@ class TestNativeSends:
|
|
|
)
|
|
|
async def test_uploads_and_attaches(self, adapter, method, arg_name):
|
|
|
result = await getattr(adapter, method)(
|
|
|
- "room-1", **{arg_name: "/tmp/thing.bin"}, caption="here you go",
|
|
|
+ "room-1",
|
|
|
+ **{arg_name: "/tmp/thing.bin"},
|
|
|
+ caption="here you go",
|
|
|
)
|
|
|
assert result.success is True
|
|
|
adapter._upload_asset.assert_called_once_with("room-1", "/tmp/thing.bin")
|
|
|
@@ -634,6 +681,7 @@ class TestNativeSends:
|
|
|
|
|
|
# -- Batched image delivery --
|
|
|
|
|
|
+
|
|
|
class TestSendMultipleImages:
|
|
|
"""A batch of images belongs in ONE Chatto message."""
|
|
|
|
|
|
@@ -650,7 +698,8 @@ class TestSendMultipleImages:
|
|
|
|
|
|
async def test_bundles_into_single_message(self, adapter):
|
|
|
await adapter.send_multiple_images(
|
|
|
- "room-1", [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
|
|
|
+ "room-1",
|
|
|
+ [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
|
|
|
)
|
|
|
adapter._chatto_client.post_message.assert_called_once()
|
|
|
call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
|
|
|
@@ -673,7 +722,8 @@ class TestSendMultipleImages:
|
|
|
async def test_partial_upload_failure_still_sends_the_rest(self, adapter):
|
|
|
adapter._upload_asset = AsyncMock(side_effect=["asset-1", None])
|
|
|
await adapter.send_multiple_images(
|
|
|
- "room-1", [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
|
|
|
+ "room-1",
|
|
|
+ [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
|
|
|
)
|
|
|
call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
|
|
|
assert call_kwargs["attachment_asset_ids"] == ["asset-1"]
|
|
|
@@ -689,6 +739,7 @@ class TestSendMultipleImages:
|
|
|
|
|
|
# -- Reaction event forwarding --
|
|
|
|
|
|
+
|
|
|
class TestReactionForwarding:
|
|
|
"""Human reactions reach the gateway's reaction hook surface."""
|
|
|
|
|
|
@@ -704,7 +755,9 @@ class TestReactionForwarding:
|
|
|
event.kind = kind
|
|
|
event.actor_id = actor_id
|
|
|
payload = ReactionPayload(
|
|
|
- room_id="room-1", message_event_id="msg-1", emoji="thumbsup",
|
|
|
+ room_id="room-1",
|
|
|
+ message_event_id="msg-1",
|
|
|
+ emoji="thumbsup",
|
|
|
)
|
|
|
# RealtimeEvent.get() only yields the payload for its own kind.
|
|
|
event.get = MagicMock(side_effect=lambda k: payload if k == kind else None)
|
|
|
@@ -750,6 +803,7 @@ class TestReactionForwarding:
|
|
|
|
|
|
# -- chat_type mapping --
|
|
|
|
|
|
+
|
|
|
class TestChatTypeMapping:
|
|
|
"""RoomKind -> the gateway's chat_type vocabulary."""
|
|
|
|
|
|
@@ -803,6 +857,7 @@ class TestChatTypeMapping:
|
|
|
|
|
|
# -- Presence --
|
|
|
|
|
|
+
|
|
|
class TestPresence:
|
|
|
"""Presence is a server-side TTL: stop re-announcing and the bot goes offline."""
|
|
|
|
|
|
@@ -837,7 +892,8 @@ class TestPresence:
|
|
|
adapter = self._adapter()
|
|
|
adapter._closing = False
|
|
|
adapter._chatto_client.update_presence = AsyncMock(
|
|
|
- side_effect=[RuntimeError("boom"), None, None])
|
|
|
+ side_effect=[RuntimeError("boom"), None, None]
|
|
|
+ )
|
|
|
with patch.object(ChattoConstants, "PRESENCE_REFRESH_INTERVAL", 0.01):
|
|
|
task = asyncio.create_task(adapter._presence_refresh_loop())
|
|
|
for _ in range(200):
|
|
|
@@ -855,7 +911,9 @@ class TestPresence:
|
|
|
|
|
|
async def test_announce_online_reports_failure(self):
|
|
|
adapter = self._adapter()
|
|
|
- adapter._chatto_client.update_presence = AsyncMock(side_effect=RuntimeError("nope"))
|
|
|
+ adapter._chatto_client.update_presence = AsyncMock(
|
|
|
+ side_effect=RuntimeError("nope")
|
|
|
+ )
|
|
|
assert await adapter._announce_online() is False
|
|
|
|
|
|
async def test_disconnect_does_not_broadcast_offline(self):
|
|
|
@@ -869,6 +927,7 @@ class TestPresence:
|
|
|
|
|
|
# -- Mentions of other people --
|
|
|
|
|
|
+
|
|
|
class TestForeignMention:
|
|
|
"""With require_mention off the bot reads everything, so a message aimed at
|
|
|
a named colleague would otherwise get an unsolicited answer. Acknowledge it
|
|
|
@@ -888,16 +947,20 @@ class TestForeignMention:
|
|
|
adapter._room_kinds["room-1"] = RoomKind.CHANNEL
|
|
|
adapter._room_kinds["dm-1"] = RoomKind.DM
|
|
|
# The directory knows bob and nobody else.
|
|
|
- adapter._chatto_client.get_user = AsyncMock(side_effect=lambda **kw: (
|
|
|
- DirectoryMember(user=_make_user("user-2", "bob"))
|
|
|
- if kw.get("login") == "bob" else None
|
|
|
- ))
|
|
|
+ adapter._chatto_client.get_user = AsyncMock(
|
|
|
+ side_effect=lambda **kw: (
|
|
|
+ DirectoryMember(user=_make_user("user-2", "bob"))
|
|
|
+ if kw.get("login") == "bob"
|
|
|
+ else None
|
|
|
+ )
|
|
|
+ )
|
|
|
return adapter
|
|
|
|
|
|
async def _dispatch(self, adapter, body, room_id="room-1"):
|
|
|
payload = _make_posted_payload(room_id=room_id)
|
|
|
payload.fetch_message = AsyncMock(
|
|
|
- return_value=_make_message(body=body, room_id=room_id))
|
|
|
+ return_value=_make_message(body=body, room_id=room_id)
|
|
|
+ )
|
|
|
await adapter._dispatch_message_posted(payload)
|
|
|
|
|
|
async def test_message_for_someone_else_is_only_acknowledged(self):
|
|
|
@@ -915,10 +978,13 @@ class TestForeignMention:
|
|
|
|
|
|
async def test_several_people_addressed_and_none_of_them_us(self):
|
|
|
adapter = self._adapter()
|
|
|
- adapter._chatto_client.get_user = AsyncMock(side_effect=lambda **kw: (
|
|
|
- DirectoryMember(user=_make_user("u", kw["login"]))
|
|
|
- if kw.get("login") in {"bob", "carol"} else None
|
|
|
- ))
|
|
|
+ adapter._chatto_client.get_user = AsyncMock(
|
|
|
+ side_effect=lambda **kw: (
|
|
|
+ DirectoryMember(user=_make_user("u", kw["login"]))
|
|
|
+ if kw.get("login") in {"bob", "carol"}
|
|
|
+ else None
|
|
|
+ )
|
|
|
+ )
|
|
|
await self._dispatch(adapter, "@bob @carol schaut mal drüber")
|
|
|
adapter.handle_message.assert_not_called()
|
|
|
adapter.add_reaction.assert_awaited_once()
|
|
|
@@ -957,7 +1023,7 @@ class TestForeignMention:
|
|
|
adapter = self._adapter()
|
|
|
await self._dispatch(
|
|
|
adapter,
|
|
|
- 'Bitte schreibe um 8 Uhr Europe/Berlin per @-mention den '
|
|
|
+ "Bitte schreibe um 8 Uhr Europe/Berlin per @-mention den "
|
|
|
'Chatto-Nutzer "nickk" an und sage: Guten Morgen.',
|
|
|
)
|
|
|
adapter.handle_message.assert_called_once()
|
|
|
@@ -1012,6 +1078,7 @@ class TestForeignMention:
|
|
|
|
|
|
# -- require_mention --
|
|
|
|
|
|
+
|
|
|
class TestRequireMention:
|
|
|
"""require_mention gates channels only — a DM is already addressed at the bot."""
|
|
|
|
|
|
@@ -1027,7 +1094,8 @@ class TestRequireMention:
|
|
|
async def _dispatch(self, adapter, room_id, body):
|
|
|
payload = _make_posted_payload(room_id=room_id)
|
|
|
payload.fetch_message = AsyncMock(
|
|
|
- return_value=_make_message(body=body, room_id=room_id))
|
|
|
+ return_value=_make_message(body=body, room_id=room_id)
|
|
|
+ )
|
|
|
await adapter._dispatch_message_posted(payload)
|
|
|
|
|
|
async def test_channel_without_mention_is_discarded(self):
|
|
|
@@ -1052,6 +1120,7 @@ class TestRequireMention:
|
|
|
|
|
|
# -- Inbound attachments --
|
|
|
|
|
|
+
|
|
|
class TestInboundAttachments:
|
|
|
"""Messages carrying files must reach the agent, body or not."""
|
|
|
|
|
|
@@ -1063,7 +1132,9 @@ class TestInboundAttachments:
|
|
|
adapter._room_kinds["room-1"] = RoomKind.DM
|
|
|
adapter._user_cache["user-1"] = _make_user("user-1", "alice")
|
|
|
adapter.handle_message = AsyncMock()
|
|
|
- adapter._download_attachment_bytes = AsyncMock(return_value=b"\x89PNG\r\n\x1a\nrest")
|
|
|
+ adapter._download_attachment_bytes = AsyncMock(
|
|
|
+ return_value=b"\x89PNG\r\n\x1a\nrest"
|
|
|
+ )
|
|
|
return adapter
|
|
|
|
|
|
async def test_image_attachment_becomes_media_url(self, adapter):
|
|
|
@@ -1075,7 +1146,10 @@ class TestInboundAttachments:
|
|
|
)
|
|
|
payload.fetch_message = AsyncMock(return_value=message)
|
|
|
|
|
|
- with patch("adapter.cache_media_bytes", return_value=_cached("/cache/shot.png", "image/png", "image")):
|
|
|
+ with patch(
|
|
|
+ "adapter.cache_media_bytes",
|
|
|
+ return_value=_cached("/cache/shot.png", "image/png", "image"),
|
|
|
+ ):
|
|
|
await adapter._dispatch_message_posted(payload)
|
|
|
|
|
|
event = adapter.handle_message.call_args.args[0]
|
|
|
@@ -1087,11 +1161,15 @@ class TestInboundAttachments:
|
|
|
"""The empty-body early return is what silently ate file uploads."""
|
|
|
payload = _make_posted_payload()
|
|
|
message = _make_message(
|
|
|
- body="", attachments=[_make_attachment("report.pdf", "application/pdf")],
|
|
|
+ body="",
|
|
|
+ attachments=[_make_attachment("report.pdf", "application/pdf")],
|
|
|
)
|
|
|
payload.fetch_message = AsyncMock(return_value=message)
|
|
|
|
|
|
- with patch("adapter.cache_media_bytes", return_value=_cached("/cache/report.pdf", "application/pdf", "document")):
|
|
|
+ with patch(
|
|
|
+ "adapter.cache_media_bytes",
|
|
|
+ return_value=_cached("/cache/report.pdf", "application/pdf", "document"),
|
|
|
+ ):
|
|
|
await adapter._dispatch_message_posted(payload)
|
|
|
|
|
|
adapter.handle_message.assert_called_once()
|
|
|
@@ -1108,9 +1186,12 @@ class TestInboundAttachments:
|
|
|
async def test_download_failure_still_delivers_the_text(self, adapter):
|
|
|
adapter._download_attachment_bytes = AsyncMock(side_effect=RuntimeError("404"))
|
|
|
payload = _make_posted_payload()
|
|
|
- payload.fetch_message = AsyncMock(return_value=_make_message(
|
|
|
- body="see attached", attachments=[_make_attachment("a.png", "image/png")],
|
|
|
- ))
|
|
|
+ payload.fetch_message = AsyncMock(
|
|
|
+ return_value=_make_message(
|
|
|
+ body="see attached",
|
|
|
+ attachments=[_make_attachment("a.png", "image/png")],
|
|
|
+ )
|
|
|
+ )
|
|
|
|
|
|
await adapter._dispatch_message_posted(payload)
|
|
|
|
|
|
@@ -1124,9 +1205,12 @@ class TestInboundAttachments:
|
|
|
payload = _make_posted_payload()
|
|
|
att = _make_attachment("clip.mp4", "video/mp4")
|
|
|
att.asset_url = None
|
|
|
- payload.fetch_message = AsyncMock(return_value=_make_message(
|
|
|
- body="clip", attachments=[att],
|
|
|
- ))
|
|
|
+ payload.fetch_message = AsyncMock(
|
|
|
+ return_value=_make_message(
|
|
|
+ body="clip",
|
|
|
+ attachments=[att],
|
|
|
+ )
|
|
|
+ )
|
|
|
|
|
|
await adapter._dispatch_message_posted(payload)
|
|
|
|
|
|
@@ -1136,7 +1220,10 @@ class TestInboundAttachments:
|
|
|
|
|
|
async def test_document_wins_over_image(self, adapter):
|
|
|
"""Mixed batches classify as DOCUMENT — that gates context injection."""
|
|
|
- assert adapter._message_type_for_media_kinds(["image", "document"]) is MessageType.DOCUMENT
|
|
|
+ assert (
|
|
|
+ adapter._message_type_for_media_kinds(["image", "document"])
|
|
|
+ is MessageType.DOCUMENT
|
|
|
+ )
|
|
|
assert adapter._message_type_for_media_kinds(["image"]) is MessageType.PHOTO
|
|
|
assert adapter._message_type_for_media_kinds(["video"]) is MessageType.VIDEO
|
|
|
assert adapter._message_type_for_media_kinds(["audio"]) is MessageType.AUDIO
|
|
|
@@ -1147,15 +1234,25 @@ class TestInboundAttachments:
|
|
|
import httpx
|
|
|
|
|
|
big = get_inbound_media_max_bytes() + 1
|
|
|
- transport = httpx.MockTransport(lambda request: httpx.Response(
|
|
|
- 200, headers={"content-length": str(big)}, content=b"x",
|
|
|
- ))
|
|
|
+ transport = httpx.MockTransport(
|
|
|
+ lambda request: httpx.Response(
|
|
|
+ 200,
|
|
|
+ headers={"content-length": str(big)},
|
|
|
+ content=b"x",
|
|
|
+ )
|
|
|
+ )
|
|
|
real_adapter = _make_adapter()
|
|
|
real_client_cls = httpx.AsyncClient
|
|
|
|
|
|
- with patch("httpx.AsyncClient", lambda **kw: real_client_cls(transport=transport)):
|
|
|
- with pytest.raises(ValueError):
|
|
|
- await real_adapter._download_attachment_bytes("https://chat.example.com/a.png")
|
|
|
+ with (
|
|
|
+ patch(
|
|
|
+ "httpx.AsyncClient", lambda **kw: real_client_cls(transport=transport)
|
|
|
+ ),
|
|
|
+ pytest.raises(ValueError),
|
|
|
+ ):
|
|
|
+ await real_adapter._download_attachment_bytes(
|
|
|
+ "https://chat.example.com/a.png"
|
|
|
+ )
|
|
|
|
|
|
|
|
|
# NOTE: there are deliberately no tests for get_user(), set_presence() or
|
|
|
@@ -1167,6 +1264,7 @@ class TestInboundAttachments:
|
|
|
|
|
|
# -- Room operations --
|
|
|
|
|
|
+
|
|
|
class TestRoomOperations:
|
|
|
"""Test room creation and DM initiation."""
|
|
|
|
|
|
@@ -1188,7 +1286,9 @@ class TestRoomOperations:
|
|
|
|
|
|
async def test_create_room(self, adapter):
|
|
|
adapter._chatto_client.create_room.return_value = _make_room(
|
|
|
- "room-123", "Test Room", RoomKind.CHANNEL,
|
|
|
+ "room-123",
|
|
|
+ "Test Room",
|
|
|
+ RoomKind.CHANNEL,
|
|
|
)
|
|
|
result = await adapter.create_room("Test Room", "A test room")
|
|
|
assert result == "room-123"
|
|
|
@@ -1197,7 +1297,9 @@ class TestRoomOperations:
|
|
|
|
|
|
async def test_start_dm(self, adapter):
|
|
|
adapter._chatto_client.start_dm.return_value = _make_room(
|
|
|
- "dm-123", "DM with user", RoomKind.DM,
|
|
|
+ "dm-123",
|
|
|
+ "DM with user",
|
|
|
+ RoomKind.DM,
|
|
|
)
|
|
|
result = await adapter.start_dm("user-123")
|
|
|
assert result == "dm-123"
|
|
|
@@ -1207,10 +1309,12 @@ class TestRoomOperations:
|
|
|
|
|
|
# -- DM room management (/join, /leave) --
|
|
|
|
|
|
+
|
|
|
def _make_room_state(room, is_member):
|
|
|
"""Build a RoomWithViewerState the way list_rooms()/get_room() return it."""
|
|
|
return RoomWithViewerState(
|
|
|
- room=room, viewer_state=RoomViewerState(is_member=is_member),
|
|
|
+ room=room,
|
|
|
+ viewer_state=RoomViewerState(is_member=is_member),
|
|
|
)
|
|
|
|
|
|
|
|
|
@@ -1218,7 +1322,6 @@ class TestDmRoomCommands:
|
|
|
"""/join and /leave arrive over DMs, change server-side membership and
|
|
|
must never reach the agent pipeline."""
|
|
|
|
|
|
-
|
|
|
def _adapter(self):
|
|
|
adapter = _make_adapter()
|
|
|
adapter.chatto_config.allow_all_users.value = True
|
|
|
@@ -1235,23 +1338,22 @@ class TestDmRoomCommands:
|
|
|
adapter.send = AsyncMock()
|
|
|
return adapter
|
|
|
|
|
|
-
|
|
|
async def _dispatch(self, adapter, body, room_id="dm-1"):
|
|
|
payload = _make_posted_payload(room_id=room_id)
|
|
|
payload.fetch_message = AsyncMock(
|
|
|
- return_value=_make_message(body=body, room_id=room_id))
|
|
|
+ return_value=_make_message(body=body, room_id=room_id)
|
|
|
+ )
|
|
|
await adapter._dispatch_message_posted(payload)
|
|
|
|
|
|
-
|
|
|
def _reply(self, adapter):
|
|
|
assert adapter.send.await_count == 1
|
|
|
return adapter.send.await_args.kwargs["content"]
|
|
|
|
|
|
-
|
|
|
async def test_join_by_name_joins_and_watches(self):
|
|
|
adapter = self._adapter()
|
|
|
state = _make_room_state(
|
|
|
- _make_room("room-9", "Deploy", RoomKind.CHANNEL), False)
|
|
|
+ _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
|
|
|
+ )
|
|
|
adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
|
|
|
adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
|
|
|
|
|
|
@@ -1261,13 +1363,11 @@ class TestDmRoomCommands:
|
|
|
assert adapter._watch_room_ids == ["room-9"]
|
|
|
assert "Joined 'Deploy' (room-9)" in self._reply(adapter)
|
|
|
|
|
|
-
|
|
|
async def test_join_skips_rpc_when_already_a_member(self):
|
|
|
adapter = self._adapter()
|
|
|
"""Natively invited accounts hold membership already — they only need
|
|
|
seeding into the watch list."""
|
|
|
- state = _make_room_state(
|
|
|
- _make_room("room-9", "Deploy", RoomKind.CHANNEL), True)
|
|
|
+ state = _make_room_state(_make_room("room-9", "Deploy", RoomKind.CHANNEL), True)
|
|
|
adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
|
|
|
|
|
|
await self._dispatch(adapter, "/join #deploy")
|
|
|
@@ -1276,7 +1376,6 @@ class TestDmRoomCommands:
|
|
|
assert adapter._watch_room_ids == ["room-9"]
|
|
|
assert "Already a member" in self._reply(adapter)
|
|
|
|
|
|
-
|
|
|
async def test_join_unknown_name_reports_without_joining(self):
|
|
|
adapter = self._adapter()
|
|
|
adapter._chatto_client.list_rooms = AsyncMock(return_value=[])
|
|
|
@@ -1287,7 +1386,6 @@ class TestDmRoomCommands:
|
|
|
assert "No room named '#nope'" in self._reply(adapter)
|
|
|
assert adapter._watch_room_ids == []
|
|
|
|
|
|
-
|
|
|
async def test_ambiguous_name_offers_the_candidate_ids(self):
|
|
|
adapter = self._adapter()
|
|
|
matches = [
|
|
|
@@ -1303,11 +1401,11 @@ class TestDmRoomCommands:
|
|
|
reply = self._reply(adapter)
|
|
|
assert "r-0" in reply and "r-1" in reply
|
|
|
|
|
|
-
|
|
|
async def test_join_by_room_id_verifies_via_get_room(self):
|
|
|
adapter = self._adapter()
|
|
|
state = _make_room_state(
|
|
|
- _make_room("room-9", "Deploy", RoomKind.CHANNEL), False)
|
|
|
+ _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
|
|
|
+ )
|
|
|
adapter._chatto_client.get_room = AsyncMock(return_value=state)
|
|
|
adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
|
|
|
|
|
|
@@ -1317,12 +1415,10 @@ class TestDmRoomCommands:
|
|
|
adapter._chatto_client.join_room.assert_awaited_once_with("room-9")
|
|
|
assert adapter._watch_room_ids == ["room-9"]
|
|
|
|
|
|
-
|
|
|
async def test_leave_stops_watching_the_room(self):
|
|
|
adapter = self._adapter()
|
|
|
adapter._watch_room_ids = ["room-7"]
|
|
|
- state = _make_room_state(
|
|
|
- _make_room("room-7", "Deploy", RoomKind.CHANNEL), True)
|
|
|
+ state = _make_room_state(_make_room("room-7", "Deploy", RoomKind.CHANNEL), True)
|
|
|
adapter._chatto_client.get_room = AsyncMock(return_value=state)
|
|
|
|
|
|
await self._dispatch(adapter, "/leave room-7")
|
|
|
@@ -1331,11 +1427,9 @@ class TestDmRoomCommands:
|
|
|
assert adapter._watch_room_ids == []
|
|
|
assert "Left 'Deploy' (room-7)" in self._reply(adapter)
|
|
|
|
|
|
-
|
|
|
async def test_leave_refuses_direct_messages(self):
|
|
|
adapter = self._adapter()
|
|
|
- state = _make_room_state(
|
|
|
- _make_room("dm-2", "", RoomKind.DM), True)
|
|
|
+ state = _make_room_state(_make_room("dm-2", "", RoomKind.DM), True)
|
|
|
adapter._chatto_client.get_room = AsyncMock(return_value=state)
|
|
|
|
|
|
await self._dispatch(adapter, "/leave dm-2")
|
|
|
@@ -1343,14 +1437,12 @@ class TestDmRoomCommands:
|
|
|
adapter._chatto_client.leave_room.assert_not_awaited()
|
|
|
assert "Direct messages cannot be left" in self._reply(adapter)
|
|
|
|
|
|
-
|
|
|
async def test_leave_refuses_home_channel(self):
|
|
|
"""Leaving CHATTO_HOME_CHANNEL would break cron/notification delivery."""
|
|
|
adapter = self._adapter()
|
|
|
adapter.chatto_config.home_channel.value = "room-7"
|
|
|
adapter._watch_room_ids = ["room-7"]
|
|
|
- state = _make_room_state(
|
|
|
- _make_room("room-7", "Home", RoomKind.CHANNEL), True)
|
|
|
+ state = _make_room_state(_make_room("room-7", "Home", RoomKind.CHANNEL), True)
|
|
|
adapter._chatto_client.get_room = AsyncMock(return_value=state)
|
|
|
|
|
|
await self._dispatch(adapter, "/leave room-7")
|
|
|
@@ -1358,7 +1450,6 @@ class TestDmRoomCommands:
|
|
|
adapter._chatto_client.leave_room.assert_not_awaited()
|
|
|
assert "home channel" in self._reply(adapter)
|
|
|
|
|
|
-
|
|
|
async def test_commands_outside_dms_are_ignored(self):
|
|
|
adapter = self._adapter()
|
|
|
"""In a channel the text is just a message — mention gating applies,
|
|
|
@@ -1372,14 +1463,12 @@ class TestDmRoomCommands:
|
|
|
adapter.handle_message.assert_not_called()
|
|
|
adapter.send.assert_not_called()
|
|
|
|
|
|
-
|
|
|
async def test_non_command_dm_falls_through_to_pipeline(self):
|
|
|
adapter = self._adapter()
|
|
|
await self._dispatch(adapter, "/status all good")
|
|
|
adapter.handle_message.assert_awaited_once()
|
|
|
adapter.send.assert_not_called()
|
|
|
|
|
|
-
|
|
|
async def test_missing_argument_gets_usage_reply(self):
|
|
|
adapter = self._adapter()
|
|
|
for body in ("/join", "/leave"):
|
|
|
@@ -1388,18 +1477,15 @@ class TestDmRoomCommands:
|
|
|
assert self._reply(adapter).startswith("Usage:")
|
|
|
|
|
|
|
|
|
-
|
|
|
class TestRoomWatchRefresh:
|
|
|
"""_refresh_rooms mirrors watch-list membership against the server."""
|
|
|
|
|
|
-
|
|
|
def _adapter(self):
|
|
|
adapter = _make_adapter()
|
|
|
client = adapter._chatto_client
|
|
|
client.list_rooms = AsyncMock(return_value=[])
|
|
|
return adapter
|
|
|
|
|
|
-
|
|
|
async def test_unwatches_rooms_no_longer_joined(self):
|
|
|
adapter = self._adapter()
|
|
|
adapter._watch_room_ids = ["gone-1", "kept"]
|
|
|
@@ -1410,21 +1496,18 @@ class TestRoomWatchRefresh:
|
|
|
|
|
|
assert adapter._watch_room_ids == ["kept"]
|
|
|
|
|
|
-
|
|
|
async def test_warns_once_when_home_channel_is_not_joined(self):
|
|
|
adapter = self._adapter()
|
|
|
adapter.chatto_config.home_channel.value = "home-x"
|
|
|
other = _make_room_state(_make_room("other", "Other", RoomKind.CHANNEL), True)
|
|
|
adapter._chatto_client.list_rooms = AsyncMock(return_value=[other])
|
|
|
|
|
|
-
|
|
|
await adapter._refresh_rooms()
|
|
|
assert adapter._home_warning_logged
|
|
|
|
|
|
await adapter._refresh_rooms()
|
|
|
assert adapter._home_warning_logged
|
|
|
|
|
|
-
|
|
|
async def test_no_warning_while_home_channel_is_member(self):
|
|
|
adapter = self._adapter()
|
|
|
adapter.chatto_config.home_channel.value = "home-x"
|
|
|
@@ -1437,6 +1520,7 @@ class TestRoomWatchRefresh:
|
|
|
|
|
|
# -- Constants --
|
|
|
|
|
|
+
|
|
|
class TestConstants:
|
|
|
"""Test that constants are properly defined."""
|
|
|
|