Spaces:
Paused
Paused
Download tests/test_backup.py from Mega-Evolutions/ButtonBot: direct link, hf CLI and curl.
- Browser
- Download file 21.9 kB
-
https://huggingface.co/spaces/Mega-Evolutions/ButtonBot/resolve/main/tests/test_backup.py
- Command line
-
hf download hf://spaces/Mega-Evolutions/ButtonBot/tests/test_backup.py
-
curl -L -o test_backup.py https://huggingface.co/spaces/Mega-Evolutions/ButtonBot/resolve/main/tests/test_backup.py
21.9 kB
| """/export and /import: a backup can be put back, only adds, and never trusts what is in the file.""" | |
| import asyncio | |
| import datetime | |
| import json | |
| from types import SimpleNamespace | |
| import pytest | |
| from app.backup import ( | |
| MAX_BYTES, | |
| BackupError, | |
| apply_import, | |
| channel_row, | |
| parse_backup, | |
| plan_import, | |
| post_row, | |
| stamp, | |
| ) | |
| from app.db import utcnow | |
| from .dbutil import db_url_for, fresh_db | |
| from .harness import App, FakeEvent, norm | |
| T0 = datetime.datetime(2026, 10, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) | |
| def run(coro): | |
| return asyncio.run(coro) | |
| def backup_bytes(**parts): | |
| data = {"exported_at": T0.isoformat(), "channels": [], "posts": [], "ignored": []} | |
| data.update(parts) | |
| return json.dumps(data).encode() | |
| def chan(cid=1, **kw): | |
| return {"id": cid, "access_hash": 5, "title": f"Channel {cid}", "username": f"ch{cid}", "active": True, | |
| "added_by": 1, "created_at": T0.isoformat(), **kw} | |
| def post(cid=1, mid=10, **kw): | |
| base = {"channel_id": cid, "message_id": mid, "status": "sent", "source": "bot", "text": f"post {mid}", "entities": [], | |
| "media_kind": None, "media_file_id": None, "buttons": [], "link_preview": False, "created_by": 1, | |
| "created_at": T0.isoformat(), "updated_at": T0.isoformat(), "sent_at": T0.isoformat()} | |
| base.update(kw) | |
| return base | |
| async def fresh(tmp_path, name="a"): | |
| d = tmp_path / name | |
| d.mkdir(exist_ok=True) | |
| return await fresh_db(db_url_for(d)) | |
| # ================================================================================================ reading the file | |
| def test_a_good_file_is_read_completely(): | |
| posts = [ | |
| post(1, 10, entities=[{"k": "bold", "o": 0, "l": 4}], buttons=[[{"t": "Watch", "u": "https://t.me/a/1"}]]), | |
| post(1, None, status="draft", text="not sent yet"), | |
| post(2, 7, source="adopted", media_kind="photo", media_file_id="abc"), | |
| ] | |
| bk = parse_backup(backup_bytes(channels=[chan(1), chan(2, username=None, active=False)], posts=posts, ignored=[{"channel_id": 1, "message_id": 4}])) | |
| assert [c["id"] for c in bk.channels] == [1, 2] and bk.channels[1]["active"] is False and bk.channels[1]["username"] is None | |
| assert [(p["channel_id"], p["message_id"], p["status"]) for p in bk.posts] == [(1, 10, "sent"), (1, None, "draft"), (2, 7, "sent")] | |
| assert bk.posts[0]["buttons"] == [[{"t": "Watch", "u": "https://t.me/a/1"}]] and bk.posts[2]["source"] == "adopted" | |
| assert bk.posts[0]["created_at"] == T0 and bk.ignored == [(1, 4)] and bk.bad_rows == 0 | |
| assert bk.exported_at == T0.isoformat() | |
| def test_a_file_with_a_byte_order_mark_and_old_style_still_reads(): | |
| raw = b"\xef\xbb\xbf" + json.dumps({"channels": [chan(1)], "posts": [post(1, 3)]}).encode() # no "ignored", no "exported_at" | |
| bk = parse_backup(raw) | |
| assert len(bk.channels) == 1 and len(bk.posts) == 1 and bk.ignored == [] and bk.exported_at is None | |
| def test_a_file_that_is_not_a_backup_is_refused_with_the_reason(raw, why): | |
| with pytest.raises(BackupError) as e: | |
| parse_backup(raw) | |
| assert why in str(e.value) | |
| def test_unusable_entries_are_counted_and_left_out(): | |
| rows = [ | |
| post(1, 10), | |
| post(1, 11, status="weird"), # unknown status | |
| post(1, None), # published without a message id | |
| post(1, 12, text=5), # text is not text | |
| post(1, 13, entities="bold"), # formatting is not a list | |
| post(1, 14, buttons=[["not a button"]]), # buttons are not dicts | |
| post(1, 15, media_kind="x" * 40), | |
| post(1, 16, created_at="yesterday"), | |
| post("one", 17), | |
| post(True, 18), # a boolean is not a channel id | |
| "junk", | |
| None, | |
| ] | |
| bk = parse_backup(backup_bytes(channels=[chan(1), chan(0), {"title": "no id"}], posts=rows, ignored=[{"channel_id": 1}, "x", {"channel_id": 1, "message_id": 3}])) | |
| assert [p["message_id"] for p in bk.posts] == [10] | |
| assert [c["id"] for c in bk.channels] == [1] and bk.ignored == [(1, 3)] | |
| assert bk.bad_rows == 11 + 2 + 2 - 1 + 1 # 11 bad posts (one of them None counts too), 2 channels, 2 marks | |
| def test_rows_are_cleaned_while_they_are_read(): | |
| p = post_row({"channel_id": 3, "message_id": 4, "status": "draft", "created_at": "2026-10-01T12:00:00Z"}) | |
| assert p["message_id"] is None and p["status"] == "draft" and p["created_at"] == T0 and p["updated_at"] == T0 | |
| p = post_row({"channel_id": 3, "message_id": 4, "source": "somebody"}) | |
| assert p["status"] == "sent" and p["source"] == "bot" and p["text"] == "" and p["entities"] == [] and p["buttons"] == [] | |
| naive = post_row({"channel_id": 3, "message_id": 4, "created_at": "2026-10-01T12:00:00"}) # no zone: UTC | |
| assert naive["created_at"] == T0 | |
| c = channel_row({"id": -100123, "username": "@Name", "title": "t" * 400}) | |
| assert c["username"] == "Name" and len(c["title"]) == 256 and c["active"] is True and c["access_hash"] == 0 | |
| def test_the_date_of_the_backup_is_written_for_people(): | |
| assert stamp("2026-10-01T12:00:00+00:00") == "01 Oct 2026, 12:00 UTC" | |
| assert stamp("2026-10-01T17:30:00+05:30") == "01 Oct 2026, 12:00 UTC" | |
| assert stamp("not a date") == "not a date" and stamp(None) == "" | |
| # =================================================================================================== the round trip | |
| async def filled(db): | |
| await db.save_channel(1, 11, "Anime One", "animeone", 7) | |
| await db.save_channel(2, 22, "Movies", None, 7) | |
| await db.set_channel_active(2, False) | |
| a = await db.create_post( | |
| channel_id=1, message_id=10, status="sent", source="bot", text="Episode 1", created_by=7, sent_at=T0, | |
| entities=[{"k": "bold", "o": 0, "l": 7}], buttons=[[{"t": "Watch", "u": "https://t.me/animeone/9"}]], | |
| media_kind="photo", media_file_id="ref-1", link_preview=False, | |
| ) | |
| b = await db.create_post(channel_id=1, message_id=11, status="sent", source="adopted", text="From the app", sent_at=T0) | |
| c = await db.create_post(channel_id=2, status="draft", text="a draft", created_by=7) | |
| await db.ignore_post(1, 99) | |
| return a, b, c | |
| def same_post(x, y): | |
| keys = ("channel_id", "message_id", "status", "source", "text", "entities", "media_kind", "media_file_id", "buttons", | |
| "link_preview", "created_by") | |
| return all(getattr(x, k) == getattr(y, k) for k in keys) | |
| def test_what_export_writes_import_puts_back_on_a_new_bot(tmp_path): | |
| async def go(): | |
| old = await fresh(tmp_path, "old") | |
| made = await filled(old) | |
| data = await old.export_all() | |
| assert data["ignored"] == [{"channel_id": 1, "message_id": 99}] | |
| raw = json.dumps(data, ensure_ascii=False, indent=1).encode("utf-8") # exactly what /export sends | |
| new = await fresh(tmp_path, "new") | |
| plan = await plan_import(new, parse_backup(raw)) | |
| assert (len(plan.new_channels), plan.sent_to_add, plan.drafts_to_add, len(plan.new_ignored)) == (2, 2, 1, 1) | |
| res = await apply_import(new, plan) | |
| assert (res.channels_added, res.posts_added, res.drafts_added, res.marks_added, res.skipped_meanwhile) == (2, 2, 1, 1, 0) | |
| chans = {c.id: c for c in await new.list_channels(active_only=False)} | |
| assert (chans[1].title, chans[1].username, chans[1].access_hash, chans[1].active) == ("Anime One", "animeone", 11, True) | |
| assert (chans[2].title, chans[2].username, chans[2].active) == ("Movies", None, False) | |
| for original in made: | |
| if original.status == "sent": | |
| back = await new.find_by_message(original.channel_id, original.message_id) | |
| assert back is not None and same_post(original, back) | |
| assert back.id != original.id or True # the new database numbers its own posts | |
| else: | |
| rows, _ = await new.list_posts(2, "draft", 0, 10) | |
| assert len(rows) == 1 and same_post(original, rows[0]) | |
| assert await new.ignored_ids(1) == {99} | |
| await old.close() | |
| await new.close() | |
| run(go()) | |
| def test_importing_the_same_file_twice_adds_nothing_the_second_time(tmp_path): | |
| async def go(): | |
| old = await fresh(tmp_path, "old") | |
| await filled(old) | |
| raw = json.dumps(await old.export_all()).encode() | |
| new = await fresh(tmp_path, "new") | |
| await apply_import(new, await plan_import(new, parse_backup(raw))) | |
| again = await plan_import(new, parse_backup(raw)) | |
| assert again.nothing_new and again.channels_present == 2 and again.posts_present == 2 and again.drafts_present == 1 | |
| res = await apply_import(new, again) | |
| assert (res.channels_added, res.posts_added, res.drafts_added, res.marks_added) == (0, 0, 0, 0) | |
| assert len((await new.list_posts(None, None, 0, 50))[0]) == 3 | |
| await old.close() | |
| await new.close() | |
| run(go()) | |
| def test_importing_only_adds_and_never_changes_what_is_here(tmp_path): | |
| async def go(): | |
| db = await fresh(tmp_path) | |
| await db.save_channel(1, 99, "Renamed Since", "newname", 7) | |
| mine = await db.create_post(channel_id=1, message_id=10, status="sent", text="the edit I made after the backup", sent_at=T0) | |
| raw = backup_bytes(channels=[chan(1, title="Old Name", username="oldname")], posts=[post(1, 10, text="the old text"), post(1, 11), post(1, 12)]) | |
| plan = await plan_import(db, parse_backup(raw)) | |
| assert plan.channels_present == 1 and plan.posts_present == 1 and plan.sent_to_add == 2 | |
| await apply_import(db, plan) | |
| c = await db.get_channel(1) | |
| assert (c.title, c.username, c.access_hash) == ("Renamed Since", "newname", 99) | |
| assert (await db.find_by_message(1, 10)).text == "the edit I made after the backup" | |
| assert [p.message_id for p in await db.sent_posts(1)] == [10, 11, 12] | |
| assert (await db.get_post(mine.id)) is not None | |
| await db.close() | |
| run(go()) | |
| def test_posts_the_owner_made_the_bot_forget_are_not_brought_back(tmp_path): | |
| async def go(): | |
| db = await fresh(tmp_path) | |
| await db.save_channel(1, 5, "Chan", "chan", 7) | |
| await db.ignore_post(1, 11) # forgotten here after the backup was made | |
| raw = backup_bytes(channels=[chan(1)], posts=[post(1, 10), post(1, 11), post(1, 12)], ignored=[{"channel_id": 1, "message_id": 12}]) | |
| plan = await plan_import(db, parse_backup(raw)) | |
| assert plan.posts_forgotten == 2 and plan.sent_to_add == 1 # 11 (forgotten here) and 12 (forgotten in the backup) | |
| await apply_import(db, plan) | |
| assert [p.message_id for p in await db.sent_posts(1)] == [10] | |
| assert await db.ignored_ids(1) == {11, 12} | |
| await db.close() | |
| run(go()) | |
| def test_posts_of_an_unknown_channel_are_left_out(tmp_path): | |
| async def go(): | |
| db = await fresh(tmp_path) | |
| raw = backup_bytes(channels=[chan(1)], posts=[post(1, 10), post(2, 20), post(2, None, status="draft")], ignored=[{"channel_id": 2, "message_id": 5}]) | |
| plan = await plan_import(db, parse_backup(raw)) | |
| assert plan.posts_orphaned == 2 and plan.sent_to_add == 1 and plan.new_ignored == [] | |
| await apply_import(db, plan) | |
| assert await db.get_channel(2) is None and len((await db.list_posts(None, None, 0, 10))[0]) == 1 | |
| await db.close() | |
| run(go()) | |
| def test_a_post_that_turns_up_between_the_preview_and_the_import_is_skipped_not_an_error(tmp_path): | |
| async def go(): | |
| db = await fresh(tmp_path) | |
| raw = backup_bytes(channels=[chan(1)], posts=[post(1, 10), post(1, 11), post(1, 12)]) | |
| plan = await plan_import(db, parse_backup(raw)) | |
| await db.save_channel(1, 5, "Chan", "chan", 7) | |
| await db.adopt_message(1, 11, {"text": "the sync was quicker"}) # while the owner was reading the preview | |
| res = await apply_import(db, plan) | |
| assert res.posts_added == 2 and res.skipped_meanwhile == 1 and res.channels_added == 0 | |
| assert (await db.find_by_message(1, 11)).text == "the sync was quicker" | |
| assert [p.message_id for p in await db.sent_posts(1)] == [10, 11, 12] | |
| await db.close() | |
| run(go()) | |
| def test_a_big_backup_is_saved_in_pieces(tmp_path): | |
| async def go(): | |
| db = await fresh(tmp_path) | |
| posts = [post(1, i, text=f"post {i}") for i in range(1, 1202)] | |
| plan = await plan_import(db, parse_backup(backup_bytes(channels=[chan(1)], posts=posts))) | |
| seen = [] | |
| async def progress(done, total): | |
| seen.append((done, total)) | |
| res = await apply_import(db, plan, progress=progress) | |
| assert res.posts_added == 1201 and seen == [(500, 1201), (1000, 1201), (1201, 1201)] | |
| assert len(await db.sent_posts(1)) == 1201 | |
| await db.close() | |
| run(go()) | |
| def test_two_drafts_with_the_same_text_but_made_at_different_times_are_both_kept(tmp_path): | |
| async def go(): | |
| db = await fresh(tmp_path) | |
| d1 = post(1, None, status="draft", text="same", created_at=T0.isoformat()) | |
| d2 = post(1, None, status="draft", text="same", created_at=(T0 + datetime.timedelta(minutes=5)).isoformat()) | |
| raw = backup_bytes(channels=[chan(1)], posts=[d1, d2, d1]) # the third is a copy of the first | |
| plan = await plan_import(db, parse_backup(raw)) | |
| assert plan.drafts_to_add == 2 and plan.drafts_present == 1 | |
| await apply_import(db, plan) | |
| assert len((await db.list_posts(1, "draft", 0, 10))[0]) == 2 | |
| await db.close() | |
| run(go()) | |
| # ===================================================================================== the commands, as a person uses them | |
| def backup_file(raw: bytes, *, size=None): | |
| async def download_media(file=None): | |
| assert file is bytes | |
| return raw | |
| return SimpleNamespace( | |
| raw_text="", entities=[], media=object(), grouped_id=None, forward=None, | |
| file=SimpleNamespace(size=len(raw) if size is None else size, name="channel-posts-export.json"), | |
| download_media=download_media, | |
| ) | |
| async def started(tmp_path, **kw): | |
| app = App(tmp_path, **kw) | |
| await app.start() | |
| return app | |
| def test_export_sends_the_file_and_tells_how_to_put_it_back(tmp_path, monkeypatch): | |
| async def go(): | |
| app = await started(tmp_path) | |
| await app.db.save_channel(1, 5, "Chan", "chan", 1) | |
| await app.db.create_post(channel_id=1, message_id=3, status="sent", text="hello", sent_at=utcnow()) | |
| await app.db.ignore_post(1, 8) | |
| files = [] | |
| real = FakeEvent.respond | |
| async def respond(self, text=None, buttons=None, file=None, **kw): | |
| files.append(file) | |
| return await real(self, text, buttons, file, **kw) | |
| monkeypatch.setattr(FakeEvent, "respond", respond) | |
| await app.text("/export") | |
| text = app.out.log[-1][1] | |
| assert "1 channel(s), 1 post(s)" in text and "/import" in text | |
| (buf,) = files | |
| assert buf.name == "channel-posts-export.json" | |
| data = json.loads(buf.getvalue().decode("utf-8")) | |
| assert set(data) == {"exported_at", "channels", "posts", "ignored"} and data["ignored"] == [{"channel_id": 1, "message_id": 8}] | |
| back = parse_backup(buf.getvalue()) # what /import reads is what /export wrote | |
| assert len(back.channels) == 1 and [p["message_id"] for p in back.posts] == [3] and back.ignored == [(1, 8)] | |
| await app.db.close() | |
| run(go()) | |
| def test_import_asks_for_the_file_and_the_owner_only(tmp_path): | |
| async def go(): | |
| app = await started(tmp_path) | |
| await app.text("/import") | |
| assert "Send me the file that /export made" in norm(app.out.last_text) and "only adds" in norm(app.out.last_text) | |
| assert app.ctx.state[1]["mode"] == "import" | |
| await app.text("/cancel") | |
| assert 1 not in app.ctx.state | |
| app.uid = 2 # not an owner, not an admin | |
| await app.text("/import") | |
| assert "private" in norm(app.out.last_text) and 2 not in app.ctx.state | |
| await app.db.close() | |
| run(go()) | |
| def test_sending_text_or_the_wrong_file_keeps_the_import_waiting(tmp_path): | |
| async def go(): | |
| app = await started(tmp_path) | |
| await app.text("/import") | |
| await app.text("here you go") | |
| assert "backup FILE" in norm(app.out.last_text) and app.ctx.state[1]["mode"] == "import" | |
| await app.text("", message=backup_file(b"this is not json")) | |
| assert "can't be imported" in norm(app.out.last_text) and "not a JSON file" in norm(app.out.last_text) | |
| assert app.ctx.state[1]["mode"] == "import" # the right file can still be sent | |
| await app.text("", message=backup_file(b"{}", size=MAX_BYTES + 1)) | |
| assert "larger than" in norm(app.out.last_text) and app.ctx.state[1]["mode"] == "import" | |
| await app.db.close() | |
| run(go()) | |
| def test_the_whole_import_as_a_person_does_it(tmp_path): | |
| async def go(): | |
| app = await started(tmp_path) | |
| await app.db.save_channel(1, 5, "Chan", "chan", 1) | |
| await app.db.create_post(channel_id=1, message_id=10, status="sent", text="already here", sent_at=utcnow()) | |
| raw = backup_bytes( | |
| channels=[chan(1), chan(2, title="Movies")], | |
| posts=[post(1, 10), post(1, 11), post(2, 5), post(2, None, status="draft", text="a draft"), post(9, 1)], | |
| ignored=[{"channel_id": 2, "message_id": 77}], | |
| ) | |
| await app.text("/import") | |
| await app.text("", message=backup_file(raw)) | |
| text = norm(app.out.last_text) | |
| assert "Backup file read" in text and "01 Oct 2026, 12:00 UTC" in text | |
| assert "Channels: 2 in the file - 1 new, 1 here already" in text | |
| assert "Published posts: 4 in the file - 2 to add, 1 here already" in text # the fourth is left out (below) | |
| assert "Drafts: 1 in the file - 1 to add, 0 here already" in text | |
| assert "belong to a channel that is neither here nor in the file" in text | |
| assert "Nothing that is here now is changed or deleted" in text and "admin there" in text | |
| assert 1 not in app.ctx.state # waiting for the OK, not for another file | |
| assert await app.db.get_channel(2) is None # nothing happened yet | |
| await app.press(app.out.callback_data("Import")) | |
| text = norm(app.out.last_text) | |
| assert "Imported" in text and "1 channel(s) added" in text and "2 published post(s) added" in text and "1 draft(s) added" in text | |
| assert (await app.db.get_channel(2)).title == "Movies" | |
| assert [p.message_id for p in await app.db.sent_posts(1)] == [10, 11] | |
| assert (await app.db.find_by_message(1, 10)).text == "already here" | |
| assert await app.db.ignored_ids(2) == {77} | |
| assert not app.ctx.lock.locked() and not [k for k in app.ctx.pending] | |
| await app.press(f"imy:{'deadbeef'}") | |
| assert "expired" in norm(app.out.last_text) | |
| await app.db.close() | |
| run(go()) | |
| def test_cancelling_the_preview_imports_nothing(tmp_path): | |
| async def go(): | |
| app = await started(tmp_path) | |
| await app.text("/import") | |
| await app.text("", message=backup_file(backup_bytes(channels=[chan(1)], posts=[post(1, 3)]))) | |
| cancel = app.out.callback_data("Cancel") | |
| await app.press(cancel) | |
| assert "nothing was imported" in norm(app.out.last_text) | |
| assert await app.db.get_channel(1) is None and app.ctx.pending == {} | |
| await app.press(cancel.replace("imx", "imy")) # the old Import button does not work any more | |
| assert "expired" in norm(app.out.last_text) | |
| await app.db.close() | |
| run(go()) | |
| def test_a_file_with_nothing_new_says_so_and_offers_no_button(tmp_path): | |
| async def go(): | |
| app = await started(tmp_path) | |
| await app.db.save_channel(1, 5, "Chan", "chan", 1) | |
| await app.db.create_post(channel_id=1, message_id=3, status="sent", text="x", sent_at=utcnow()) | |
| await app.text("/import") | |
| await app.text("", message=backup_file(backup_bytes(channels=[chan(1)], posts=[post(1, 3)]))) | |
| assert "nothing new in this file" in norm(app.out.last_text) | |
| assert app.out.log[-1][2] is None and app.ctx.pending == {} | |
| await app.db.close() | |
| run(go()) | |
| def test_the_import_waits_while_another_long_job_runs(tmp_path): | |
| async def go(): | |
| app = await started(tmp_path) | |
| await app.text("/import") | |
| await app.text("", message=backup_file(backup_bytes(channels=[chan(1)], posts=[post(1, 3)]))) | |
| data = app.out.callback_data("Import") | |
| async with app.ctx.lock: | |
| await app.press(data) | |
| assert "still running" in norm(app.out.last_text) and await app.db.get_channel(1) is None | |
| await app.press(data) # now it is free | |
| assert "Imported" in norm(app.out.last_text) and (await app.db.get_channel(1)) is not None | |
| await app.db.close() | |
| run(go()) | |
| def test_an_admin_who_is_not_an_owner_cannot_press_the_import_button(tmp_path): | |
| async def go(): | |
| app = await started(tmp_path, admins=frozenset({2})) | |
| await app.text("/import") | |
| await app.text("", message=backup_file(backup_bytes(channels=[chan(1)], posts=[post(1, 3)]))) | |
| data = app.out.callback_data("Import") | |
| app.uid = 2 | |
| await app.press(data) | |
| assert await app.db.get_channel(1) is None # refused: only owners | |
| await app.db.close() | |
| run(go()) | |