Spaces:
Paused
Paused
Download tests/test_autopost.py from Mega-Evolutions/ButtonBot: direct link, hf CLI and curl.
- Browser
- Download file 22.3 kB
-
https://huggingface.co/spaces/Mega-Evolutions/ButtonBot/resolve/main/tests/test_autopost.py
- Command line
-
hf download hf://spaces/Mega-Evolutions/ButtonBot/tests/test_autopost.py
-
curl -L -o test_autopost.py https://huggingface.co/spaces/Mega-Evolutions/ButtonBot/resolve/main/tests/test_autopost.py
22.3 kB
| """/autopost (a series of episode posts, then the real links) and "Mass replace links" in a post's panel.""" | |
| import asyncio | |
| import pytest | |
| from telethon import errors | |
| from app.autopost import ( | |
| PLACEHOLDER_LINK, | |
| button_label, | |
| parse_autopost_args, | |
| plan_ranges, | |
| post_text, | |
| ranges_summary, | |
| ) | |
| from app.tgutil import button_url | |
| from .harness import App, norm | |
| def run(coro): | |
| return asyncio.run(coro) | |
| async def make_app(tmp_path, **cfg): | |
| app = App(tmp_path, edit_delay=0, **cfg) | |
| await app.start() | |
| await app.db.save_channel(1, 5, "Anime Channel", "animech", 1) | |
| return app | |
| def sent_markup(entry): | |
| return entry[3]["buttons"] | |
| def link_edits(app): | |
| """(message id, new link) for every keyboard-only edit the bot made.""" | |
| out = [] | |
| for kind, mid, req in app.tg.edits: | |
| if kind == "raw" and req.message is None and req.reply_markup is not None: | |
| out.append((mid, button_url(req.reply_markup.rows[0].buttons[0]))) | |
| return out | |
| # ------------------------------------------------------------------------------- the plan | |
| def test_the_ranges_follow_the_interval(): | |
| assert plan_ranges(114, 20) == [(1, 20), (21, 40), (41, 60), (61, 80), (81, 100), (101, 114)] | |
| assert plan_ranges(100, 20) == [(1, 20), (21, 40), (41, 60), (61, 80), (81, 100)] | |
| assert plan_ranges(20, 20) == [(1, 20)] | |
| def test_a_short_end_is_added_to_the_post_before_it(): | |
| assert plan_ranges(102, 20)[-2:] == [(61, 80), (81, 102)] # 2 left over: less than half of 20 | |
| assert plan_ranges(110, 20)[-1] == (81, 110) # exactly half of 20: still added | |
| assert plan_ranges(111, 20)[-2:] == [(81, 100), (101, 111)] # 11 is more than half: its own post | |
| assert plan_ranges(114, 20)[-1] == (101, 114) # 14 is more than half | |
| assert plan_ranges(22, 15) == [(1, 22)] # half of 15 is 7.5: 7 left over is added ... | |
| assert plan_ranges(23, 15) == [(1, 15), (16, 23)] # ... 8 is not | |
| def test_a_total_below_the_interval_is_one_post_and_small_intervals_work(): | |
| assert plan_ranges(14, 20) == [(1, 14)] | |
| assert plan_ranges(3, 20) == [(1, 3)] | |
| assert plan_ranges(3, 1) == [(1, 1), (2, 2), (3, 3)] | |
| assert plan_ranges(5, 2) == [(1, 2), (3, 5)] # one left over = half of 2: added to the post before it | |
| with pytest.raises(ValueError): | |
| plan_ranges(0, 20) | |
| def test_every_episode_is_in_exactly_one_post(): | |
| for total in range(1, 130): | |
| for interval in (1, 2, 3, 7, 10, 15, 20, 25): | |
| ranges = plan_ranges(total, interval) | |
| flat = [n for a, b in ranges for n in range(a, b + 1)] | |
| assert flat == list(range(1, total + 1)), (total, interval) | |
| assert all(b - a + 1 <= interval + interval // 2 for a, b in ranges) | |
| def test_texts_are_the_ones_asked_for(): | |
| assert post_text(1, 20) == "Episodes 01 to 20\n\nClick the button below to download ๐" | |
| assert post_text(101, 114) == "Episodes 101 to 114\n\nClick the button below to download ๐" | |
| assert button_label(21, 40) == "Download Episodes 21 to 40" | |
| assert button_label(5, 5) == "Download Episode 05" | |
| assert ranges_summary(plan_ranges(114, 20)) == "01โ20 ยท 21โ40 ยท 41โ60 ยท 61โ80 ยท 81โ100 ยท 101โ114" | |
| assert ranges_summary([(i, i) for i in range(1, 40)]).endswith("โฆ ยท 38 ยท 39") | |
| def test_the_arguments(): | |
| assert parse_autopost_args("114 20") == (114, 20) | |
| assert parse_autopost_args(" 12, 5 ") == (12, 5) | |
| for bad in ("", "114", "114 20 3", "a b", "0 20", "20 0", "-5 3", "1.5 2", "999999999 20"): | |
| with pytest.raises(ValueError): | |
| parse_autopost_args(bad) | |
| # ------------------------------------------------------------------------------- the command | |
| def test_the_whole_autopost_flow(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path) | |
| await app.text("/autopost 114 20") | |
| shown = norm(app.out.last_text) | |
| assert "6 post(s)" in shown and "01โ20 ยท 21โ40 ยท 41โ60 ยท 61โ80 ยท 81โ100 ยท 101โ114" in shown | |
| assert "Which channel" in shown and "1. Anime Channel" in shown and app.out.last_buttons()[0] == ["1"] | |
| await app.press(app.out.callback_data("Anime Channel")) | |
| posted = [e for e in app.tg.sent if e[0] == "text" and e[1].channel_id == 1] | |
| assert len(posted) == 6 | |
| assert [e[2] for e in posted] == [post_text(a, b) for a, b in plan_ranges(114, 20)] | |
| for (a, b), entry in zip(plan_ranges(114, 20), posted): | |
| row = sent_markup(entry).rows[0].buttons | |
| assert len(row) == 1 and row[0].text == f"Download Episodes {a:02d} to {b:02d}" | |
| assert button_url(row[0]) == PLACEHOLDER_LINK == "https://t.me/English" | |
| assert entry[3]["link_preview"] is False | |
| # all six are in My posts, as published posts of this channel | |
| posts = await app.db.sent_posts(1) | |
| assert [p.message_id for p in posts] == [100, 101, 102, 103, 104, 105] | |
| assert posts[0].source == "bot" and posts[0].buttons == [[{"t": "Download Episodes 01 to 20", "u": PLACEHOLDER_LINK}]] | |
| # the first link is asked for at once, with a cancel button | |
| ask = norm(app.out.last_text) | |
| assert "1 of 6" in ask and "Download Episodes 01 to 20" in ask and PLACEHOLDER_LINK in ask | |
| assert "Cancel" in str(app.out.last_buttons()) and "Skip" in str(app.out.last_buttons()) | |
| links = [f"https://t.me/mybot?start=part{i}" for i in range(1, 7)] | |
| for i, url in enumerate(links): | |
| await app.text(url) | |
| if i < 5: | |
| nxt = norm(app.out.last_text) | |
| assert f"{i + 2} of 6" in nxt and "now opens" in nxt and url in nxt | |
| a, b = plan_ranges(114, 20)[i + 1] | |
| assert f"Download Episodes {a:02d} to {b:02d}" in nxt | |
| assert "All done" in app.out.last_text and "6 link(s) replaced" in app.out.last_text | |
| # each answer changed only the keyboard of its own message, at once | |
| assert link_edits(app) == [(100 + i, links[i]) for i in range(6)] | |
| assert [button_url(app.tg.edits[i][2].reply_markup.rows[0].buttons[0]) for i in range(6)] == links | |
| posts = await app.db.sent_posts(1) | |
| assert [p.buttons[0][0]["u"] for p in posts] == links | |
| assert [p.buttons[0][0]["t"] for p in posts][-1] == "Download Episodes 101 to 114" | |
| assert 1 not in app.ctx.state # nothing left to answer | |
| await app.db.close() | |
| run(go()) | |
| def test_the_short_end_gets_its_own_post_or_joins_the_last(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path) | |
| await app.text("/autopost 102 20") | |
| await app.press(app.out.callback_data("Anime Channel")) | |
| texts = [e[2].splitlines()[0] for e in app.tg.sent if e[0] == "text" and e[1].channel_id == 1] | |
| assert texts == ["Episodes 01 to 20", "Episodes 21 to 40", "Episodes 41 to 60", "Episodes 61 to 80", "Episodes 81 to 102"] | |
| await app.db.close() | |
| run(go()) | |
| def test_admins_may_use_it_and_strangers_may_not(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path, admins=frozenset({7})) | |
| app.uid = 7 | |
| await app.text("/autopost 40 20") | |
| assert "Which channel" in norm(app.out.last_text) | |
| await app.press(app.out.callback_data("Anime Channel")) | |
| assert len([e for e in app.tg.sent if e[0] == "text" and e[1].channel_id == 1]) == 2 | |
| app.uid = 99 | |
| before = len(app.tg.sent) | |
| await app.text("/autopost 40 20") | |
| assert "private" in app.out.last_text and len(app.tg.sent) == before | |
| await app.db.close() | |
| run(go()) | |
| def test_wrong_numbers_and_no_channels_are_explained(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path) | |
| await app.text("/autopost") | |
| assert "Usage" in norm(app.out.last_text) and "/autopost 114 20" in norm(app.out.last_text) | |
| for bad in ("114", "x y", "0 5", "114 20 5"): | |
| await app.text(f"/autopost {bad}") | |
| assert "Usage: /autopost <total episodes> <interval>" in norm(app.out.last_text), bad | |
| await app.text("/autopost 5000 1") | |
| assert "at most 200" in app.out.last_text | |
| assert not [e for e in app.tg.sent if e[0] == "text"] # nothing was posted by any of those | |
| (tmp_path / "empty").mkdir() | |
| empty = App(tmp_path / "empty", edit_delay=0) | |
| await empty.start() | |
| await empty.text("/autopost 114 20") | |
| assert "No channels yet" in empty.out.last_text | |
| await empty.db.close() | |
| await app.db.close() | |
| run(go()) | |
| def test_cancelling_the_channel_choice_posts_nothing(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path) | |
| await app.text("/autopost 114 20") | |
| await app.press(app.out.callback_data("Cancel")) | |
| assert "Cancelled" in app.out.last_text | |
| assert not [e for e in app.tg.sent if e[0] == "text"] | |
| # an old button does nothing any more | |
| await app.press("apc:" + "0" * 8 + ":1") | |
| assert "expired" in app.out.last_text | |
| await app.db.close() | |
| run(go()) | |
| def test_a_double_press_posts_once(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path) | |
| await app.text("/autopost 40 20") | |
| data = app.out.callback_data("Anime Channel") | |
| await app.press(data) | |
| await app.press(data) | |
| assert "expired" in app.out.last_text | |
| assert len([e for e in app.tg.sent if e[0] == "text" and e[1].channel_id == 1]) == 2 | |
| await app.db.close() | |
| run(go()) | |
| def test_the_bot_needs_the_post_right_and_a_free_hand(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path) | |
| app.tg.rights = dict(admin=True, post=False, edit=True, delete=True, invite=False, add_admins=False) | |
| await app.text("/autopost 40 20") | |
| pick = app.out.callback_data("Anime Channel") | |
| await app.press(pick) | |
| assert "Post messages" in app.out.last_text | |
| assert not [e for e in app.tg.sent if e[0] == "text"] | |
| # the plan is still there: with the right given, the same button works - but not while a long job runs | |
| app.tg.rights["post"] = True | |
| await app.ctx.lock.acquire() | |
| await app.press(pick) | |
| assert "still running" in app.out.last_text | |
| app.ctx.lock.release() | |
| await app.press(pick) | |
| assert len([e for e in app.tg.sent if e[0] == "text" and e[1].channel_id == 1]) == 2 | |
| await app.db.close() | |
| run(go()) | |
| def test_when_telegram_stops_midway_the_posts_made_still_get_their_links(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path) | |
| real = app.tg.send_message | |
| calls = [] | |
| async def flaky(peer, message="", **kw): | |
| calls.append(message) | |
| if len(calls) == 3: | |
| raise errors.ChatWriteForbiddenError(request=None) | |
| return await real(peer, message, **kw) | |
| app.tg.send_message = flaky | |
| await app.text("/autopost 114 20") | |
| await app.press(app.out.callback_data("Anime Channel")) | |
| # the summary (an edit of the picker) tells where it stopped; the next message asks for the first link | |
| summary = [norm(t) for k, t, _ in app.out.log if k == "edit" and t and "Stopped early" in t] | |
| assert summary and "Posted 2 of 6" in summary[0] and "Episodes 41 to 60" in summary[0] | |
| assert "1 of 2" in norm(app.out.last_text) | |
| assert len(await app.db.sent_posts(1)) == 2 | |
| await app.text("https://t.me/mybot?start=1") | |
| await app.text("https://t.me/mybot?start=2") | |
| assert "All done" in app.out.last_text and "2 link(s) replaced" in app.out.last_text | |
| await app.db.close() | |
| run(go()) | |
| def test_nothing_posted_means_a_plain_error(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path) | |
| async def refuse(peer, message="", **kw): | |
| raise errors.ChatWriteForbiddenError(request=None) | |
| app.tg.send_message = refuse | |
| await app.text("/autopost 40 20") | |
| await app.press(app.out.callback_data("Anime Channel")) | |
| assert "Nothing was posted" in app.out.last_text and "Episodes 01 to 20" in app.out.last_text | |
| assert await app.db.sent_posts(1) == [] | |
| await app.db.close() | |
| run(go()) | |
| def test_skipping_and_stopping_while_the_links_are_asked(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path) | |
| await app.text("/autopost 80 20") | |
| await app.press(app.out.callback_data("Anime Channel")) | |
| await app.text("https://t.me/mybot?start=1") | |
| assert "2 of 4" in norm(app.out.last_text) | |
| await app.press(app.out.callback_data("Skip")) # keeps the placeholder | |
| assert "Skipped" in app.out.last_text and "3 of 4" in norm(app.out.last_text) | |
| cancel = app.out.callback_data("Cancel") | |
| # the Skip button of the earlier message is stale now | |
| await app.press("lqs:1") | |
| assert "earlier step" in app.out.last_text | |
| await app.press(cancel) | |
| shown = norm(app.out.last_text) | |
| assert "Stopped" in shown and "1 link(s) replaced" in shown and "1 skipped" in shown and "2 not asked" in shown | |
| assert 1 not in app.ctx.state | |
| posts = await app.db.sent_posts(1) | |
| assert [p.buttons[0][0]["u"] for p in posts] == ["https://t.me/mybot?start=1", PLACEHOLDER_LINK, PLACEHOLDER_LINK, PLACEHOLDER_LINK] | |
| # nothing more is expected from the user | |
| await app.text("https://t.me/whatever") | |
| assert "Use /new" in app.out.last_text | |
| await app.db.close() | |
| run(go()) | |
| def test_something_that_is_not_a_link_is_asked_again_and_a_refusal_keeps_the_step(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path) | |
| await app.text("/autopost 40 20") | |
| await app.press(app.out.callback_data("Anime Channel")) | |
| await app.text("hello there") | |
| assert "doesn't look like a link" in norm(app.out.last_text) | |
| # Telegram refuses the keyboard edit: the saved link is unchanged and the same post is asked again | |
| app.tg.fail["EditMessageRequest"] = errors.ChatAdminRequiredError(request=None) | |
| await app.text("t.me/mybot?start=1") | |
| assert "NOT changed" in app.out.last_text | |
| assert (await app.db.sent_posts(1))[0].buttons[0][0]["u"] == PLACEHOLDER_LINK | |
| app.tg.fail.clear() | |
| await app.text("t.me/mybot?start=1") | |
| assert "now opens" in app.out.last_text and "https://t.me/mybot?start=1" in app.out.last_text | |
| assert (await app.db.sent_posts(1))[0].buttons[0][0]["u"] == "https://t.me/mybot?start=1" | |
| await app.db.close() | |
| run(go()) | |
| # ------------------------------------------------------------------- mass replace links | |
| def sent(app, pid_text, message_id, buttons, channel_id=1, text=None): | |
| return app.db.create_post( | |
| channel_id=channel_id, message_id=message_id, status="sent", source="bot", text=text or pid_text, | |
| entities=[], buttons=buttons, link_preview=False, | |
| ) | |
| def one(label, url): | |
| return [[{"t": label, "u": url}]] | |
| async def channel_with_posts(app): | |
| """#1 text, #2 button, #3 text, #4 only another bot's button, #5 button, #6 two buttons, #7 button in ANOTHER channel.""" | |
| await app.db.save_channel(2, 6, "Other", "other", 1) | |
| posts = {} | |
| posts[1] = await sent(app, "just text", 10, []) | |
| posts[2] = await sent(app, "Episodes 01 to 20", 11, one("Download Episodes 01 to 20", "https://t.me/old?start=a")) | |
| posts[3] = await sent(app, "plain again", 12, []) | |
| posts[4] = await sent(app, "reactions only", 13, [[{"t": "๐ 3", "raw": "AAAA"}]]) | |
| posts[5] = await sent(app, "Episodes 21 to 40", 14, one("Download Episodes 21 to 40", "https://t.me/old?start=b")) | |
| posts[6] = await sent( | |
| app, "Choose", 15, [[{"t": "480p", "u": "https://t.me/old?start=c"}, {"t": "720p", "u": "https://t.me/old?start=d"}]], | |
| ) | |
| posts[7] = await sent(app, "elsewhere", 5, one("Other channel", "https://t.me/old?start=z"), channel_id=2) | |
| return posts | |
| def test_only_published_posts_with_a_link_button_get_the_option(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path) | |
| posts = await channel_with_posts(app) | |
| draft = await app.db.create_post(channel_id=1, status="draft", source="bot", text="d", entities=[], buttons=one("Get", "https://t.me/x")) | |
| for key, expected in ((1, False), (2, True), (4, False), (5, True), (6, True)): | |
| await app.press(f"po:{posts[key].id}") | |
| assert ("Mass replace links" in str(app.out.last_buttons())) is expected, key | |
| await app.press(f"po:{draft.id}") | |
| assert "Mass replace links" not in str(app.out.last_buttons()) | |
| await app.press(f"mlp:{draft.id}") | |
| assert "published posts only" in app.out.last_text | |
| await app.press(f"mlp:{posts[1].id}") | |
| assert "no button with a link" in app.out.last_text | |
| await app.db.close() | |
| run(go()) | |
| def test_mass_replace_walks_through_the_button_posts_of_the_channel(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path) | |
| posts = await channel_with_posts(app) | |
| await app.press(f"po:{posts[2].id}") | |
| await app.press(app.out.callback_data("Mass replace links")) | |
| ask = norm(app.out.last_text) | |
| assert "Mass replace links" in ask and "1 of 4" in ask and "Download Episodes 01 to 20" in ask | |
| assert "https://t.me/old?start=a" in ask and f"Post #{posts[2].id}" in ask | |
| assert "Cancel" in str(app.out.last_buttons()) | |
| await app.text("https://t.me/new?start=1") | |
| ask = norm(app.out.last_text) # the text posts (#3) and the reactions-only post (#4) are skipped | |
| assert "2 of 4" in ask and "Download Episodes 21 to 40" in ask and f"Post #{posts[5].id}" in ask | |
| assert "now opens https://t.me/new?start=1" in ask | |
| await app.text("https://t.me/new?start=2") | |
| ask = norm(app.out.last_text) | |
| assert "3 of 4" in ask and "480p" in ask and "https://t.me/old?start=c" in ask | |
| await app.text("https://t.me/new?start=3") | |
| assert "4 of 4" in norm(app.out.last_text) and "720p" in norm(app.out.last_text) | |
| await app.text("https://t.me/new?start=4") | |
| assert "All done" in app.out.last_text and "4 link(s) replaced" in app.out.last_text | |
| # keyboard-only edits: the text of the posts was never sent again | |
| edits = [(mid, req) for kind, mid, req in app.tg.edits if kind == "raw"] | |
| assert [mid for mid, _ in edits] == [11, 14, 15, 15] | |
| assert all(req.message is None for _, req in edits) | |
| last = edits[-1][1].reply_markup.rows[0].buttons | |
| assert [button_url(b) for b in last] == ["https://t.me/new?start=3", "https://t.me/new?start=4"] | |
| assert [b.text for b in last] == ["480p", "720p"] | |
| fresh = {k: await app.db.get_post(p.id) for k, p in posts.items()} | |
| assert fresh[2].buttons == one("Download Episodes 01 to 20", "https://t.me/new?start=1") | |
| assert fresh[5].buttons[0][0]["u"] == "https://t.me/new?start=2" | |
| assert [b["u"] for b in fresh[6].buttons[0]] == ["https://t.me/new?start=3", "https://t.me/new?start=4"] | |
| assert fresh[4].buttons == [[{"t": "๐ 3", "raw": "AAAA"}]] # another bot's button: untouched | |
| assert fresh[7].buttons[0][0]["u"] == "https://t.me/old?start=z" # another channel: untouched | |
| assert fresh[2].text == "Episodes 01 to 20" | |
| await app.db.close() | |
| run(go()) | |
| def test_it_starts_at_the_chosen_post_and_goes_towards_the_newest(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path) | |
| posts = await channel_with_posts(app) | |
| await app.press(f"mlp:{posts[5].id}") # #2 is older: not asked | |
| assert "1 of 3" in norm(app.out.last_text) and "Download Episodes 21 to 40" in norm(app.out.last_text) | |
| await app.press(app.out.callback_data("Skip")) | |
| assert "2 of 3" in norm(app.out.last_text) and "480p" in norm(app.out.last_text) | |
| await app.press(app.out.callback_data("Cancel")) | |
| shown = norm(app.out.last_text) | |
| assert "Stopped" in shown and "1 skipped" in shown and "2 not asked" in shown | |
| assert (await app.db.get_post(posts[5].id)).buttons[0][0]["u"] == "https://t.me/old?start=b" | |
| assert not [1 for kind, mid, req in app.tg.edits if kind == "raw"] | |
| await app.db.close() | |
| run(go()) | |
| def test_a_post_that_disappears_meanwhile_is_passed_over(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path) | |
| posts = await channel_with_posts(app) | |
| await app.press(f"mlp:{posts[2].id}") | |
| await app.db.delete_post(posts[5].id) # forgotten in the bot while the list is open | |
| await app.text("https://t.me/new?start=1") | |
| assert "3 of 4" in norm(app.out.last_text) and "480p" in norm(app.out.last_text) # #5 was passed over | |
| await app.text("https://t.me/new?start=2") | |
| await app.text("https://t.me/new?start=3") | |
| assert "All done" in app.out.last_text | |
| assert "3 link(s) replaced" in app.out.last_text and "1 button(s) no longer there" in app.out.last_text | |
| await app.db.close() | |
| run(go()) | |
| def test_telegram_refusing_a_link_keeps_the_same_step(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path) | |
| posts = await channel_with_posts(app) | |
| await app.press(f"mlp:{posts[2].id}") | |
| skip = app.out.callback_data("Skip") | |
| app.tg.fail["EditMessageRequest"] = errors.MessageIdInvalidError(request=None) | |
| await app.text("https://t.me/new?start=1") | |
| assert "NOT changed" in app.out.last_text | |
| assert (await app.db.get_post(posts[2].id)).buttons[0][0]["u"] == "https://t.me/old?start=a" | |
| app.tg.fail.clear() | |
| await app.press(skip) | |
| assert "2 of 4" in norm(app.out.last_text) | |
| await app.db.close() | |
| run(go()) | |
| def test_the_last_button_post_ends_the_list(tmp_path): | |
| async def go(): | |
| app = await make_app(tmp_path) | |
| p = await sent(app, "only one", 3, one("Get", "https://t.me/old")) | |
| await app.press(f"mlp:{p.id}") | |
| assert "1 of 1" in norm(app.out.last_text) | |
| await app.text("https://t.me/new") | |
| assert "All done" in app.out.last_text and "1 link(s) replaced" in app.out.last_text | |
| await app.db.close() | |
| run(go()) | |