"""Тесты аналитики: каталог событий, буферизация и деградация без ClickHouse. Тесты не требуют ни ClickHouse, ни aiogram: клиент подменяется фейком, а ``clickhouse_connect`` импортируется лениво внутри клиента. """ from __future__ import annotations import logging import re from datetime import datetime from pathlib import Path from typing import Any import pytest from common.analytics import ALL_EVENT_TYPES, EventType, close_analytics, get_analytics, init_analytics, record from common.analytics.client import PAYLOAD_MAX_LENGTH, AnalyticsClient from common.config import Settings from common.version import __version__ _DOCS_PATH = Path(__file__).resolve().parents[1] / "docs" / "events.md" #: Строка каталога событий: первый столбец в обратных кавычках. _EVENT_ROW = re.compile(r"^\|\s*`([a-z_]+)`\s*\|", re.MULTILINE) class FakeClickHouse: """Заглушка клиента ClickHouse: пишет в список или падает.""" def __init__(self, *, fail: bool = False) -> None: self.rows: list[list[Any]] = [] self.table: str | None = None self.columns: list[str] | None = None self.closed = False self._fail = fail async def insert(self, table: str, data: list[list[Any]], column_names: list[str]) -> None: if self._fail: raise RuntimeError("ClickHouse недоступен") self.table = table self.columns = list(column_names) self.rows.extend(data) async def close(self) -> None: self.closed = True def _settings(**overrides: Any) -> Settings: return Settings(_env_file=None, bot_token="1:a", **overrides) @pytest.fixture(autouse=True) async def _clean_singleton(): """Тесты не должны оставлять активный singleton аналитики.""" await close_analytics() yield await close_analytics() def _client(**overrides: Any) -> tuple[AnalyticsClient, FakeClickHouse]: fake = FakeClickHouse() return AnalyticsClient(_settings(**overrides), client=fake), fake # --- каталог событий --- def test_event_types_are_unique_and_nonempty() -> None: assert len(ALL_EVENT_TYPES) == len(EventType) assert ALL_EVENT_TYPES def test_event_types_match_documentation() -> None: documented = set(_EVENT_ROW.findall(_DOCS_PATH.read_text(encoding="utf-8"))) assert documented == set(ALL_EVENT_TYPES), "Список событий в common/analytics/events.py и docs/events.md разошёлся" # --- буферизация и запись --- async def test_record_without_init_is_noop() -> None: assert get_analytics() is None record(EventType.MESSAGE, user_id=1, chat_id=2, payload={"a": 1}) # не должно бросать async def test_client_buffers_and_flushes_rows() -> None: client, fake = _client() client.record(EventType.MESSAGE, user_id=11, username="vasya", chat_id=100, payload={"message_id": 5}) client.record(EventType.COMMAND, user_id=11, username="vasya", chat_id=100, payload={"command": "/инфо"}) assert client.pending == 2 await client.flush() assert client.pending == 0 assert fake.table == "analytics.events" assert fake.columns == [ "event_time", "event_type", "user_id", "username", "chat_id", "payload", "bot_version", "prod", ] assert len(fake.rows) == 2 row = fake.rows[0] assert isinstance(row[0], datetime) assert row[1] == "message" assert row[2] == 11 assert row[3] == "vasya" assert row[4] == 100 assert '"message_id": 5' in row[5] assert row[6] == __version__ assert row[7] == 0 async def test_remember_user_enriches_username() -> None: client, fake = _client() client.remember_user(42, "nickname") client.record(EventType.PURIFICATION, user_id=42, chat_id=1) await client.flush() assert fake.rows[0][3] == "nickname" async def test_record_triggers_flush_at_batch_size() -> None: client, _ = _client(analytics_batch_size=3) client.record(EventType.MESSAGE, user_id=1) client.record(EventType.MESSAGE, user_id=1) assert not client._flush_now.is_set() client.record(EventType.MESSAGE, user_id=1) assert client._flush_now.is_set() async def test_overflow_drops_oldest_events() -> None: client, _ = _client(analytics_buffer_max=2, analytics_batch_size=100) for user_id in range(1, 5): client.record(EventType.MESSAGE, user_id=user_id) assert client.pending == 2 assert [row[2] for row in client._rows] == [3, 4] async def test_disabled_analytics_ignores_events() -> None: client, fake = _client(clickhouse_enabled=False) client.record(EventType.MESSAGE, user_id=1) await client.flush() assert client.pending == 0 assert fake.rows == [] # --- деградация без ClickHouse --- async def test_flush_failure_logs_fallback_and_drops(caplog: pytest.LogCaptureFixture) -> None: fake = FakeClickHouse(fail=True) client = AnalyticsClient(_settings(), client=fake) client.record(EventType.MESSAGE, user_id=1, username="vasya", chat_id=100) with caplog.at_level(logging.WARNING): await client.flush() # не должно бросать assert client.pending == 0 assert any("Fallback" in message for message in caplog.messages) async def test_unavailable_client_falls_back_to_log(caplog: pytest.LogCaptureFixture) -> None: settings = _settings(clickhouse_host="127.0.0.1", clickhouse_port=1, clickhouse_password="x") client = AnalyticsClient(settings) client.record(EventType.BOT_START, payload={"app_env": "dev"}) with caplog.at_level(logging.WARNING): await client.flush() assert client.pending == 0 assert any("Fallback" in message for message in caplog.messages) async def test_payload_is_truncated() -> None: client, fake = _client() client.record(EventType.MESSAGE, user_id=1, payload={"text": "x" * (PAYLOAD_MAX_LENGTH * 2)}) await client.flush() assert len(fake.rows[0][5]) == PAYLOAD_MAX_LENGTH # --- жизненный цикл --- async def test_init_and_close_flush_pending_events() -> None: fake = FakeClickHouse() client = await init_analytics(_settings(), client=fake) record(EventType.BOT_START, payload={"app_env": "dev"}) record(EventType.BOT_STOP, payload={"app_env": "dev"}) assert get_analytics() is client assert client.pending == 2 await close_analytics() assert get_analytics() is None assert len(fake.rows) == 2 assert fake.closed is True