|
|
@@ -8,6 +8,7 @@ Covers:
|
|
|
- Message sending and reactions
|
|
|
- User lookup (with caching)
|
|
|
- Presence and custom status
|
|
|
+ - DM room management (/join, /leave)
|
|
|
|
|
|
All network calls are mocked — no real HTTP or WebSocket connections.
|
|
|
"""
|
|
|
@@ -52,6 +53,8 @@ from chattolib.types import (
|
|
|
PresenceStatus,
|
|
|
Room,
|
|
|
RoomKind,
|
|
|
+ RoomViewerState,
|
|
|
+ RoomWithViewerState,
|
|
|
User,
|
|
|
)
|
|
|
from platform_config import ChattoConstants
|
|
|
@@ -1191,6 +1194,236 @@ class TestRoomOperations:
|
|
|
assert adapter._room_kinds["dm-123"] == RoomKind.DM
|
|
|
|
|
|
|
|
|
+# -- 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),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+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
|
|
|
+ adapter.me = _make_user("bot-user-id", "hermes_bot")
|
|
|
+ adapter._user_cache["user-1"] = _make_user("user-1", "alice")
|
|
|
+ client = adapter._chatto_client
|
|
|
+ client.list_rooms = AsyncMock(return_value=[])
|
|
|
+ client.get_room_events = AsyncMock(return_value=MagicMock(events=[]))
|
|
|
+ client.join_room = AsyncMock()
|
|
|
+ client.leave_room = AsyncMock(return_value=True)
|
|
|
+ client.get_room = AsyncMock()
|
|
|
+ adapter._room_kinds["dm-1"] = RoomKind.DM
|
|
|
+ adapter.handle_message = AsyncMock()
|
|
|
+ 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))
|
|
|
+ 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)
|
|
|
+ adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
|
|
|
+ adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
|
|
|
+
|
|
|
+ await self._dispatch(adapter, "/join #deploy")
|
|
|
+
|
|
|
+ adapter._chatto_client.join_room.assert_awaited_once_with("room-9")
|
|
|
+ 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)
|
|
|
+ adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
|
|
|
+
|
|
|
+ await self._dispatch(adapter, "/join #deploy")
|
|
|
+
|
|
|
+ adapter._chatto_client.join_room.assert_not_awaited()
|
|
|
+ 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=[])
|
|
|
+
|
|
|
+ await self._dispatch(adapter, "/join #nope")
|
|
|
+
|
|
|
+ adapter._chatto_client.join_room.assert_not_awaited()
|
|
|
+ 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 = [
|
|
|
+ _make_room_state(_make_room(f"r-{i}", "General", RoomKind.CHANNEL), False)
|
|
|
+ for i in range(2)
|
|
|
+ ]
|
|
|
+ adapter._chatto_client.list_rooms = AsyncMock(return_value=matches)
|
|
|
+ adapter._chatto_client.join_room = AsyncMock()
|
|
|
+
|
|
|
+ await self._dispatch(adapter, "/join #general")
|
|
|
+
|
|
|
+ adapter._chatto_client.join_room.assert_not_awaited()
|
|
|
+ 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)
|
|
|
+ adapter._chatto_client.get_room = AsyncMock(return_value=state)
|
|
|
+ adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
|
|
|
+
|
|
|
+ await self._dispatch(adapter, "/join room-9")
|
|
|
+
|
|
|
+ adapter._chatto_client.get_room.assert_awaited_once_with("room-9")
|
|
|
+ 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)
|
|
|
+ adapter._chatto_client.get_room = AsyncMock(return_value=state)
|
|
|
+
|
|
|
+ await self._dispatch(adapter, "/leave room-7")
|
|
|
+
|
|
|
+ adapter._chatto_client.leave_room.assert_awaited_once_with("room-7")
|
|
|
+ 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)
|
|
|
+ adapter._chatto_client.get_room = AsyncMock(return_value=state)
|
|
|
+
|
|
|
+ await self._dispatch(adapter, "/leave dm-2")
|
|
|
+
|
|
|
+ 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)
|
|
|
+ adapter._chatto_client.get_room = AsyncMock(return_value=state)
|
|
|
+
|
|
|
+ await self._dispatch(adapter, "/leave room-7")
|
|
|
+
|
|
|
+ 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,
|
|
|
+ no command runs, nothing is sent."""
|
|
|
+ adapter.chatto_config.require_mention.value = True
|
|
|
+ adapter._room_kinds["chan-1"] = RoomKind.CHANNEL
|
|
|
+
|
|
|
+ await self._dispatch(adapter, "/leave room-7", room_id="chan-1")
|
|
|
+
|
|
|
+ adapter._chatto_client.leave_room.assert_not_awaited()
|
|
|
+ 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"):
|
|
|
+ adapter.send.reset_mock()
|
|
|
+ await self._dispatch(adapter, body)
|
|
|
+ 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"]
|
|
|
+ kept = _make_room_state(_make_room("kept", "Kept", RoomKind.CHANNEL), True)
|
|
|
+ adapter._chatto_client.list_rooms = AsyncMock(return_value=[kept])
|
|
|
+
|
|
|
+ await adapter._refresh_rooms()
|
|
|
+
|
|
|
+ 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"
|
|
|
+ home = _make_room_state(_make_room("home-x", "Home", RoomKind.CHANNEL), True)
|
|
|
+ adapter._chatto_client.list_rooms = AsyncMock(return_value=[home])
|
|
|
+
|
|
|
+ await adapter._refresh_rooms()
|
|
|
+ assert not adapter._home_warning_logged
|
|
|
+
|
|
|
+
|
|
|
# -- Constants --
|
|
|
|
|
|
class TestConstants:
|