|
| 1 | +"""Exercise poll voting through the registered tool and real Telethon TL objects.""" |
| 2 | + |
| 3 | +from datetime import datetime, timezone |
| 4 | +from types import SimpleNamespace |
| 5 | +from unittest.mock import AsyncMock, Mock |
| 6 | + |
| 7 | +import pytest |
| 8 | +from mcp.server.fastmcp.exceptions import ToolError |
| 9 | +from telethon import errors, types |
| 10 | +from telethon.tl.functions.messages import SendVoteRequest |
| 11 | + |
| 12 | +from telegram_mcp import runtime |
| 13 | +from telegram_mcp.tools import messages |
| 14 | + |
| 15 | + |
| 16 | +@pytest.fixture |
| 17 | +def voting(monkeypatch): |
| 18 | + poll = types.Poll( |
| 19 | + id=77, |
| 20 | + hash=0, |
| 21 | + question=types.TextWithEntities("Pick a drink", []), |
| 22 | + answers=[ |
| 23 | + types.PollAnswer(types.TextWithEntities(text, []), option) |
| 24 | + for text, option in [ |
| 25 | + ("Tea", b"tea-id"), |
| 26 | + ("Water", b"\xff\x10"), |
| 27 | + ("Juice", b"juice-id"), |
| 28 | + ] |
| 29 | + ], |
| 30 | + closed=False, |
| 31 | + multiple_choice=False, |
| 32 | + ) |
| 33 | + message = types.Message( |
| 34 | + id=42, |
| 35 | + peer_id=types.PeerChat(123), |
| 36 | + date=datetime.now(timezone.utc), |
| 37 | + message="", |
| 38 | + media=types.MessageMediaPoll(poll, types.PollResults()), |
| 39 | + ) |
| 40 | + client = AsyncMock() |
| 41 | + client.get_messages.return_value = message |
| 42 | + entity = types.InputPeerChat(123) |
| 43 | + get_client = Mock(return_value=client) |
| 44 | + monkeypatch.setattr(messages, "get_client", get_client) |
| 45 | + monkeypatch.setattr(messages, "resolve_entity", AsyncMock(return_value=entity)) |
| 46 | + monkeypatch.setattr(runtime, "is_multi_mode", lambda: False) |
| 47 | + monkeypatch.setattr(runtime, "ALLOWED_CHAT_IDS", None) |
| 48 | + monkeypatch.delenv("TELEGRAM_ALLOWED_CHAT_IDS", raising=False) |
| 49 | + return SimpleNamespace( |
| 50 | + poll=poll, message=message, client=client, entity=entity, get_client=get_client |
| 51 | + ) |
| 52 | + |
| 53 | + |
| 54 | +async def call_vote(**kwargs): |
| 55 | + args = {"chat_id": -123, "message_id": 42, "option_indices": [1], **kwargs} |
| 56 | + return await messages.vote_poll(**args) |
| 57 | + |
| 58 | + |
| 59 | +@pytest.mark.asyncio |
| 60 | +@pytest.mark.parametrize("indices", [[1], [2, 0]]) |
| 61 | +async def test_vote_sends_real_option_bytes(voting, indices): |
| 62 | + voting.poll.multiple_choice = len(indices) > 1 |
| 63 | + result = await call_vote(option_indices=indices, account="personal") |
| 64 | + assert result == "Vote submitted for poll 42 in chat -123." |
| 65 | + voting.get_client.assert_called_once_with("personal") |
| 66 | + voting.client.get_messages.assert_awaited_once_with(voting.entity, ids=42) |
| 67 | + request = voting.client.call_args.args[0] |
| 68 | + assert isinstance(request, SendVoteRequest) |
| 69 | + assert request.peer == voting.entity |
| 70 | + assert request.msg_id == 42 |
| 71 | + assert request.options == ([b"\xff\x10"] if indices == [1] else [b"juice-id", b"tea-id"]) |
| 72 | + assert bytes(request) # Request is serializable by Telethon, not just a mock. |
| 73 | + voting.client.assert_awaited_once() |
| 74 | + |
| 75 | + |
| 76 | +@pytest.mark.asyncio |
| 77 | +@pytest.mark.parametrize( |
| 78 | + "args, expected", |
| 79 | + [ |
| 80 | + ({"message_id": 0}, "positive integer"), |
| 81 | + ({"message_id": -1}, "positive integer"), |
| 82 | + ({"option_indices": []}, "non-empty list"), |
| 83 | + ({"option_indices": [True]}, "list of integers"), |
| 84 | + ({"option_indices": [1.0]}, "list of integers"), |
| 85 | + ({"option_indices": [1, 1]}, "duplicates"), |
| 86 | + ({"option_indices": [-1]}, "out of range"), |
| 87 | + ({"option_indices": [3]}, "out of range"), |
| 88 | + ({"option_indices": [0, 1]}, "only allows one answer"), |
| 89 | + ], |
| 90 | +) |
| 91 | +async def test_invalid_votes_never_send(voting, args, expected): |
| 92 | + assert expected in await call_vote(**args) |
| 93 | + voting.client.assert_not_awaited() |
| 94 | + |
| 95 | + |
| 96 | +@pytest.mark.asyncio |
| 97 | +@pytest.mark.parametrize("kind", ["missing", "plain", "closed"]) |
| 98 | +async def test_non_poll_and_closed_poll_never_send(voting, kind): |
| 99 | + if kind == "missing": |
| 100 | + voting.client.get_messages.return_value = None |
| 101 | + elif kind == "plain": |
| 102 | + voting.message.media = None |
| 103 | + else: |
| 104 | + voting.poll.closed = True |
| 105 | + result = await call_vote() |
| 106 | + assert ("Poll is closed" if kind == "closed" else "does not contain a poll") in result |
| 107 | + voting.client.assert_not_awaited() |
| 108 | + |
| 109 | + |
| 110 | +@pytest.mark.asyncio |
| 111 | +async def test_rpc_error_is_not_reported_as_success(voting): |
| 112 | + voting.client.side_effect = errors.MessagePollClosedError(request=None) |
| 113 | + result = await call_vote() |
| 114 | + assert "Vote submitted" not in result |
| 115 | + assert "Error" in result or "error" in result |
| 116 | + |
| 117 | + |
| 118 | +@pytest.mark.asyncio |
| 119 | +async def test_multi_account_requires_explicit_account(voting, monkeypatch): |
| 120 | + monkeypatch.setattr(runtime, "is_multi_mode", lambda: True) |
| 121 | + monkeypatch.setattr(runtime, "clients", {"personal": voting.client, "work": AsyncMock()}) |
| 122 | + assert "'account' is required" in await call_vote() |
| 123 | + voting.client.get_messages.assert_not_awaited() |
| 124 | + voting.client.assert_not_awaited() |
| 125 | + |
| 126 | + |
| 127 | +@pytest.mark.asyncio |
| 128 | +async def test_chat_allowlist_blocks_vote(voting, monkeypatch): |
| 129 | + monkeypatch.setenv("TELEGRAM_ALLOWED_CHAT_IDS", "-456") |
| 130 | + assert "restricted by privacy policy" in await call_vote() |
| 131 | + voting.client.get_messages.assert_not_awaited() |
| 132 | + voting.client.assert_not_awaited() |
| 133 | + |
| 134 | + |
| 135 | +@pytest.mark.asyncio |
| 136 | +async def test_resolved_chat_is_checked_before_reading(voting, monkeypatch): |
| 137 | + monkeypatch.setattr(messages, "is_chat_allowlist_enabled", lambda: True) |
| 138 | + monkeypatch.setattr(messages, "is_chat_allowed", lambda *args: False) |
| 139 | + monkeypatch.setattr( |
| 140 | + messages, "check_chat_access", lambda *args: "restricted by privacy policy" |
| 141 | + ) |
| 142 | + result = await call_vote() |
| 143 | + assert "Vote submitted" not in result |
| 144 | + voting.client.get_messages.assert_not_awaited() |
| 145 | + voting.client.assert_not_awaited() |
| 146 | + |
| 147 | + |
| 148 | +@pytest.mark.asyncio |
| 149 | +async def test_registered_tool_accepts_username_and_rejects_coercion(voting): |
| 150 | + tool = next(t for t in messages.mcp._tool_manager.list_tools() if t.name == "vote_poll") |
| 151 | + assert not tool.annotations.readOnlyHint |
| 152 | + assert tool.annotations.destructiveHint |
| 153 | + result = await tool.run({"chat_id": "@example_chat", "message_id": 42, "option_indices": [1]}) |
| 154 | + assert "Vote submitted" in result |
| 155 | + for invalid in [True, 1.0, "1"]: |
| 156 | + voting.client.reset_mock() |
| 157 | + with pytest.raises(ToolError): |
| 158 | + await tool.run({"chat_id": -123, "message_id": 42, "option_indices": [invalid]}) |
| 159 | + voting.client.assert_not_awaited() |
| 160 | + |
| 161 | + |
| 162 | +def test_vote_exported_for_compatibility_entrypoint(): |
| 163 | + from telegram_mcp.tools import vote_poll |
| 164 | + |
| 165 | + assert vote_poll is messages.vote_poll |
0 commit comments