ButtonBot / tests /test_autopost.py
github-actions[bot]
Automated sync from GitHub
65651b1
Raw History Blame Contribute Delete
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())