Files
pyrogram-bridge/tests/test_tg_cache_store.py
T
agent_coder 432715200f
Docker Image CI / build (pull_request) Waiting to run
fix(cache): снапшот всех спец-медиа типов + render-parity тест + file_size для всех медиа (ревью #44)
Ревью #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>
2026-07-10 01:25:41 +03:00

207 lines
7.6 KiB
Python

# 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 the tg_cache generic JSON store, prefix-limit, jitter and sweep (issue #23).
Задания 2 / 6 / 17 / 18, verification item 8, 11, 12.
"""
import json
import os
import time
from types import SimpleNamespace
import pytest
import tg_cache
from message_snapshot import SNAPSHOT_VERSION
@pytest.fixture
def cache_dir(tmp_path, monkeypatch):
d = tmp_path / "tgcache"
d.mkdir()
monkeypatch.setattr(tg_cache, "CACHE_DIR", str(d))
return d
# --------------------------------------------------------------------------- #
# _store_entry / _load_entry.
# --------------------------------------------------------------------------- #
def test_store_load_roundtrip(cache_dir):
path = str(cache_dir / "x.history.json")
tg_cache._store_entry(path, {"limit": 5, "messages": [{"id": 1}]})
loaded = tg_cache._load_entry(path, max_age_hours=8)
assert loaded is not None
assert loaded["limit"] == 5
assert loaded["messages"] == [{"id": 1}]
assert loaded["version"] == SNAPSHOT_VERSION
assert 0.8 <= loaded["jitter"] <= 1.0
def test_load_missing_file(cache_dir):
assert tg_cache._load_entry(str(cache_dir / "nope.json"), 8) is None
def test_load_expired_ttl(cache_dir):
path = str(cache_dir / "x.history.json")
tg_cache._store_entry(path, {"data": 1})
# max_age_hours=0 -> adjusted max age 0 -> any positive age is expired.
assert tg_cache._load_entry(path, max_age_hours=0) is None
# Still fresh with a real TTL.
assert tg_cache._load_entry(path, max_age_hours=8) is not None
def test_load_version_mismatch(cache_dir):
path = str(cache_dir / "x.history.json")
with open(path, "w", encoding="utf-8") as f:
json.dump({"version": SNAPSHOT_VERSION + 999, "timestamp": time.time(), "data": 1}, f)
assert tg_cache._load_entry(path, 8) is None
def test_load_corrupt_json(cache_dir):
path = str(cache_dir / "x.history.json")
with open(path, "w", encoding="utf-8") as f:
f.write("{not valid json")
assert tg_cache._load_entry(path, 8) is None
def test_store_uses_unique_tmp_names(cache_dir, monkeypatch):
path = str(cache_dir / "x.history.json")
seen = []
real_replace = os.replace
def spy_replace(src, dst):
seen.append(src)
return real_replace(src, dst)
monkeypatch.setattr(tg_cache.os, "replace", spy_replace)
tg_cache._store_entry(path, {"data": 1})
tg_cache._store_entry(path, {"data": 2})
assert len(seen) == 2
assert seen[0] != seen[1] # each writer used a distinct tmp path
for src in seen:
assert src.startswith(path + ".tmp.")
# No tmp leftovers.
assert [n for n in os.listdir(cache_dir) if ".tmp." in n] == []
# --------------------------------------------------------------------------- #
# cleanup_legacy_cache_files / sweep_tgcache.
# --------------------------------------------------------------------------- #
def test_cleanup_legacy_keeps_new_format(cache_dir):
(cache_dir / "chan.cache").write_text("x")
(cache_dir / "chan_history.cache").write_text("x")
(cache_dir / "chan.chatinfo").write_text("x")
(cache_dir / "chan.history.json").write_text("x")
(cache_dir / "chan.chatinfo.json").write_text("x")
removed = tg_cache.cleanup_legacy_cache_files()
assert removed == 3
remaining = set(os.listdir(cache_dir))
assert remaining == {"chan.history.json", "chan.chatinfo.json"}
def test_sweep_removes_only_old(cache_dir):
old = cache_dir / "old.history.json"
new = cache_dir / "new.history.json"
old.write_text("x")
new.write_text("x")
old_time = time.time() - 8 * 86400 # 8 days old, threshold is 7
os.utime(old, (old_time, old_time))
removed = tg_cache.sweep_tgcache(max_age_days=7)
assert removed == 1
remaining = set(os.listdir(cache_dir))
assert remaining == {"new.history.json"}
def test_sweep_removes_orphan_tmp(cache_dir):
orphan = cache_dir / "x.history.json.tmp.deadbeef"
orphan.write_text("x")
old_time = time.time() - 10 * 86400
os.utime(orphan, (old_time, old_time))
assert tg_cache.sweep_tgcache(max_age_days=7) == 1
# --------------------------------------------------------------------------- #
# History prefix-limit (Задание 6).
# --------------------------------------------------------------------------- #
def _fake_messages(n):
return [SimpleNamespace(id=i) for i in range(n)]
def test_prefix_larger_cache_serves_smaller_request(cache_dir):
tg_cache._save_history_to_cache("chan", _fake_messages(100), limit=100)
served = tg_cache._get_history_from_cache("chan", limit=50)
assert served is not None
assert len(served) == 50
assert [m.id for m in served] == list(range(50)) # order preserved (newest-first slice)
def test_prefix_smaller_cache_is_miss(cache_dir):
tg_cache._save_history_to_cache("chan", _fake_messages(50), limit=50)
assert tg_cache._get_history_from_cache("chan", limit=100) is None
def test_prefix_exhausted_channel_serves_larger_request(cache_dir):
# Fetched with limit 100 but the channel only has 37 messages -> entire history cached.
tg_cache._save_history_to_cache("chan", _fake_messages(37), limit=100)
served = tg_cache._get_history_from_cache("chan", limit=200)
assert served is not None
assert len(served) == 37
def test_save_stores_fetch_limit_not_len(cache_dir):
tg_cache._save_history_to_cache("chan", _fake_messages(37), limit=100)
path = tg_cache._cache_file_path("chan", "history.json")
payload = tg_cache._load_entry(path, max_age_hours=8)
assert payload["limit"] == 100
assert len(payload["messages"]) == 37
def test_history_ttl_independent_of_prefix(cache_dir):
tg_cache._save_history_to_cache("chan", _fake_messages(100), limit=100)
# Expired regardless of prefix match.
assert tg_cache._get_history_from_cache("chan", limit=50, max_age_hours=0) is None
# --------------------------------------------------------------------------- #
# Jitter stability (Задание 17 / item 12).
# --------------------------------------------------------------------------- #
def test_jitter_read_is_stable(cache_dir):
path = str(cache_dir / "x.history.json")
tg_cache._store_entry(path, {"data": 1})
# Repeated reads near a TTL boundary must give a stable result: the jitter is fixed at
# write time and _load_entry never calls random().
results = [tg_cache._load_entry(path, max_age_hours=8) is not None for _ in range(10)]
assert all(results)
def test_load_does_not_call_random(cache_dir, monkeypatch):
path = str(cache_dir / "x.history.json")
tg_cache._store_entry(path, {"data": 1})
def boom(*a, **k):
raise AssertionError("random.uniform must not be called at read time")
monkeypatch.setattr(tg_cache.random, "uniform", boom)
assert tg_cache._load_entry(path, max_age_hours=8) is not None
# --------------------------------------------------------------------------- #
# chatinfo store round trip.
# --------------------------------------------------------------------------- #
def test_chatinfo_roundtrip(cache_dir):
tg_cache._save_chat_to_cache("chan", {"id": -100, "title": "T", "username": "u"})
data = tg_cache._get_chat_from_cache("chan")
assert data == {"id": -100, "title": "T", "username": "u"}
def test_no_pickle_import():
import pathlib
src = pathlib.Path(tg_cache.__file__).read_text()
assert "pickle" not in src