# flake8: noqa # pylint: disable=protected-access, missing-function-docstring, missing-class-docstring # pylint: disable=redefined-outer-name, logging-fstring-interpolation, line-too-long # pylance: disable=reportMissingImports, reportMissingModuleSource """Render-parity oracle for the snapshot cache (issue #23 review fix). The core ask of the review: prove that snapshot -> restore preserves EVERYTHING the render pipeline reads. The old golden test replays a pickle corpus and never renders a live Message against its snapshot, so a field dropped from the allowlist (the exact bug the reviewer mutation-proved for web_page/sender_chat/... and for the special-media types) passes silently. This test builds a CORPUS of representative live fake Message objects (plain text, every media type, every special-media type, web_page, poll, forwards Case 1-5, reactions, caption, a multi-message media group) and for EACH case: 1. renders the LIVE objects through the REAL public feed entry points (rss_generator.generate_channel_html / generate_channel_rss — the same path the golden test and production use, driven by monkeypatching tg_cache), 2. snapshot -> restore every message, renders the RESTORED objects the same way, 3. asserts the two renders are BYTE-IDENTICAL (HTML and the RSS feed). Any field the snapshot fails to preserve makes the two renders diverge and this test go red. It is a self-contained live-vs-restored parity oracle; it does NOT depend on the frozen pickle goldens. """ import asyncio from datetime import datetime from types import SimpleNamespace import pytest from pyrogram.enums import MessageMediaType from message_snapshot import snapshot_messages, restore_messages from tests import golden_replay as gr # --------------------------------------------------------------------------- # # Live-object fakes. # --------------------------------------------------------------------------- # class FakeStr(str): """Stand-in for a live pyrogram Str: a str carrying a .html rendering.""" def __new__(cls, plain, html=None): obj = str.__new__(cls, plain) obj._html = html if html is not None else plain return obj @property def html(self): return self._html _CHANNEL = "parity_ch" _CHAT = SimpleNamespace(id=-1001234567890, username=_CHANNEL, title="Parity Channel", usernames=None) # The full top-level attribute surface the render pipeline reads (mirrors CachedMessage). # A live fake sets every one so it renders WITHOUT relying on getattr fallbacks that a # restored CachedMessage would provide — otherwise the live side, not the snapshot, would # be the thing under-specified. _DEFAULTS = dict( id=0, date=None, text=None, caption=None, media=None, service=None, media_group_id=None, views=100, show_caption_above_media=False, reply_to_message_id=None, reply_to_message=None, empty=False, chat=_CHAT, sender_chat=None, from_user=None, forward_origin=None, reactions=None, poll=None, web_page=None, photo=None, video=None, document=None, audio=None, voice=None, video_note=None, animation=None, sticker=None, story=None, contact=None, location=None, venue=None, dice=None, game=None, giveaway=None, giveaway_winners=None, checklist=None, paid_media=None, live_photo=None, ) _id_counter = [1000] def make_msg(**overrides): _id_counter[0] += 1 fields = dict(_DEFAULTS) fields["id"] = _id_counter[0] fields["date"] = datetime(2024, 3, 1, 12, 0, _id_counter[0] % 60) fields.update(overrides) return SimpleNamespace(**fields) def _reactions(*reacts): return SimpleNamespace(reactions=list(reacts)) # --------------------------------------------------------------------------- # # Corpus: each case is (name, [live messages]) rendered as its own mini feed. # --------------------------------------------------------------------------- # def build_corpus(): corpus = [] # Plain text. corpus.append(("plain_text", [make_msg(text=FakeStr("Hello world", "Hello world"))])) # Caption (photo + caption + show_caption_above_media). corpus.append(("caption_photo", [make_msg( media=MessageMediaType.PHOTO, photo=SimpleNamespace(file_unique_id="pcap"), caption=FakeStr("A caption", "A caption"), show_caption_above_media=True, )])) # Regular media types. corpus.append(("photo", [make_msg(media=MessageMediaType.PHOTO, photo=SimpleNamespace(file_unique_id="ph1"))])) corpus.append(("video", [make_msg(media=MessageMediaType.VIDEO, video=SimpleNamespace(file_unique_id="vd1", file_size=2048))])) corpus.append(("document_pdf", [make_msg(media=MessageMediaType.DOCUMENT, document=SimpleNamespace(file_unique_id="doc1", mime_type="application/pdf"))])) corpus.append(("document_other", [make_msg(media=MessageMediaType.DOCUMENT, document=SimpleNamespace(file_unique_id="doc2", mime_type="image/png"))])) corpus.append(("audio", [make_msg(media=MessageMediaType.AUDIO, audio=SimpleNamespace(file_unique_id="au1", mime_type="audio/mpeg"))])) corpus.append(("voice", [make_msg(media=MessageMediaType.VOICE, voice=SimpleNamespace(file_unique_id="vo1", mime_type="audio/ogg"))])) corpus.append(("video_note", [make_msg(media=MessageMediaType.VIDEO_NOTE, video_note=SimpleNamespace(file_unique_id="vn1"))])) corpus.append(("animation", [make_msg(media=MessageMediaType.ANIMATION, animation=SimpleNamespace(file_unique_id="an1"))])) corpus.append(("sticker_image", [make_msg(media=MessageMediaType.STICKER, sticker=SimpleNamespace(file_unique_id="st1", emoji="😀", is_video=False))])) corpus.append(("sticker_video", [make_msg(media=MessageMediaType.STICKER, sticker=SimpleNamespace(file_unique_id="st2", emoji="🎥", is_video=True))])) corpus.append(("live_photo", [make_msg(media=MessageMediaType.LIVE_PHOTO, live_photo=SimpleNamespace(file_unique_id="lp1", file_size=4096))])) # --- Special-media types (Fix 1) ------------------------------------- # corpus.append(("story_photo", [make_msg( media=MessageMediaType.STORY, story=SimpleNamespace(video=None, photo=SimpleNamespace(file_unique_id="sto_p")), )])) corpus.append(("story_video", [make_msg( media=MessageMediaType.STORY, story=SimpleNamespace(video=SimpleNamespace(file_unique_id="sto_v"), photo=None), )])) corpus.append(("contact", [make_msg( media=MessageMediaType.CONTACT, contact=SimpleNamespace(first_name="Ann", last_name="Bee", phone_number="+15551234"), )])) corpus.append(("location", [make_msg( media=MessageMediaType.LOCATION, location=SimpleNamespace(latitude=51.50111, longitude=-0.14222), )])) corpus.append(("venue", [make_msg( media=MessageMediaType.VENUE, venue=SimpleNamespace(title="Big Ben", address="Westminster", location=SimpleNamespace(latitude=51.50055, longitude=-0.12461)), )])) corpus.append(("dice", [make_msg( media=MessageMediaType.DICE, dice=SimpleNamespace(emoji="🎯", value=6))])) corpus.append(("game", [make_msg( media=MessageMediaType.GAME, game=SimpleNamespace(title="Chess Master"))])) corpus.append(("giveaway", [make_msg( media=MessageMediaType.GIVEAWAY, giveaway=SimpleNamespace(quantity=3, months=6, stars=None, until_date=datetime(2030, 12, 31, 12, 0, 0), description="Great prizes"), )])) corpus.append(("giveaway_stars", [make_msg( media=MessageMediaType.GIVEAWAY, giveaway=SimpleNamespace(quantity=2, months=None, stars=500, until_date=None, description=None), )])) corpus.append(("giveaway_winners", [make_msg( media=MessageMediaType.GIVEAWAY_WINNERS, giveaway_winners=SimpleNamespace(winner_count=5, quantity=10, prize_description="Premium"), )])) corpus.append(("checklist", [make_msg( media=MessageMediaType.CHECKLIST, checklist=SimpleNamespace(title="Todo list", tasks=[ SimpleNamespace(text="done task", completed_by=SimpleNamespace(id=1), completion_date=None), SimpleNamespace(text="open task", completed_by=None, completion_date=None), ]), )])) corpus.append(("paid_media", [make_msg( media=MessageMediaType.PAID_MEDIA, paid_media=SimpleNamespace(stars_amount=50, media=[object(), object()]), )])) # --- web_page + poll -------------------------------------------------- # corpus.append(("web_page", [make_msg( text=FakeStr("check this", "check this"), media=MessageMediaType.WEB_PAGE, web_page=SimpleNamespace(type="article", url="https://example.com/a", display_url="example.com/a", site_name="Example", title="An Article", description="Desc here", has_large_media=False, photo=SimpleNamespace(file_unique_id="wp1")), )])) corpus.append(("poll", [make_msg( media=MessageMediaType.POLL, poll=SimpleNamespace( question=SimpleNamespace(text="Favourite?", entities=[]), options=[SimpleNamespace(text=SimpleNamespace(text="Red", entities=[])), SimpleNamespace(text=SimpleNamespace(text="Blue", entities=[]))], description_media=None, explanation_media=None, ), )])) # --- Forwards Case 1-5 (drive _format_forward_info) ------------------- # corpus.append(("forward_channel", [make_msg( text=FakeStr("fwd", "fwd"), forward_origin=SimpleNamespace(type="channel", chat=SimpleNamespace(id=-100, title="Src Chan", username="srcchan")), )])) corpus.append(("forward_hidden", [make_msg( text=FakeStr("fwd", "fwd"), forward_origin=SimpleNamespace(type="hidden_user", sender_user_name="Hidden Guy"), )])) corpus.append(("forward_user", [make_msg( text=FakeStr("fwd", "fwd"), forward_origin=SimpleNamespace(type="user", sender_user=SimpleNamespace(first_name="Ann", last_name="Bee", username="annbee")), )])) corpus.append(("forward_channel_nouser", [make_msg( text=FakeStr("fwd", "fwd"), forward_origin=SimpleNamespace(type="channel", chat_id=-1002, title="NoUserChan"), )])) corpus.append(("forward_other", [make_msg( text=FakeStr("fwd", "fwd"), forward_origin=SimpleNamespace(type="something_else"), )])) # --- Reactions: normal / paid / custom -------------------------------- # corpus.append(("reactions", [make_msg( text=FakeStr("react", "react"), reactions=_reactions( SimpleNamespace(emoji="👍", count=5, is_paid=False), SimpleNamespace(count=3, is_paid=True), SimpleNamespace(custom_emoji_id=1234567890, count=2), ), )])) # --- sender_chat / from_user author paths ----------------------------- # corpus.append(("sender_chat_author", [make_msg( text=FakeStr("a", "a"), sender_chat=SimpleNamespace(id=-100, title="Sender Chan", username="senderchan"), )])) corpus.append(("from_user_author", [make_msg( text=FakeStr("a", "a"), from_user=SimpleNamespace(first_name="Joe", last_name="Doe", username="joedoe"), )])) # --- Multi-message media group (grouping merge) ----------------------- # corpus.append(("media_group", [ make_msg(media=MessageMediaType.PHOTO, media_group_id="grp1", photo=SimpleNamespace(file_unique_id="grp_a"), caption=FakeStr("group cap", "group cap")), make_msg(media=MessageMediaType.PHOTO, media_group_id="grp1", photo=SimpleNamespace(file_unique_id="grp_b")), ])) return corpus # --------------------------------------------------------------------------- # # Rendering harness — patches tg_cache to feed a message list into the real # generate_channel_* entry points (the golden-replay path). # --------------------------------------------------------------------------- # def _render(messages, monkeypatch): async def fake_get_chat_history(client, channel_id, limit=20): return messages async def fake_get_chat(client, channel_id): return SimpleNamespace(id=_CHAT.id, title=_CHAT.title, username=_CHAT.username) monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_chat_history, raising=False) monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False) from rss_generator import generate_channel_html, generate_channel_rss html = asyncio.run(generate_channel_html(_CHANNEL, client=SimpleNamespace(), limit=50)) rss = asyncio.run(generate_channel_rss(_CHANNEL, client=SimpleNamespace(), limit=50)) return html, gr.normalize_rss(rss) @pytest.mark.parametrize("name,messages", build_corpus(), ids=lambda v: v if isinstance(v, str) else "") def test_snapshot_render_parity(name, messages, monkeypatch): gr.pin_environment(monkeypatch) # 1) Render the LIVE objects through the real pipeline. live_html, live_rss = _render(messages, monkeypatch) # 2) snapshot -> restore, then render the RESTORED objects the same way. restored = restore_messages(snapshot_messages(messages)) restored_html, restored_rss = _render(restored, monkeypatch) # 3) Byte-identical HTML and RSS prove the snapshot preserved everything the # renderer read for this case. A dropped allowlist field diverges here. assert restored_html == live_html, f"HTML render diverged for case '{name}'" assert restored_rss == live_rss, f"RSS render diverged for case '{name}'" def test_corpus_covers_all_special_media_types(): """Guard: every special-media type from Fix 1 is exercised by the parity corpus.""" names = {n for n, _ in build_corpus()} required = {"story_photo", "story_video", "contact", "location", "venue", "dice", "game", "giveaway", "giveaway_winners", "checklist", "paid_media", "live_photo"} assert required <= names