ButtonBot / tests /test_handleswap.py
github-actions[bot]
Automated sync from GitHub
109b8ca
Raw History Blame Contribute Delete
12.5 kB
"""/handleswap @bro @sis: the username written in posts changes - links, hyperlinks and button links do not."""
import asyncio
import pytest
from telethon import types
from app.handleswap import compute_handle_changes, find_mention_spans, mention_regex
from app.handlers.replace import parse_replace_args
from app.replace_engine import ScanOptions, apply_channel, scan_channel, undo_batch
from app.tgutil import button_url, is_callback_button
from telethon.helpers import add_surrogate
from .dbutil import db_url_for, fresh_db
from .fakes import FakeClient, cb_btn, make_msg, markup, url_btn
from .harness import App, norm
def run(coro):
return asyncio.run(coro)
def change(text, entities=None, mk=None):
return compute_handle_changes(make_msg(1, text, entities, mk), "bro", "sis")
def utf16(s, start, length):
return s.encode("utf-16-le")[start * 2 : (start + length) * 2].decode("utf-16-le")
# ------------------------------------------------------------------------------- what counts as a username
def test_only_whole_usernames_are_found():
rx = mention_regex("bro")
found = lambda s: [m.group(0) for m in rx.finditer(s)] # noqa: E731
assert found("Join @bro now") == ["@bro"]
assert found("@bro") == ["@bro"] and found("(@BRO)!") == ["@BRO"] and found("a,@bro.") == ["@bro"]
assert found("two: @bro @Bro") == ["@bro", "@Bro"]
assert found("Contact:@bro") == ["@bro"]
# parts of other things are not usernames
assert found("@bro_bot @brother @bro2") == []
assert found("mail me: a@bro.com") == []
assert found("https://example.com/x?u=@bro") == []
assert found("t.me/bro and https://t.me/bro?start=1") == []
def test_a_username_in_text_is_swapped_and_the_rest_stays():
text = "Join @bro and @Bro_bot, mail a@bro.com, https://t.me/bro?x=@bro, see t.me/bro, (@BRO)!"
ch, skipped = change(text)
assert ch.n_typed == 2 and ch.n_links == 0 and ch.n_buttons == 0 and skipped == 0
assert ch.new_text == "Join @sis and @Bro_bot, mail a@bro.com, https://t.me/bro?x=@bro, see t.me/bro, (@sis)!"
assert change("nothing to see here")[0] is None
assert change("only links: t.me/bro https://t.me/bro?start=a")[0] is None
def test_formatting_stays_in_place_when_the_name_gets_longer_or_shorter():
text = "Hi @bro, welcome to @bro's place πŸ˜€ and @bro"
ents = [
types.MessageEntityBold(3, 4), # exactly the first @bro
types.MessageEntityMention(3, 4),
types.MessageEntityItalic(9, 7), # ", welcome"[..] runs across text after it
types.MessageEntityCode(len(text.encode("utf-16-le")) // 2 - 4, 4), # the last @bro
]
ch, _ = compute_handle_changes(make_msg(1, text, ents), "bro", "sister")
assert ch.new_text == "Hi @sister, welcome to @sister's place πŸ˜€ and @sister"
bold, mention, italic, code = ch.new_entities
assert utf16(ch.new_text, bold.offset, bold.length) == "@sister" == utf16(ch.new_text, mention.offset, mention.length)
assert utf16(ch.new_text, code.offset, code.length) == "@sister"
assert italic.offset == 3 + 7 + 2 and italic.length == 7 # starts after the longer name: ", welcome" begins 3+7
# shorter name
ch, _ = compute_handle_changes(make_msg(1, "x @brother y", [types.MessageEntityBold(0, 1)]), "brother", "bo")
assert ch.new_text == "x @bo y" and ch.new_entities[0].offset == 0
def test_links_hyperlinks_and_emails_are_left_alone():
text = "@bro writes: https://example.com/p?u=@bro, mail x@bro.com or see @bro"
url_start = len("@bro writes: ")
url = "https://example.com/p?u=@bro"
ents = [
types.MessageEntityUrl(url_start, len(url)),
types.MessageEntityEmail(text.index("x@bro.com"), len("x@bro.com")),
types.MessageEntityTextUrl(len(text) - 4, 4, "https://t.me/bro"), # the last "@bro" is a hyperlink
]
ch, skipped = compute_handle_changes(make_msg(1, text, ents), "bro", "sis")
assert ch.n_typed == 1 and ch.new_text.startswith("@sis writes: https://example.com/p?u=@bro, mail x@bro.com")
assert ch.new_text.endswith("or see @bro") # the hyperlinked @bro is a hyperlink: not touched
assert skipped == 1
link = [e for e in ch.new_entities if isinstance(e, types.MessageEntityTextUrl)][0]
assert link.url == "https://t.me/bro" # and its link is exactly as it was
def test_button_names_change_but_their_links_do_not():
mk = markup(
[url_btn("Join @bro", "https://t.me/bro?start=1"), cb_btn("πŸ‘ @bro", b"r")],
[url_btn("Open https://t.me/bro", "https://t.me/bro")],
)
ch, _ = change("hello", mk=mk)
assert (ch.n_typed, ch.n_buttons, ch.text_changed, ch.callbacks) == (0, 1, False, True)
rows = ch.new_markup.rows
assert rows[0].buttons[0].text == "Join @sis" and button_url(rows[0].buttons[0]) == "https://t.me/bro?start=1"
assert is_callback_button(rows[0].buttons[1]) and rows[0].buttons[1].text == "πŸ‘ @bro" # another bot's button
assert rows[1].buttons[0].text == "Open https://t.me/bro"
# no username anywhere: nothing to do and the keyboard object is not rebuilt
assert change("hello", mk=markup([url_btn("Go", "https://t.me/bro")]))[0] is None
def test_find_mention_spans_counts_what_it_skips():
sur = add_surrogate("@bro and @bro")
spans, skipped = find_mention_spans(sur, [types.MessageEntityTextUrl(9, 4, "https://x.y")], "bro")
assert spans == [(0, 4)] and skipped == 1
def test_the_arguments():
assert parse_replace_args("@bro @sis", handles=True)[:2] == ("bro", "sis")
assert parse_replace_args("bro sis --channel @a --last 50", handles=True)[2] == {"channel": "@a", "last": 50, "posts": False, "typed": None}
for bad in ("@bro", "@bro @bro", "@bro @sis --posts", "@bro @sis --no-typed", "@b!ro @sis"):
with pytest.raises(ValueError):
parse_replace_args(bad, handles=True)
# ------------------------------------------------------------------------------- the engine
def build():
return {
1: make_msg(1, "Join @bro today", [types.MessageEntityBold(5, 4)], out=True),
2: make_msg(2, "Episodes", markup=markup([url_btn("Download @bro", "https://t.me/bro?start=A"), cb_btn("πŸ‘ 3", b"r")]), out=True),
3: make_msg(3, "link only https://t.me/bro?start=B", [types.MessageEntityUrl(10, 24)]),
4: make_msg(4, "by @bro_bot"),
5: make_msg(5, "@BRO wrote this", media=None), # someone else's post
7: types.MessageEmpty(id=7, peer_id=types.PeerChannel(1)),
}
async def engine_setup(tmp_path, msgs):
db = await fresh_db(db_url_for(tmp_path))
await db.save_channel(1, 5, "Test", None, 99)
return db, await db.get_channel(1), FakeClient(msgs, pts=8)
def test_scan_apply_and_undo(tmp_path):
async def go():
msgs = build()
db, ch, client = await engine_setup(tmp_path, msgs)
opts = ScanOptions("bro", "sis", mode="handles")
scan = await scan_channel(client, ch, opts, can_edit_others=True)
assert set(scan.infos) == {1, 2, 5}
assert (scan.infos[1].typed, scan.infos[2].buttons, scan.infos[2].callbacks) == (1, 1, True)
assert scan.foreign == 1
await db.create_batch("hs1", "bro", "sis", 99)
res = await apply_channel(client, db, scan, opts, "hs1", 99, edit_delay=0)
assert (res.edited, res.failed) == (3, [])
assert msgs[1].message == "Join @sis today" and (msgs[1].entities[0].offset, msgs[1].entities[0].length) == (5, 4)
assert msgs[5].message == "@sis wrote this"
rows = msgs[2].reply_markup.rows
assert rows[0].buttons[0].text == "Download @sis" and button_url(rows[0].buttons[0]) == "https://t.me/bro?start=A"
assert is_callback_button(rows[0].buttons[1]) # the other bot's button survives the text edit
assert msgs[2].message == "Episodes" # only the button name changed
assert msgs[3].message == "link only https://t.me/bro?start=B" and msgs[4].message == "by @bro_bot"
# My posts follows
p1 = await db.find_by_message(1, 1)
assert p1.text == "Join @sis today"
p2 = await db.find_by_message(1, 2)
assert p2.buttons[0][0] == {"t": "Download @sis", "u": "https://t.me/bro?start=A"}
back = await undo_batch(client, db, "hs1", edit_delay=0)
assert (back.restored, back.failed) == (3, [])
assert msgs[1].message == "Join @bro today" and msgs[5].message == "@BRO wrote this"
assert msgs[2].reply_markup.rows[0].buttons[0].text == "Download @bro"
await db.close()
run(go())
# --------------------------------------------------------------------------------- the command
async def flow_app(tmp_path, **cfg):
app = App(tmp_path, **cfg)
await app.start()
await app.db.save_channel(1, 5, "Anime Channel", "animech", 1)
app.tg.msgs = build()
app.tg._top = 8
return app
def test_the_whole_command_with_undo(tmp_path):
async def go():
app = await flow_app(tmp_path)
await app.text("/handleswap")
assert "Usage: /handleswap @old @new" in norm(app.out.last_text)
await app.text("/handleswap @bro @sis --posts")
assert "Unknown option --posts" in app.out.last_text
await app.text("/handleswap @bro")
assert "old and the new username" in app.out.last_text
await app.text("/handleswap @bro @sis")
shown = norm(app.out.last_text)
assert "swap the username @bro β†’ @sis" in shown and "3 post(s) to change" in shown
assert "3 mention(s) in the text" not in shown and "2 mention(s) in the text, 1 in button names" in shown
assert "Nothing has been changed yet" in shown and "Apply to 3 post(s)" in str(app.out.last_buttons())
assert app.tg.msgs[1].message == "Join @bro today" # still untouched
await app.press(app.out.callback_data("Apply to 3"))
done = norm(app.out.last_text)
assert "Done - username swap @bro β†’ @sis" in done and "edited: 3" in done
assert app.tg.msgs[1].message == "Join @sis today" and app.tg.msgs[5].message == "@sis wrote this"
assert app.tg.msgs[2].reply_markup.rows[0].buttons[0].text == "Download @sis"
assert "Undo this swap" in str(app.out.last_buttons())
await app.text("/undo")
assert "Undo the username swap @bro β†’ @sis" in norm(app.out.last_text) and "3 post(s)" in norm(app.out.last_text)
await app.press(app.out.callback_data("Yes, undo"))
assert "Restored 3 post(s)" in norm(app.out.last_text)
assert app.tg.msgs[1].message == "Join @bro today" and app.tg.msgs[2].reply_markup.rows[0].buttons[0].text == "Download @bro"
await app.db.close()
run(go())
def test_nothing_to_swap_and_only_the_owner(tmp_path):
async def go():
app = await flow_app(tmp_path)
await app.text("/handleswap @nobody @sis")
assert "Nothing to change" in app.out.last_text and "Apply" not in str(app.out.last_buttons())
app.uid = 99
await app.text("/handleswap @bro @sis")
assert "private" in app.out.last_text
app.uid = 1
await app.text("/undo")
assert "nothing to undo" in app.out.last_text
await app.db.close()
run(go())
def test_it_can_be_limited_to_one_channel(tmp_path):
async def go():
app = await flow_app(tmp_path)
await app.text("/handleswap @bro @sis --channel @nosuchchannel")
assert "isn't registered" in norm(app.out.last_text)
await app.text("/handleswap @bro @sis --channel @animech")
assert "Apply to 3 post(s)" in str(app.out.last_buttons())
await app.db.close()
run(go())
def test_replace_still_works_after_the_refactor(tmp_path):
async def go():
app = await flow_app(tmp_path)
app.tg.msgs[3].reply_markup = markup([url_btn("Get", "https://t.me/bro?start=1")])
await app.text("/swap @bro @sis")
shown = norm(app.out.last_text)
assert "replace @bro β†’ @sis" in shown and "Apply to" in str(app.out.last_buttons())
await app.press(app.out.callback_data("Apply to"))
assert "Done - @bro β†’ @sis" in norm(app.out.last_text)
assert button_url(app.tg.msgs[3].reply_markup.rows[0].buttons[0]) == "https://t.me/sis?start=1"
assert app.tg.msgs[1].message == "Join @bro today" # /swap never touches a plain username
await app.text("/undo")
assert "Undo the replace @bro β†’ @sis" in norm(app.out.last_text)
await app.db.close()
run(go())