|
|
@@ -5422,8 +5422,9 @@ class TestPluginToolHandlers:
|
|
|
|
|
|
async def test_get_history_reads_a_room_the_send_tool_would_refuse(self):
|
|
|
"""Reading breaks no read-only promise — the send gate exists for
|
|
|
- posting, and a visible room's history stays readable without the
|
|
|
- mention lists."""
|
|
|
+ posting, and a joined room's history stays readable without the
|
|
|
+ mention lists. Membership is a separate requirement the read tools
|
|
|
+ share with the send one (see the refusal below)."""
|
|
|
room = _make_room("room-9", "Lurk", RoomKind.CHANNEL)
|
|
|
client = self._stub_client()
|
|
|
client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
@@ -5437,6 +5438,144 @@ class TestPluginToolHandlers:
|
|
|
)
|
|
|
assert result["success"] is True
|
|
|
|
|
|
+ async def test_get_history_pages_backwards_with_a_cursor(self):
|
|
|
+ """`before` is a start_cursor from the previous page, not an event ID:
|
|
|
+ the server walks the timeline from an opaque position. One call reads
|
|
|
+ one page — paging is the caller's next call, not a loop inside this one.
|
|
|
+ """
|
|
|
+ room = self._readable_room()
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ page = self._timeline_page([_make_message(message_id="msg-1")])
|
|
|
+ page.start_cursor = "cur-start"
|
|
|
+ page.end_cursor = "cur-end"
|
|
|
+ page.has_older = True
|
|
|
+ page.has_newer = True
|
|
|
+ client.get_room_events = AsyncMock(return_value=page)
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_get_history,
|
|
|
+ {"room_id": "room-1", "before": "cur-start"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is True
|
|
|
+ assert client.get_room_events.await_args.kwargs["before"] == "cur-start"
|
|
|
+ # The next page is one call away only if this answer carries what it
|
|
|
+ # takes to make that call.
|
|
|
+ assert result["start_cursor"] == "cur-start"
|
|
|
+ assert result["end_cursor"] == "cur-end"
|
|
|
+ assert result["has_older"] is True
|
|
|
+ assert result["has_newer"] is True
|
|
|
+
|
|
|
+ async def test_get_history_pages_forwards_with_a_cursor(self):
|
|
|
+ room = self._readable_room()
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ client.get_room_events = AsyncMock(
|
|
|
+ return_value=self._timeline_page([_make_message(message_id="msg-2")])
|
|
|
+ )
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_get_history,
|
|
|
+ {"room_id": "room-1", "after": "cur-end"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is True
|
|
|
+ assert client.get_room_events.await_args.kwargs["after"] == "cur-end"
|
|
|
+
|
|
|
+ async def test_get_history_cursors_reach_a_thread_read_too(self):
|
|
|
+ """One timeline, one rule: a thread page pages the same way a room page
|
|
|
+ does, or a long thread goes unreachable while a room does not."""
|
|
|
+ room = self._readable_room()
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ client.get_message = AsyncMock(
|
|
|
+ return_value=_make_message(message_id="msg-1", body="root")
|
|
|
+ )
|
|
|
+ client.get_thread_events = AsyncMock(
|
|
|
+ return_value=self._timeline_page([_make_message(message_id="msg-1")])
|
|
|
+ )
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_get_history,
|
|
|
+ {
|
|
|
+ "room_id": "room-1",
|
|
|
+ "thread_root_event_id": "msg-1",
|
|
|
+ "before": "cur-start",
|
|
|
+ },
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is True
|
|
|
+ assert client.get_thread_events.await_args.kwargs["before"] == "cur-start"
|
|
|
+
|
|
|
+ async def test_get_history_refuses_both_cursors_at_once(self):
|
|
|
+ """chattolib sends `before` OR `after`, never both — the second is
|
|
|
+ dropped without a word. Refusing beats handing back a page that is not
|
|
|
+ the one that was asked for."""
|
|
|
+ room = self._readable_room()
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ client.get_room_events = AsyncMock()
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_get_history,
|
|
|
+ {"room_id": "room-1", "before": "a", "after": "b"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is False
|
|
|
+ assert "before" in result["error"] and "after" in result["error"]
|
|
|
+ client.get_room_events.assert_not_awaited()
|
|
|
+
|
|
|
+ async def test_get_history_omits_the_cursor_when_none_was_asked_for(self):
|
|
|
+ """An unset cursor must reach the client as None, not as "": an empty
|
|
|
+ string is a value, and the server reads a cursor as a position."""
|
|
|
+ room = self._readable_room()
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(return_value=_make_room_state(room))
|
|
|
+ client.get_room_events = AsyncMock(
|
|
|
+ return_value=self._timeline_page([_make_message(message_id="msg-1")])
|
|
|
+ )
|
|
|
+ await self._run(
|
|
|
+ _handle_chatto_get_history, {"room_id": "room-1"}, client=client
|
|
|
+ )
|
|
|
+ kwargs = client.get_room_events.await_args.kwargs
|
|
|
+ assert kwargs.get("before") is None
|
|
|
+ assert kwargs.get("after") is None
|
|
|
+
|
|
|
+ async def test_get_history_refuses_a_room_the_bot_has_not_joined(self):
|
|
|
+ """Room membership is required by every read RPC the plugin makes —
|
|
|
+ GetRoomEvents, GetMessage and BatchGetMessages all say so. Pre-empting
|
|
|
+ it with the /join remedy beats handing the agent a bare
|
|
|
+ PERMISSION_DENIED that names neither the room nor the fix."""
|
|
|
+ room = _make_room("room-9", "Lurk", RoomKind.CHANNEL)
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(
|
|
|
+ return_value=_make_room_state(room, is_member=False)
|
|
|
+ )
|
|
|
+ client.get_room_events = AsyncMock()
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_get_history, {"room_id": "room-9"}, client=client
|
|
|
+ )
|
|
|
+ assert result["success"] is False
|
|
|
+ assert "/join room-9" in result["error"]
|
|
|
+ client.get_room_events.assert_not_awaited()
|
|
|
+
|
|
|
+ async def test_get_message_refuses_a_room_the_bot_has_not_joined(self):
|
|
|
+ """The same server rule covers GetMessage/BatchGetMessages, and the
|
|
|
+ remedy is the same — one refusal, two tools, one room rule."""
|
|
|
+ room = _make_room("room-9", "Lurk", RoomKind.CHANNEL)
|
|
|
+ client = self._stub_client()
|
|
|
+ client.get_room = AsyncMock(
|
|
|
+ return_value=_make_room_state(room, is_member=False)
|
|
|
+ )
|
|
|
+ client.get_message = AsyncMock()
|
|
|
+ client.batch_get_messages = AsyncMock()
|
|
|
+ result, _, _ = await self._run(
|
|
|
+ _handle_chatto_get_message,
|
|
|
+ {"room_id": "room-9", "event_id": "msg-1"},
|
|
|
+ client=client,
|
|
|
+ )
|
|
|
+ assert result["success"] is False
|
|
|
+ assert "/join room-9" in result["error"]
|
|
|
+ client.get_message.assert_not_awaited()
|
|
|
+ client.batch_get_messages.assert_not_awaited()
|
|
|
+
|
|
|
async def test_get_history_refuses_an_out_of_range_limit(self):
|
|
|
result, _, _ = await self._run(
|
|
|
_handle_chatto_get_history, {"room_id": "room-1", "limit": 101}
|