File size: 6,710 Bytes
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5bcd8c9
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
"""Check that every Telegram call the bot makes builds a valid, serialisable MTProto request.

Telethon's network layer is replaced by a function that captures the request (after Telethon has
resolved it) and serialises it, so wrong argument types or shapes fail here instead of in production.
"""
import asyncio
import datetime

import pytest
from telethon import TelegramClient, functions, types, utils
from telethon.sessions import MemorySession

from app.common import push_edit, send_post
from app.tgutil import (
    build_markup,
    de_entities,
    edit_raw,
    media_from_ref,
    media_ref,
    peer_of,
    ser_entities,
)
from app.ui import button_keyboard, panel_keyboard

from .fakes import make_msg

NOW = datetime.datetime.now(datetime.timezone.utc)
CH = type("Ch", (), {"id": 1234, "access_hash": 5678})()


class Captured(Exception):
    def __init__(self, req):
        self.req = req


@pytest.fixture()
def client(monkeypatch):
    async def fake_call(self, request, ordered=False, flood_sleep_threshold=None):
        reqs = list(request) if isinstance(request, (list, tuple)) else [request]
        for r in reqs:
            if hasattr(r, "resolve"):
                await r.resolve(self, utils)
            assert isinstance(bytes(r), bytes)  # must serialise
        raise Captured(reqs[0])

    monkeypatch.setattr(TelegramClient, "__call__", fake_call)
    return TelegramClient(MemorySession(), 1, "h")


def capture(coro):
    async def go():
        try:
            await coro
        except Captured as c:
            return c.req
        raise AssertionError("no request was made")

    return asyncio.run(go())


def post(**kw):
    base = dict(
        id=1, message_id=50, text="Hello πŸ˜€", entities=[{"k": "bold", "o": 0, "l": 5}, {"k": "url", "o": 6, "l": 2, "u": "https://x.com"}],
        media_kind=None, media_file_id=None, link_preview=False,
        buttons=[[{"t": "Go", "u": "https://t.me/bot?start=a"}, {"t": "Two", "u": "https://t.me/bot?start=b"}]],
    )
    base.update(kw)
    return type("P", (), base)()


def photo_ref():
    photo = types.Photo(11, 22, b"ref", NOW, [types.PhotoSize("x", 100, 100, 999)], 2)
    return media_ref(make_msg(1, "", media=types.MessageMediaPhoto(photo=photo)))


def test_send_text_post(client):
    req = capture(send_post(client, peer_of(CH), post()))
    assert isinstance(req, functions.messages.SendMessageRequest)
    assert req.message == "Hello πŸ˜€" and req.no_webpage is True
    assert [type(e).__name__ for e in req.entities] == ["MessageEntityBold", "MessageEntityTextUrl"]
    assert req.reply_markup.rows[0].buttons[1].text == "Two"
    assert isinstance(req.peer, types.InputPeerChannel)


def test_send_media_post_with_and_without_file_reference(client):
    ref = photo_ref()
    req = capture(send_post(client, peer_of(CH), post(media_kind="photo", media_file_id=ref)))
    assert isinstance(req, functions.messages.SendMediaRequest)
    assert isinstance(req.media, types.InputMediaPhoto) and req.media.id.id == 11 and req.media.id.file_reference == b"ref"
    assert req.message == "Hello πŸ˜€" and req.reply_markup is not None

    async def blank():
        c = client
        _, media, _ = await c._file_to_media(media_from_ref(ref, blank_reference=True))
        return media

    assert asyncio.run(blank()).id.file_reference == b""


def test_raw_edits(client):
    p = post()
    req = capture(push_edit(client, CH, p))
    assert isinstance(req, functions.messages.EditMessageRequest)
    assert req.id == 50 and req.message == "Hello πŸ˜€" and req.no_webpage is True and req.reply_markup is not None
    # no buttons -> no reply_markup (Telegram then removes the keyboard)
    req = capture(push_edit(client, CH, post(buttons=[])))
    assert req.reply_markup is None and req.message == "Hello πŸ˜€"
    # markup-only edit leaves the text alone
    req = capture(edit_raw(client, peer_of(CH), 9, markup=build_markup([[{"t": "A", "u": "https://a.com"}]])))
    assert req.message is None and req.entities is None and req.reply_markup.rows[0].buttons[0].text == "A"


def test_media_replacement_edit(client):
    ref = photo_ref()
    req = capture(push_edit(client, CH, post(media_kind="photo", media_file_id=ref), replace_media=True))
    assert isinstance(req, functions.messages.EditMessageRequest)
    assert isinstance(req.media, types.InputMediaPhoto) and req.message == "Hello πŸ˜€"
    assert req.reply_markup is not None


def test_reads_deletes_and_rights_requests(client):
    peer = peer_of(CH)
    req = capture(client.get_messages(peer, ids=[1, 2, 3]))
    assert isinstance(req, functions.channels.GetMessagesRequest)
    assert isinstance(req.channel, types.InputChannel) and [i.id for i in req.id] == [1, 2, 3]
    req = capture(client.delete_messages(peer, [7]))
    assert isinstance(req, functions.channels.DeleteMessagesRequest) and req.id == [7]
    req = capture(client(functions.channels.GetFullChannelRequest(peer)))
    assert isinstance(req.channel, types.InputChannel)
    req = capture(client(functions.channels.GetParticipantRequest(channel=peer, participant=types.InputPeerSelf())))
    assert isinstance(req.participant, types.InputPeerSelf)


def test_entities_with_every_stored_kind_serialise():
    ents = [
        types.MessageEntityBold(0, 1), types.MessageEntityItalic(1, 1), types.MessageEntityUnderline(2, 1),
        types.MessageEntityStrike(3, 1), types.MessageEntitySpoiler(4, 1), types.MessageEntityCode(5, 1),
        types.MessageEntityPre(6, 1, "py"), types.MessageEntityTextUrl(7, 1, "https://x.com"),
        types.MessageEntityBlockquote(8, 1, True), types.MessageEntityCustomEmoji(9, 2, 123456789),
    ]
    back = de_entities(ser_entities(ents))
    assert [type(e) for e in back] == [type(e) for e in ents]
    assert back[8].collapsed is True
    for e in back:
        bytes(e)


def test_ui_keyboards_serialise(client):
    p = post()
    p.status, p.channel_id, p.source = "draft", 1, "bot"
    p.buttons = [[{"t": "A", "u": "https://a.com"}, {"t": "⚠️ no link", "u": None}], [{"t": "πŸ‘", "raw": ""}]]
    for kb in (panel_keyboard(p), button_keyboard(p, 0, 0), button_keyboard(p, 1, 0)):
        mk = client.build_reply_markup(kb)
        assert isinstance(bytes(mk), bytes)
    for label_row in panel_keyboard(p):
        for b in label_row:
            assert len(b.type.data) <= 64  # Telegram's limit for callback data


def test_button_callback_data_fits_for_large_ids():
    p = post(id=2_000_000_000)
    p.status, p.channel_id, p.source = "sent", -1, "bot"
    p.buttons = [[{"t": "x", "u": "https://a.com"}] * 8] * 12
    for row in panel_keyboard(p):
        for b in row:
            assert len(b.type.data) <= 64