From 129b07abdaa86add8c5caea747d89d9e8af8322b Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Fri, 4 Apr 2025 22:09:52 +0300 Subject: [PATCH] add logging --- .cursor/rules/general.mdc | 134 +++++ .cursor/rules/logging.mdc | 16 + .cursor/rules/project_structure.mdc | 9 + api_server.py | 58 ++- tests/plan.md | 756 ++++++++++++++++++++++++++++ 5 files changed, 955 insertions(+), 18 deletions(-) create mode 100644 .cursor/rules/general.mdc create mode 100644 .cursor/rules/logging.mdc create mode 100644 .cursor/rules/project_structure.mdc create mode 100644 tests/plan.md diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc new file mode 100644 index 0000000..bc97ae5 --- /dev/null +++ b/.cursor/rules/general.mdc @@ -0,0 +1,134 @@ +# General Development & Project Convention Rules + +- **Clarity and Simplicity:** Write code that is easy to understand and maintain. Avoid unnecessary complexity. +- **Modularity:** Break down functionality into smaller, reusable components (functions, classes, modules). *Avoid creating separate directories for every small module unless complexity absolutely requires it.* +- **Configuration Management:** Separate configuration from code. Use a dedicated file (like `config.py`) or environment variables. +- **Dependency Management:** List *all* Python dependencies with pinned versions in `requirements.txt`. Use a virtual environment (like `.venv`) for local development (ensure `.venv` is in `.gitignore`). +- **Containerization:** + - Use `Dockerfile` to define the application image. Follow established patterns in the existing `Dockerfile`. + ```Dockerfile # Example Structure + FROM python:3.11-slim + + WORKDIR /app + + # Set environment variables (optional) + # ENV PYTHONDONTWRITEBYTECODE 1 + # ENV PYTHONUNBUFFERED 1 + + # Install dependencies + COPY requirements.txt . + RUN pip install --no-cache-dir -r requirements.txt + + # Copy application code + COPY src/ /app/src/ + # If code is in root: + # COPY . . + + # Expose port (if applicable) + EXPOSE 8000 + + # Command to run the application + CMD ["python", "src/api_server.py"] # or "uvicorn", "src.api_server:app", ... + ``` + - Use `docker-compose.yml` for local development setup and potentially for deployment orchestration. + ```yaml # Example Structure (using pre-built image) not use version, its deprecated! + volumes: + app_data: # Define named volume for persistence + + services: + app: + image: ghcr.io/vvzvlad/project:latest # Use pre-built image + container_name: project-name + ports: + - "8000:80" # Map host port 8000 to container port 80 (adjust if needed) + volumes: + - app_data:/app/data # Mount named volume for data + environment: + TG_API_ID: "YOUR_API_ID" + TG_API_HASH: "YOUR_API_HASH" + restart: unless-stopped + ``` + - **Data Persistence:** Mount the `./data/` directory as a volume in Docker containers to persist application data (cache, logs, etc.) outside the container. All application-generated files should go here. +- **Version Control:** Follow standard Git practices (meaningful commit messages, branches for features/fixes). +- **CI/CD:** Utilize GitHub Actions (workflows defined in `.github/workflows/`) for automated tasks like testing, building, and deployment. (Note: Add this section if `.github/` directory is actively used for workflows). +- **Documentation:** + - Maintain a comprehensive `README.md` explaining the project's purpose, setup, configuration, and usage. + Recommended `README.md` Structure: + ```markdown + # Project Title + + [Brief Description] + + ## Features + - Feature 1 + - Feature 2 + + ## Requirements + - Docker & Docker Compose + - Python 3.x (for local non-Docker development, if supported) + - ... + + ## Installation & Setup (Docker) + 1. Ensure Docker and Docker Compose are installed. + 2. Create `docker-compose.yml` based on the example in the README or the rules. + 3. Create `.env` file (if needed for `env_file` directive in your *actual* `docker-compose.yml`) or configure environment variables directly in `docker-compose.yml`. + 4. Pull the image and start the container: `docker-compose up -d` + 5. Perform first-time setup if necessary (login via `docker exec`, see main README). + 6. Access the application at `http://localhost:8000` (or the configured host port). + + ## Configuration + Explain environment variables (loaded via `environment` or `env_file` in Docker Compose) or config file settings. + + ## Usage + Explain how to use the application (API endpoints, common tasks). + + ## Development + Instructions for setting up a local development environment (if different from Docker setup). + - How to run tests (if applicable). + - Linting/formatting tools and commands. + + ## Contributing (Optional) + + ## License (Optional) + ``` + - Add docstrings to public functions and classes explaining their purpose, arguments, and return values. Add comments for complex or non-obvious logic. +- **Language:** All code, comments, logs, and documentation must be in English. +- **Naming:** Use `snake_case` for variables, functions, and filenames. Use `PascalCase` for classes. +- **Testing:** While not currently implemented, aim to add tests for critical functionality in the future (e.g., in the `tests/` directory). +- **Standard File Header:** Start all Python files (`.py`) with the following header for consistency and linter configuration: + ```python + #!/usr/bin/env python3 + # -*- coding: utf-8 -*- + + # flake8: noqa + # pylint: disable=broad-exception-raised, raise-missing-from, too-many-arguments, redefined-outer-name + # pylance: disable=reportMissingImports, reportMissingModuleSource, reportGeneralTypeIssues + # type: ignore + + # --- Your code starts here --- + ``` + +- **Project Structure (Target):** + - All Python source code should ideally reside within the `src/` directory. + - Keep the structure flat initially; avoid deep nesting unless necessary. + - Example Target Layout: + ``` + .github/ + data/ + src/ + __init__.py + api_server.py + config.py + post_parser.py + rss_generator.py + telegram_client.py + url_signer.py + utils.py # For common helpers + tests/ + .cursor/ + .gitignore + Dockerfile + docker-compose.yml + README.md + requirements.txt + ``` \ No newline at end of file diff --git a/.cursor/rules/logging.mdc b/.cursor/rules/logging.mdc new file mode 100644 index 0000000..9139f7e --- /dev/null +++ b/.cursor/rules/logging.mdc @@ -0,0 +1,16 @@ +# Logging Rules + +- **Library:** Use the standard Python `logging` module. +- **Configuration:** Configure logger level (e.g., `INFO`, `DEBUG`) and format centrally, potentially based on environment (development vs. production). +- **Level Usage:** + - `DEBUG`: Detailed information, typically of interest only when diagnosing problems. + - `INFO`: Confirmation that things are working as expected (e.g., server start, request received, background task completion). + - `WARNING`: An indication that something unexpected happened, or indicative of some problem in the near future (e.g., 'disk space low'). The software is still working as expected. + - `ERROR`: Due to a more serious problem, the software has not been able to perform some function. + - `CRITICAL`: A serious error, indicating that the program itself may be unable to continue running. +- **Message Content:** + - **Be Specific:** Avoid generic messages. State *what* happened and provide context. Instead of "Error", write "Failed to download media file {file_id} from channel {channel_id}: {error_message}". + - **Include Relevant Data:** Log important identifiers (user IDs, request IDs, file IDs, channel IDs) when applicable. + - **Language:** Log messages must be in English. +- **Context:** Ensure log records include timestamp, logger name, level, and the message. +- **Performance:** Be mindful of logging frequency in performance-critical sections. Avoid excessive logging in loops unless necessary for debugging. \ No newline at end of file diff --git a/.cursor/rules/project_structure.mdc b/.cursor/rules/project_structure.mdc new file mode 100644 index 0000000..91e1cdb --- /dev/null +++ b/.cursor/rules/project_structure.mdc @@ -0,0 +1,9 @@ +# Project Structure Rules + +- **Keep core logic in root files:** `api_server.py`, `post_parser.py`, `rss_generator.py`, `telegram_client.py`, `url_signer.py` contain the main functionality. +- **Configuration:** All configuration is managed in `config.py`. +- **Dependencies:** Python dependencies are listed in `requirements.txt`. +- **Docker:** Docker setup is defined in `Dockerfile` and `docker-compose.yml`. +- **Data:** Persistent data and cache should be stored in the `data/` directory. +- **Naming Conventions:** Use `snake_case` for filenames and variables. +- **Language:** All code comments and log messages must be in English. \ No newline at end of file diff --git a/api_server.py b/api_server.py index d8b5bdf..3b95ed8 100644 --- a/api_server.py +++ b/api_server.py @@ -112,24 +112,46 @@ async def find_file_id_in_message(message, file_unique_id: str): logger.debug(f"Message {message.id} is a poll, skipping media search") return None - if message.photo and message.photo.file_unique_id == file_unique_id: - return message.photo.file_id - elif message.video and message.video.file_unique_id == file_unique_id: - return message.video.file_id - elif message.animation and message.animation.file_unique_id == file_unique_id: - return message.animation.file_id - elif message.video_note and message.video_note.file_unique_id == file_unique_id: - return message.video_note.file_id - elif message.audio and message.audio.file_unique_id == file_unique_id: - return message.audio.file_id - elif message.voice and message.voice.file_unique_id == file_unique_id: - return message.voice.file_id - elif message.sticker and message.sticker.file_unique_id == file_unique_id: - return message.sticker.file_id - elif message.web_page and message.web_page.photo and message.web_page.photo.file_unique_id == file_unique_id: - return message.web_page.photo.file_id - elif message.document and message.document.file_unique_id == file_unique_id: - return message.document.file_id + media_found = [] + if message.photo: + media_found.append(f"photo ({message.photo.file_unique_id})") + if message.photo.file_unique_id == file_unique_id: + return message.photo.file_id + if message.video: + media_found.append(f"video ({message.video.file_unique_id})") + if message.video.file_unique_id == file_unique_id: + return message.video.file_id + if message.animation: + media_found.append(f"animation ({message.animation.file_unique_id})") + if message.animation.file_unique_id == file_unique_id: + return message.animation.file_id + if message.video_note: + media_found.append(f"video_note ({message.video_note.file_unique_id})") + if message.video_note.file_unique_id == file_unique_id: + return message.video_note.file_id + if message.audio: + media_found.append(f"audio ({message.audio.file_unique_id})") + if message.audio.file_unique_id == file_unique_id: + return message.audio.file_id + if message.voice: + media_found.append(f"voice ({message.voice.file_unique_id})") + if message.voice.file_unique_id == file_unique_id: + return message.voice.file_id + if message.sticker: + media_found.append(f"sticker ({message.sticker.file_unique_id})") + if message.sticker.file_unique_id == file_unique_id: + return message.sticker.file_id + if message.web_page and message.web_page.photo: + media_found.append(f"web_page.photo ({message.web_page.photo.file_unique_id})") + if message.web_page.photo.file_unique_id == file_unique_id: + return message.web_page.photo.file_id + if message.document: + media_found.append(f"document ({message.document.file_unique_id})") + if message.document.file_unique_id == file_unique_id: + return message.document.file_id + + # If we reached here, the file_unique_id was not found + logger.warning(f"Could not find media with file_unique_id '{file_unique_id}' in message {message.id} (channel: {message.chat.id}). Found media: {', '.join(media_found) or 'None'}") return None diff --git a/tests/plan.md b/tests/plan.md new file mode 100644 index 0000000..195d390 --- /dev/null +++ b/tests/plan.md @@ -0,0 +1,756 @@ +# 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 с запрещенными тегами (`