# Test Plan ## `config.py` ### `get_settings()` * **Цель:** Проверить корректность чтения настроек из переменных окружения и использование значений по умолчанию. * **Тест-кейсы:** * **Все переменные установлены:** * Установить все переменные окружения (`TG_API_ID`, `TG_API_HASH`, `SESSION_PATH`, `API_HOST`, `API_PORT`, `PYROGRAM_BRIDGE_URL`, `LOG_LEVEL`, `DEBUG`, `TOKEN`, `TIME_BASED_MERGE`, `SHOW_BRIDGE_LINK`, `SHOW_POST_FLAGS`). * Вызвать `get_settings()`. * Проверить, что возвращенный словарь содержит правильные значения и типы данных (int для `tg_api_id`, `api_port`; bool для `debug`, `time_based_merge`, `show_bridge_link`, `show_post_flags`). * **Использование значений по умолчанию:** * Установить только обязательные `TG_API_ID` и `TG_API_HASH`. * Не устанавливать остальные переменные. * Вызвать `get_settings()`. * Проверить, что для необязательных переменных используются значения по умолчанию (`data`, `0.0.0.0`, `8000`, `""`, `INFO`, `False`, `""`, `False`, `False`, `False`). * **Частично установленные переменные:** * Установить `TG_API_ID`, `TG_API_HASH` и некоторые необязательные переменные (например, `API_PORT`, `DEBUG="True"`). * Вызвать `get_settings()`. * Проверить, что установленные переменные считаны корректно, а для остальных используются значения по умолчанию. * **Проверка булевых флагов:** * Проверить различные варианты для булевых флагов (`DEBUG`, `TIME_BASED_MERGE`, `SHOW_BRIDGE_LINK`, `SHOW_POST_FLAGS`): * `"True"` -> `True` * `"true"` -> `True` (для `TIME_BASED_MERGE`, `SHOW_BRIDGE_LINK`, `SHOW_POST_FLAGS`) * `"False"` -> `False` * `"anything else"` -> `False` * Переменная не установлена -> `False` * **Проверка обязательных переменных (негативный тест):** * Не устанавливать `TG_API_ID`. * Мокнуть `os._exit` и `print`. * Вызвать `get_settings()`. * Проверить, что `print` был вызван с сообщением об ошибке "TG_API_ID and TG_API_HASH must be set". * Проверить, что `os._exit(1)` был вызван. * Повторить тест, не устанавливая `TG_API_HASH`. * Повторить тест, не устанавливая обе переменные. * **Проверка типов:** * Убедиться, что `tg_api_id` и `api_port` всегда возвращаются как `int`. * Передать нечисловое значение в `API_PORT` и проверить, что используется значение по умолчанию `8000`. * Передать нечисловое значение в `TG_API_ID` и проверить, что возникает ошибка (или как это обрабатывается). *Примечание: Текущий код вызовет `ValueError` при `int(tg_api_id)`, это тоже можно проверить.* ## `url_signer.py` ### `KeyManager` * **Цель:** Проверить управление секретным ключом (генерация, чтение из файла, кеширование в памяти). * **Замечание:** Для тестов необходимо активно использовать моки (`unittest.mock`) для изоляции от файловой системы и недетерминированной генерации ключей (`os.path.exists`, `open`, `secrets.token_hex`). Создать временную директорию `data` для тестов или полностью мокать файловые операции. * **Тест-кейсы:** * **Генерация нового ключа:** * Убедиться, что `SECRET_FILE` (`data/media_digest.key`) не существует (используя мок `os.path.exists`). * Мокнуть `secrets.token_hex` для возврата предопределенного ключа. * Мокнуть `open` для проверки записи в файл. * Вызвать `KeyManager.get_or_create_signing_key()`. * Проверить, что `secrets.token_hex(32)` был вызван. * Проверить, что `open` был вызван для записи (`'w'`) в `KeyManager.SECRET_FILE`. * Проверить, что в файл был записан сгенерированный ключ. * Проверить, что метод вернул сгенерированный ключ. * Сбросить состояние моков `os.path.exists` и `open`. * Вызвать `KeyManager.get_or_create_signing_key()` снова. * Проверить, что `secrets.token_hex` и `open` для записи *не* были вызваны (ключ взят из памяти `cls.signing_key`). * Проверить, что метод вернул тот же ключ. * **Чтение ключа из файла:** * Установить `KeyManager.signing_key = None` для сброса кеша в памяти. * Мокнуть `os.path.exists` чтобы он возвращал `True`. * Мокнуть `open` для чтения (`'r'`) из `KeyManager.SECRET_FILE`, возвращая предопределенный ключ. * Вызвать `KeyManager.get_or_create_signing_key()`. * Проверить, что `os.path.exists` был вызван. * Проверить, что `open` был вызван для чтения. * Проверить, что `secrets.token_hex` *не* был вызван. * Проверить, что метод вернул ключ, прочитанный из файла. * Вызвать `KeyManager.get_or_create_signing_key()` снова. * Проверить, что `os.path.exists` и `open` для чтения *не* были вызваны (ключ взят из памяти `cls.signing_key`). * Проверить, что метод вернул тот же ключ. * **Использование ключа из памяти:** * Установить `KeyManager.signing_key` в предопределенное значение. * Вызвать `KeyManager.get_or_create_signing_key()`. * Проверить, что `os.path.exists`, `open` и `secrets.token_hex` *не* были вызваны. * Проверить, что метод вернул установленное значение `KeyManager.signing_key`. ### `generate_media_digest(url)` * **Цель:** Проверить корректность генерации HMAC-SHA1 дайджеста для URL. * **Замечание:** Мокнуть `KeyManager.get_or_create_signing_key`, чтобы возвращать фиксированный ключ для предсказуемости тестов. * **Тест-кейсы:** * **Успешная генерация:** * Мокнуть `KeyManager.get_or_create_signing_key` для возврата известного ключа (например, `'test_key'`). * Вызвать `generate_media_digest` с тестовым URL (например, `'http://example.com/media.jpg'`). * Рассчитать ожидаемый HMAC-SHA1 дайджест для `http://example.com/media.jpg` с ключом `test_key` и взять первые 8 символов. * Проверить, что функция вернула ожидаемый 8-символьный дайджест. * **Разные URL:** * Повторить тест с другим URL, убедиться, что дайджест отличается. * **Пустой URL:** * Вызвать `generate_media_digest('')`. * Проверить, что результат является корректным 8-символьным дайджестом для пустой строки и тестового ключа. * **URL с разными символами:** * Протестировать URL с кириллицей, пробелами, спецсимволами, чтобы убедиться в корректной обработке `url.encode('utf-8')`. ### `verify_media_digest(url, digest)` * **Цель:** Проверить корректность верификации дайджеста. * **Замечание:** Можно мокнуть `generate_media_digest`, чтобы изолировать тесты верификации от тестов генерации, либо использовать общий мок для `KeyManager.get_or_create_signing_key`. Использование `hmac.compare_digest` важно для безопасности, тесты должны это учитывать. * **Тест-кейсы:** * **Успешная верификация:** * Сгенерировать дайджест `d = generate_media_digest(url)` (или использовать предрассчитанный). * Вызвать `verify_media_digest(url, d)`. * Проверить, что результат `True`. * **Неверный дайджест:** * Вызвать `verify_media_digest(url, 'invalid_digest')`. * Проверить, что результат `False`. * **Неверный URL:** * Сгенерировать дайджест `d = generate_media_digest(url1)`. * Вызвать `verify_media_digest(url2, d)` где `url1 != url2`. * Проверить, что результат `False`. * **Пустой дайджест:** * Вызвать `verify_media_digest(url, '')`. * Проверить, что результат `False`. * **Дайджест `None`:** * Вызвать `verify_media_digest(url, None)`. * Проверить, что результат `False`. * **Дайджест неверной длины:** * Вызвать `verify_media_digest(url, 'short')`. * Проверить, что результат `False` (из-за несовпадения с ожидаемым 8-символьным дайджестом при `hmac.compare_digest`). * Вызвать `verify_media_digest(url, 'toolongdigest')`. * Проверить, что результат `False`. ## `telegram_client.py` ### `TelegramClient` * **Цель:** Проверить инициализацию, запуск и остановку клиента Pyrogram. * **Замечание:** Основное внимание уделяется мокированию зависимостей (`pyrogram.Client`, `config.get_settings`, `os.makedirs`) и проверке правильности вызовов методов. * **Тест-кейсы:** * **Инициализация (`__init__`)**: * Мокнуть `config.get_settings` для возврата тестовых настроек. * Мокнуть `os.makedirs`. * Мокнуть `pyrogram.Client`. * Создать экземпляр `TelegramClient`. * Проверить, что `get_settings` был вызван. * Проверить, что `os.makedirs` был вызван с правильным `session_path` из настроек и `exist_ok=True`. * Проверить, что `pyrogram.Client` был вызван с правильными `name`, `api_id`, `api_hash`, `workdir` из настроек. * **Ошибка создания директории:** * Настроить мок `os.makedirs` так, чтобы он вызывал исключение. * Проверить, что при создании экземпляра `TelegramClient` возникает исключение. * **Запуск (`start`)**: * Мокнуть `self.client` (экземпляр `pyrogram.Client`) с атрибутом `is_connected` и методом `start`. * **Клиент не подключен:** * Установить `self.client.is_connected = False`. * Вызвать `await client.start()`. * Проверить, что `self.client.start()` был вызван. * **Клиент уже подключен:** * Установить `self.client.is_connected = True`. * Вызвать `await client.start()`. * Проверить, что `self.client.start()` *не* был вызван. * **Ошибка при запуске:** * Настроить мок `self.client.start()` так, чтобы он вызывал исключение. * Установить `self.client.is_connected = False`. * Проверить, что при вызове `await client.start()` возникает исключение. * **Остановка (`stop`)**: * Мокнуть `self.client` с атрибутом `is_connected` и методом `stop`. * **Клиент подключен:** * Установить `self.client.is_connected = True`. * Вызвать `await client.stop()`. * Проверить, что `self.client.stop()` был вызван. * **Клиент не подключен:** * Установить `self.client.is_connected = False`. * Вызвать `await client.stop()`. * Проверить, что `self.client.stop()` *не* был вызван. * **Ошибка при остановке (хотя `stop` обычно не вызывает исключений, но можно проверить):** * Настроить мок `self.client.stop()` так, чтобы он вызывал исключение (если это возможно). * Установить `self.client.is_connected = True`. * Вызвать `await client.stop()` и обработать потенциальное исключение (или убедиться, что оно не мешает). ## `post_parser.py` ### `PostParser` * **Цель:** Проверить парсинг и форматирование сообщений Telegram в JSON и HTML. * **Замечание:** Это ключевой модуль. Тесты должны покрывать все типы сообщений, медиа, форварды, ответы и другие особенности. Потребуется обширное использование моков для `pyrogram.Client.get_messages`, `config.get_settings`, `url_signer.generate_media_digest`, `os.path`, `open`, `json`, `bleach`, `datetime`. Рекомендуется создать фабрику или набор фикстур для генерации тестовых объектов `Message` с различными атрибутами. #### Инициализация и Основные методы * **`__init__(self, client)`**: * **Цель:** Проверить сохранение клиента. * **Тест-кейс:** Создать мок клиента, инициализировать `PostParser`, проверить, что `self.client` равен моку. * **`channel_name_prepare(self, channel)`**: * **Цель:** Проверить корректное преобразование ID канала. * **Тест-кейсы:** * Передать строковый ID вида `"-100..."`. Проверить, что возвращается `int`. * Передать строковый username (`"mychannel"`). Проверить, что возвращается строка без изменений. * Передать числовой ID (если возможно). Проверить поведение (вероятно, вернет как есть). * Передать пустую строку. Проверить поведение. * **`get_post(self, channel, post_id, output_type, debug)`**: * **Цель:** Проверить основную логику получения и форматирования поста. * **Замечание:** Мокнуть `self.client.get_messages`, `self.channel_name_prepare`, `self._format_html`, `self.process_message`. * **Тест-кейсы:** * **Успешный вызов (HTML):** * Мокнуть `get_messages` для возврата тестового `Message`. * Вызвать `get_post` с `output_type='html'`. * Проверить, что `channel_name_prepare` был вызван с `channel`. * Проверить, что `get_messages` был вызван с подготовленным каналом и `post_id`. * Проверить, что `_format_html` был вызван с полученным сообщением и `debug`. * Проверить, что `process_message` *не* был вызван напрямую (вызывается внутри `_format_html`). * Проверить, что результат равен значению, возвращенному `_format_html`. * **Успешный вызов (JSON):** * Мокнуть `get_messages` для возврата тестового `Message`. * Вызвать `get_post` с `output_type='json'`. * Проверить, что `channel_name_prepare` и `get_messages` были вызваны. * Проверить, что `process_message` был вызван с полученным сообщением. * Проверить, что `_format_html` *не* был вызван. * Проверить, что результат равен значению, возвращенному `process_message`. * Проверить, что результат равен значению, возвращенному `process_message`. * **Пост не найден:** * Мокнуть `get_messages` для возврата `None` или пустого списка. * Вызвать `get_post`. * Проверить, что возвращается `None`. * **Неверный `output_type`:** * Вызвать `get_post` с `output_type='invalid'`. * Проверить, что возвращается `None` и логируется ошибка. * **Исключение при `get_messages`:** * Настроить мок `get_messages` так, чтобы он вызывал исключение. * Проверить, что `get_post` перехватывает исключение, логирует его и вызывает `raise`. * **Включен Debug:** * Мокнуть `print`. * Установить `Config["debug"] = True`. * Вызвать `get_post`. * Проверить, что `print(message)` был вызван после `get_messages`. #### Вспомогательные функции извлечения информации * **`_get_author_info(self, message)`**: * **Цель:** Проверить извлечение имени автора. * **Тест-кейсы:** * Сообщение от канала (есть `sender_chat` с `title` и `username`). * Сообщение от канала (есть `sender_chat` только с `title`). * Сообщение от пользователя (есть `from_user` с `first_name`, `last_name`, `username`). * Сообщение от пользователя (есть `from_user` только с `first_name`). * Сообщение от пользователя (есть `from_user` только с `username`). * Сообщение без `sender_chat` и `from_user`. Проверить возврат `"Unknown author"`. * Поля с `None` или пустыми строками. * **`_generate_title(self, message)`**: * **Цель:** Проверить генерацию заголовка поста. * **Тест-кейсы:** * Сообщение "Channel created" (`channel_chat_created=True`). * Текст содержит только URL YouTube. * Текст содержит только другой URL. * Текст содержит только URL и пробельные символы. * Текст пустой, медиа - фото/видео/GIF/аудио/голос/кружок/стикер/опрос/PDF/документ/webpage. Проверить соответствующие заголовки. * Текст пустой, медиа нет. * Текст короткий (до 100 символов), без URL и тегов. * Текст длинный (больше 100 символов). Проверить обрезку по последнему пробелу и добавление "...". * Текст содержит URL и HTML-теги. Проверить их удаление перед генерацией заголовка. * Текст после удаления URL/тегов пустой, но есть `web_page`. Проверить заголовок "🔗 Web link". * Текст содержит несколько строк. Проверить, что берется только первая. * **`_format_forward_info(self, message)`**: * **Цель:** Проверить форматирование информации о пересылке. * **Тест-кейсы:** * Переслано из чата с `username`. * Переслано из чата без `username`. * Переслано от пользователя с `username`. * Переслано от пользователя без `username`. * Переслано от скрытого пользователя (`forward_sender_name`). * Сообщение не переслано (все соответствующие атрибуты `None`). Проверить возврат `None`. * Поля `title`, `first_name`, `last_name` пустые или `None`. * **`_format_reply_info(self, message)`**: * **Цель:** Проверить форматирование информации об ответе/закрепе. * **Тест-кейсы:** * Закрепленное сообщение (`message.service`, `reply_to_message`). * Ответ на сообщение в чате с `username`. * Ответ на сообщение в чате без `username`. * Текст ответа длинный (> 100 символов). Проверить обрезку. * Текст ответа пустой (`text` и `caption` - `None`). * Сообщение не является ответом/закрепом. Проверить возврат `None`. * **`_extract_reactions(self, message)`**: * **Цель:** Проверить извлечение реакций. * **Тест-кейсы:** * Сообщение с реакциями (`message.reactions.reactions`). * Сообщение без реакций (`message.reactions` - `None`). * Реакции разных типов (эмодзи). * **`_extract_flags(self, message)`**: * **Цель:** Проверить извлечение флагов поста. * **Замечание:** Мокнуть `self._generate_html_body`, `self.get_channel_username`. * **Тест-кейсы:** * Пересланное сообщение (`fwd`). * Видео/анимация с коротким текстом (`video`). * Пост без медиа или опрос (`no_image`). * Стикер (`sticker`). * Текст содержит "стрим", "вебинар" и т.д. (`stream`). * Текст содержит "донат" (`donat`). * Реакции с 30+ 🤡 (`clown`). * Реакции с 30+ 💩 (`poo`). * Текст содержит "#реклама", "erid" и т.д. (`advert`). * Текст/атрибуты содержат http/https ссылки (`link`). * Текст содержит упоминания `@channel` (`mention`). * Текст содержит скрытые ссылки `t.me/+...` (`hid_channel`). * Текст содержит ссылки на *другие* открытые каналы `t.me/otherchannel` (`foreign_channel`). Проверить, что ссылка на *текущий* канал не добавляет флаг. * Комбинации флагов. * Сообщение без атрибутов, вызывающих флаги. * **`get_channel_username(self, message)`**: * **Цель:** Проверить извлечение username или ID канала. * **Тест-кейсы:** * Чат с активным `username` в `usernames`. * Чат с неактивным `username` в `usernames`, но есть `username`. * Чат только с `username`. * Чат без `username`, но с ID вида `-100...`. Проверить возврат `str(ID)`. * Чат без `username` и с другим ID. Проверить возврат `None`. * Объект `message` не содержит `chat` или `chat` равен `None`. #### Функции форматирования HTML * **`_format_html(self, message, debug)`**: * **Цель:** Проверить сборку HTML из частей. * **Замечание:** Мокнуть `self.process_message` и `json.dumps`. * **Тест-кейсы:** * Обычный вызов (`debug=False`). Проверить вызов `process_message`, сборку строк из `['html']['header']`, `['html']['media']`, `['html']['body']`, `['html']['footer']`. * Вызов с `debug=True`. Проверить добавление блока `
` с `json.dumps(message)`.
* **`_format_flags(self, message)`**:
* **Цель:** Проверить форматирование флагов в HTML.
* **Замечание:** Мокнуть `self._extract_flags` и `Config['show_post_flags']`.
* **Тест-кейсы:**
* `show_post_flags = False`. Проверить возврат пустой строки.
* `show_post_flags = True`, `_extract_flags` возвращает пустой список. Проверить возврат пустой строки.
* `show_post_flags = True`, `_extract_flags` возвращает список флагов. Проверить генерацию `div.message-flags` с флагами.
* **`_generate_html_header(self, message)`**:
* **Цель:** Проверить генерацию HTML-заголовка.
* **Замечание:** Мокнуть `self._format_forward_info`, `self._format_reply_info`, `self._sanitize_html`.
* **Тест-кейсы:**
* Есть информация о форварде. Проверить включение результата `_format_forward_info`.
* Есть информация об ответе (но нет форварда). Проверить включение результата `_format_reply_info`.
* Нет ни форварда, ни ответа. Проверить пустой результат (до санации).
* Проверить вызов `_sanitize_html`.
* **`_generate_html_body(self, message)`**:
* **Цель:** Проверить генерацию основного тела HTML.
* **Замечание:** Мокнуть `self._add_hyperlinks_to_raw_urls`, `self._format_poll`, `self._sanitize_html`.
* **Тест-кейсы:**
* Есть `message.text`. Проверить использование `message.text.html`, замену `\n` на `
`, вызов `_add_hyperlinks_to_raw_urls`, оборачивание в `div.message-text`.
* Есть `message.caption` (текста нет). Аналогично `message.text`.
* Есть `message.poll`. Проверить вызов `_format_poll` и добавление результата.
* Есть `message.channel_chat_created`. Проверить добавление `div.message-service`.
* Комбинация текста и опроса.
* Нет текста, подписи, опроса, сервис-сообщения. Проверить пустой результат (до санации).
* Проверить вызов `_sanitize_html`.
* **`_generate_html_media(self, message)`**:
* **Цель:** Проверить генерацию HTML для медиа.
* **Замечание:** Мокнуть `self._save_media_file_ids`, `self._get_file_unique_id`, `self.get_channel_username`, `Config['pyrogram_bridge_url']`, `generate_media_digest`, `self._format_webpage`, `self._sanitize_html`.
* **Тест-кейсы:**
* Разные типы медиа (`PHOTO`, `VIDEO`, `ANIMATION`, `VIDEO_NOTE`, `AUDIO`, `VOICE`, `STICKER`, `DOCUMENT` (обычный), `DOCUMENT` (PDF)). Проверить генерацию соответствующих тегов (`img`, `video`, `audio`) с правильными `src` (включая подпись URL), `style`, `controls`.
* Медиа без `file_unique_id`.
* Сообщение без медиа.
* Сообщение с `web_page`, короткий текст/подпись. Проверить вызов `_format_webpage` и добавление результата.
* Сообщение с `web_page`, длинный текст/подпись. Проверить, что `_format_webpage` *не* добавляется.
* Проверить вызов `_save_media_file_ids` в начале.
* Проверить вызов `_sanitize_html` в конце.
* **`_format_webpage(self, webpage, message)`**:
* **Цель:** Проверить форматирование превью веб-страницы.
* **Замечание:** Мокнуть `Config['pyrogram_bridge_url']`, `self.get_channel_username`, `generate_media_digest`, `self._add_hyperlinks_to_raw_urls`.
* **Тест-кейсы:**
* Обычная веб-страница (с `site_name`, `title`, `description`, `display_url`, `photo`).
* Ссылка на сообщение Telegram (`type="telegram_message"`). Проверить особое форматирование.
* Отсутствие некоторых атрибутов (`site_name`, `title`, `description`, `display_url`, `photo`, `url`).
* Описание (`description`) с переносами строк и URL. Проверить обработку.
* Фото в превью (`webpage.photo`). Проверить генерацию `img src` с подписью.
* Исключение во время обработки. Проверить логирование и возврат `None`.
* **`_generate_html_footer(self, message)`**:
* **Цель:** Проверить генерацию HTML-подвала.
* **Замечание:** Мокнуть `self._reactions_views_links`, `self._format_flags`, `self._sanitize_html`.
* **Тест-кейсы:**
* Есть реакции/просмотры/ссылки и флаги. Проверить включение результатов обеих функций с `
`.
* Есть только реакции/просмотры/ссылки.
* Есть только флаги.
* Нет ни того, ни другого. Проверить возврат `
` (до санации).
* Проверить вызов `_sanitize_html`.
* **`_add_hyperlinks_to_raw_urls(self, text)`**:
* **Цель:** Проверить добавление тегов `` к "сырым" URL.
* **Тест-кейсы:**
* Текст без URL.
* Текст с одним URL.
* Текст с несколькими URL.
* Текст с URL, который уже внутри тега ``. Проверить, что он не оборачивается еще раз.
* Текст с URL внутри других тегов (``, ``). Проверить, что они оборачиваются.
* Сложный текст с HTML и URL.
* Исключение во время обработки. Проверить логирование и возврат оригинального текста.
* **`_reactions_views_links(self, message)`**:
* **Цель:** Проверить форматирование реакций, просмотров, даты и ссылок.
* **Замечание:** Мокнуть `self.get_channel_username`, `Config['pyrogram_bridge_url']`, `Config['show_bridge_link']`, `Config['token']`, `self._sanitize_html`.
* **Тест-кейсы:**
* Есть реакции (обычные, платные `is_paid`, кастомные `custom_emoji_id`), просмотры, дата. Проверить корректное форматирование первой строки.
* Отсутствуют реакции/просмотры.
* Канал с `username`. Проверить генерацию ссылок `tg://`, `https://t.me/`.
* Канал с ID (`-100...`). Проверить генерацию ссылок `tg://.../c/...`, `https://t.me/c/...`.
* `Config['show_bridge_link'] = True`. Проверить добавление ссылки на бридж с токеном.
* `Config['show_bridge_link'] = False`.
* Нет ни реакций, ни просмотров, ни даты, ни ссылок. Проверить возврат `None`.
* Исключение во время обработки. Проверить логирование и возврат `None`.
* Проверить вызов `_sanitize_html`.
* **`_format_poll(self, poll)`**:
* **Цель:** Проверить форматирование опроса.
* **Тест-кейсы:**
* Обычный опрос с вопросом и вариантами ответа.
* Опрос без вариантов ответа.
* Атрибут `poll.options` отсутствует или `None`.
* Исключение во время обработки. Проверить логирование и возврат строки с ошибкой.
* **`_get_file_unique_id(self, message)`**:
* **Цель:** Проверить извлечение `file_unique_id` для разных типов медиа.
* **Тест-кейсы:**
* Все поддерживаемые `MessageMediaType` (photo, video, document и т.д.).
* `MessageMediaType.WEB_PAGE` с фото.
* `MessageMediaType.WEB_PAGE` без фото.
* Неподдерживаемый тип медиа.
* `message.media` = `None`.
* Отсутствие нужного атрибута (например, `message.photo` есть, но у него нет `file_unique_id`). Проверить обработку исключения и возврат `None`.
* **`_save_media_file_ids(self, message)`**:
* **Цель:** Проверить сохранение `file_unique_id` в JSON-файл.
* **Замечание:** Мокнуть `self.get_channel_username`, `os.path.abspath`, `os.path.exists`, `open`, `json.load`, `json.dump`, `datetime.now`.
* **Тест-кейсы:**
* Сообщение с медиа (не большим видео).
* Файл `media_file_ids.json` не существует. Проверить создание файла и запись данных.
* Файл существует, но записи для этого `file_unique_id` нет. Проверить добавление записи.
* Файл существует, и запись для этого `file_unique_id` уже есть. Проверить обновление времени `added`.
* Сообщение с большим видео (> 100MB). Проверить, что запись *не* происходит.
* Сообщение без медиа. Проверить, что запись *не* происходит.
* Не удалось получить `channel_username`. Проверить логирование и выход.
* Ошибка при чтении/записи JSON-файла. Проверить логирование.
* Ошибка при получении `file_unique_id`. Проверить логирование.
* **`_sanitize_html(self, html_raw)`**:
* **Цель:** Проверить санацию HTML.
* **Замечание:** Мокнуть `HTMLSanitizer` и `CSSSanitizer`.
* **Тест-кейсы:**
* Передать валидный HTML с разрешенными тегами и атрибутами. Проверить, что он не изменился.
* Передать HTML с запрещенными тегами (`