DispelEvilBot/tests/test_analytics.py

216 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты аналитики: каталог событий, буферизация и деградация без 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