fix(cache): снапшот всех спец-медиа типов + render-parity тест + file_size для всех медиа (ревью #44)
Docker Image CI / build (pull_request) Waiting to run

Ревью #44: allowlist снапшота был неполон -> спец-медиа блоки тихо выпадали на
cache-hit (нарушение golden-render инварианта). Исправлено:
- Снапшот+восстановление 11 типов (story, contact, location, venue, dice, game,
  giveaway, giveaway_winners, checklist, paid_media + live_photo) — ровно те
  подполя, что читает _format_special_media/find_file_id_in_message.
  SNAPSHOT_VERSION 1->2 (v1-файлы без новых ключей инвалидируются).
- Render-parity тест (tests/test_snapshot_render_parity.py): корпус из 35+ кейсов
  (все медиа/спец-медиа/web_page/poll/forward Case 1-5/reactions/media group),
  live-рендер == restore(snapshot(live))-рендер байт-в-байт через реальный
  пайплайн; мутационно проверено (выброс поля -> тест краснеет).
- F1: file_size досниман для document/audio/animation/video_note/story-media
  (правило '>100MB не кэшировать' в _save_media_file_ids читает file_size у любого
  выбранного медиа-объекта; раньше был только у video/live_photo -> >100MB документ
  ошибочно кэшировался на cache-hit).
- flake8/pylint-хедеры на 3 новых тест-файла.

post_parser.py/rss_generator.py не тронуты; pickle отсутствует.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 01:25:41 +03:00
parent 8d317e36c8
commit 432715200f
4 changed files with 716 additions and 9 deletions
+201
View File
@@ -1,3 +1,7 @@
# 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
"""Tests for message_snapshot.py (issue #23, Задания 4-5,7).
These verify that a pyrogram Message survives snapshot -> JSON -> restore as a
@@ -265,6 +269,72 @@ def test_normal_video_is_collected():
assert pp._pending_media_ids[0][2] == "vid2"
# --------------------------------------------------------------------------- #
# Test 9b — the >100MB skip must fire for every media type whose selected object
# flows into _save_media_file_ids' size check, not just video/live_photo. Before
# file_size was added to these snapshots a restored >100MB doc/audio/animation/
# video_note lacked file_size and was wrongly collected on a cache hit (F1).
# --------------------------------------------------------------------------- #
_BIG = 200 * 1024 * 1024
@pytest.mark.parametrize("media,attr,snap_obj", [
("DOCUMENT", "document", {"file_unique_id": "doc_big", "mime_type": "application/zip", "file_size": _BIG}),
("AUDIO", "audio", {"file_unique_id": "aud_big", "mime_type": "audio/mpeg", "file_size": _BIG}),
("ANIMATION", "animation", {"file_unique_id": "anim_big", "file_size": _BIG}),
("VIDEO_NOTE", "video_note", {"file_unique_id": "vn_big", "file_size": _BIG}),
])
def test_large_media_not_collected(media, attr, snap_obj):
snap = snapshot_message(SimpleNamespace())
snap["media"] = media
snap[attr] = snap_obj
snap["chat"] = {"id": -1001, "username": "chan", "title": "t", "usernames": None}
restored = restore_message(snap)
# file_size survives the snapshot/restore round-trip (the round-trip is the fix).
assert getattr(restored, attr).file_size == _BIG
pp = PostParser(None)
pp._save_media_file_ids(restored)
assert pp._pending_media_ids == []
@pytest.mark.parametrize("media,attr,fuid,snap_obj", [
("DOCUMENT", "document", "doc_ok", {"file_unique_id": "doc_ok", "mime_type": "application/zip", "file_size": 1024}),
("AUDIO", "audio", "aud_ok", {"file_unique_id": "aud_ok", "mime_type": "audio/mpeg", "file_size": 1024}),
("ANIMATION", "animation", "anim_ok", {"file_unique_id": "anim_ok", "file_size": 1024}),
("VIDEO_NOTE", "video_note", "vn_ok", {"file_unique_id": "vn_ok", "file_size": 1024}),
])
def test_normal_media_is_collected(media, attr, fuid, snap_obj):
snap = snapshot_message(SimpleNamespace())
snap["id"] = 11
snap["media"] = media
snap[attr] = snap_obj
snap["chat"] = {"id": -1001, "username": "chan", "title": "t", "usernames": None}
restored = restore_message(snap)
pp = PostParser(None)
pp._save_media_file_ids(restored)
assert len(pp._pending_media_ids) == 1
assert pp._pending_media_ids[0][2] == fuid
def test_large_story_media_not_collected():
# A >100MB story video (selected over the photo) must be skipped on a cache hit.
snap = snapshot_message(SimpleNamespace())
snap["media"] = "STORY"
snap["story"] = {
"video": {"file_unique_id": "story_vid_big", "file_size": _BIG},
"photo": None,
}
snap["chat"] = {"id": -1001, "username": "chan", "title": "t", "usernames": None}
restored = restore_message(snap)
assert restored.story.video.file_size == _BIG
pp = PostParser(None)
pp._save_media_file_ids(restored)
assert pp._pending_media_ids == []
# --------------------------------------------------------------------------- #
# Test 10 — restored chat without username -> None, not AttributeError.
# --------------------------------------------------------------------------- #
@@ -307,3 +377,134 @@ def test_snapshot_messages_list_roundtrip():
restored = restore_messages(snapshot_messages(msgs))
assert [m.id for m in restored] == [0, 1, 2]
assert all(isinstance(m, CachedMessage) for m in restored)
# --------------------------------------------------------------------------- #
# Special-media round trips (issue #23 review fix). One per type: assert the exact
# sub-fields _format_special_media / find_file_id_in_message read survive the trip.
# These are what silently vanished on a cache hit before the allowlist was completed.
# --------------------------------------------------------------------------- #
def test_story_roundtrip_file_unique_id_survives():
story = SimpleNamespace(
video=SimpleNamespace(file_unique_id="story-vid"),
photo=SimpleNamespace(file_unique_id="story-pic"),
)
restored, _ = _roundtrip(SimpleNamespace(story=story))
# find_file_id_in_message / _story_media_object read story.video/photo.file_unique_id.
assert restored.story.video.file_unique_id == "story-vid"
assert restored.story.photo.file_unique_id == "story-pic"
def test_contact_roundtrip():
contact = SimpleNamespace(first_name="Ann", last_name="Bee", phone_number="+15551234")
restored, _ = _roundtrip(SimpleNamespace(contact=contact))
assert restored.contact.first_name == "Ann"
assert restored.contact.last_name == "Bee"
assert restored.contact.phone_number == "+15551234"
def test_location_roundtrip():
location = SimpleNamespace(latitude=51.5, longitude=-0.12)
restored, _ = _roundtrip(SimpleNamespace(location=location))
assert restored.location.latitude == 51.5
assert restored.location.longitude == -0.12
def test_venue_roundtrip_with_nested_location():
venue = SimpleNamespace(
title="Big Ben", address="Westminster",
location=SimpleNamespace(latitude=51.5, longitude=-0.12),
)
restored, _ = _roundtrip(SimpleNamespace(venue=venue))
assert restored.venue.title == "Big Ben"
assert restored.venue.address == "Westminster"
assert restored.venue.location.latitude == 51.5
assert restored.venue.location.longitude == -0.12
def test_dice_roundtrip():
restored, _ = _roundtrip(SimpleNamespace(dice=SimpleNamespace(emoji="🎯", value=6)))
assert restored.dice.emoji == "🎯"
assert restored.dice.value == 6
def test_game_roundtrip():
restored, _ = _roundtrip(SimpleNamespace(game=SimpleNamespace(title="Chess")))
assert restored.game.title == "Chess"
def test_giveaway_roundtrip_until_date_datetime():
giveaway = SimpleNamespace(
quantity=3, months=6, stars=None,
until_date=datetime(2030, 12, 31, 12, 0, 0),
description="Prizes!",
)
restored, _ = _roundtrip(SimpleNamespace(giveaway=giveaway))
assert restored.giveaway.quantity == 3
assert restored.giveaway.months == 6
# until_date must restore as a datetime so the renderer's strftime branch fires.
assert restored.giveaway.until_date.strftime("%d/%m/%Y") == "31/12/2030"
assert restored.giveaway.description == "Prizes!"
def test_giveaway_winners_roundtrip():
winners = SimpleNamespace(winner_count=5, quantity=10, prize_description="Premium")
restored, _ = _roundtrip(SimpleNamespace(giveaway_winners=winners))
assert restored.giveaway_winners.winner_count == 5
assert restored.giveaway_winners.quantity == 10
assert restored.giveaway_winners.prize_description == "Premium"
def test_checklist_roundtrip_tasks_and_completion():
checklist = SimpleNamespace(
title="Todo",
tasks=[
SimpleNamespace(text="done task", completed_by=SimpleNamespace(id=1), completion_date=None),
SimpleNamespace(text="open task", completed_by=None, completion_date=None),
],
)
restored, _ = _roundtrip(SimpleNamespace(checklist=checklist))
assert restored.checklist.title == "Todo"
t_done, t_open = restored.checklist.tasks
assert t_done.text == "done task"
# Renderer marks ☑ when bool(completed_by or completion_date) is True.
assert bool(t_done.completed_by or t_done.completion_date) is True
assert t_open.text == "open task"
assert bool(t_open.completed_by or t_open.completion_date) is False
def test_paid_media_roundtrip_stars_and_item_count():
paid = SimpleNamespace(stars_amount=50, media=[object(), object(), object()])
restored, _ = _roundtrip(SimpleNamespace(paid_media=paid))
assert restored.paid_media.stars_amount == 50
# Renderer only len()'s .media — the count must survive.
assert len(restored.paid_media.media) == 3
def test_live_photo_roundtrip_file_unique_id_and_size():
live_photo = SimpleNamespace(file_unique_id="lp1", file_size=4096)
restored, _ = _roundtrip(SimpleNamespace(live_photo=live_photo))
# MEDIA_SOURCES/_get_file_unique_id + find_file_id read live_photo.file_unique_id;
# _save_media_file_ids reads file_size (the >100MB skip).
assert restored.live_photo.file_unique_id == "lp1"
assert restored.live_photo.file_size == 4096
def test_large_live_photo_not_collected():
snap = snapshot_message(SimpleNamespace())
snap["media"] = "LIVE_PHOTO"
snap["live_photo"] = {"file_unique_id": "lpbig", "file_size": 200 * 1024 * 1024}
snap["chat"] = {"id": -1001, "username": "chan", "title": "t", "usernames": None}
restored = restore_message(snap)
pp = PostParser(None)
pp._save_media_file_ids(restored)
assert pp._pending_media_ids == []
def test_special_media_defaults_present_on_empty_message():
restored = restore_message(snapshot_message(SimpleNamespace()))
for attr in ["story", "contact", "location", "venue", "dice", "game",
"giveaway", "giveaway_winners", "checklist", "paid_media", "live_photo"]:
assert hasattr(restored, attr)
assert getattr(restored, attr) is None