DispelEvilBot/tests/test_analytics.py

419 lines
13 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.

"""Тесты аналитики: каталог событий, буферизация и запись в Postgres.
Postgres не требуется: writer подменяется фейком.
"""
from __future__ import annotations
import re
from datetime import UTC, datetime
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
from common.analytics import (
ALL_EVENT_TYPES,
EventType,
close_analytics,
get_analytics,
init_analytics,
record,
record_message,
)
from common.analytics.client import PAYLOAD_MAX_LENGTH, AnalyticsClient
from common.analytics.messages import describe_command, describe_message
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 _FakeEnum:
"""Заглушка enum-полей aiogram (у значений есть ``.value``)."""
def __init__(self, value: str) -> None:
self.value = value
class FakeWriter:
"""Заглушка записи в Postgres: складывает батчи в списки."""
def __init__(self, *, fail: bool = False) -> None:
self.events: list[list[Any]] = []
self.messages: dict[tuple[Any, int, int], list[Any]] = {}
self._fail = fail
async def __call__(self, events: list[list[Any]], messages: dict[tuple[Any, int, int], list[Any]]) -> None:
if self._fail:
raise RuntimeError("Postgres недоступен")
self.events.extend(events)
for key, value in messages.items():
if key in self.messages:
self.messages[key][1] += value[1]
self.messages[key][0] = value[0]
else:
self.messages[key] = list(value)
class FakeCursor:
"""Курсор psycopg: запоминает вызовы."""
def __init__(self) -> None:
self.calls: list[tuple[str, Any, Any]] = []
async def executemany(self, query: str, rows: list[Any]) -> None:
self.calls.append(("executemany", query, rows))
async def execute(self, query: str, params: Any) -> None:
self.calls.append(("execute", query, params))
async def __aenter__(self) -> FakeCursor:
return self
async def __aexit__(self, *args: Any) -> None:
return None
class FakeConnection:
"""Соединение psycopg с курсором."""
def __init__(self) -> None:
self.cursor_obj = FakeCursor()
def cursor(self) -> FakeCursor:
return self.cursor_obj
async def __aenter__(self) -> FakeConnection:
return self
async def __aexit__(self, *args: Any) -> None:
return None
class FakePool:
"""Пул с одним соединением."""
def __init__(self) -> None:
self.conn = FakeConnection()
def connection(self) -> FakeConnection:
return self.conn
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, FakeWriter]:
fake = FakeWriter()
return AnalyticsClient(_settings(**overrides), writer=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 разошёлся"
# --- метаданные сообщения (без aiogram) ---
def _fake_message(**overrides: Any) -> Any:
"""Минимальная заглушка aiogram Message для describe_message/describe_command."""
fields: dict[str, Any] = {
"message_id": 77,
"text": "привет",
"caption": None,
"content_type": _FakeEnum("text"),
"chat": SimpleNamespace(type=_FakeEnum("supergroup")),
}
fields.update(overrides)
return SimpleNamespace(**fields)
def test_describe_message_without_text() -> None:
payload = describe_message(_fake_message(text="привет"))
assert payload == {
"message_id": 77,
"chat_type": "supergroup",
"content_type": "text",
"text_len": 6,
"is_command": False,
}
def test_describe_message_uses_caption_for_media() -> None:
payload = describe_message(_fake_message(text=None, caption="подпись", content_type=_FakeEnum("photo")))
assert payload["content_type"] == "photo"
assert payload["text_len"] == len("подпись")
assert payload["is_command"] is False
def test_describe_command_bare_and_with_args() -> None:
assert describe_command(_fake_message(text="/очищение")) == {"command": "очищение", "args_count": 0}
assert describe_command(_fake_message(text="/ник Вася")) == {"command": "ник", "args_count": 1}
def test_describe_command_strips_bot_mention() -> None:
assert describe_command(_fake_message(text="/Ник@DispelEvilBot Вася")) == {"command": "ник", "args_count": 1}
def test_describe_command_returns_none_for_plain_message() -> None:
assert describe_command(_fake_message(text="просто текст")) is None
assert describe_command(_fake_message(text=None, caption="подпись")) is None
# --- буферизация и запись ---
async def test_record_without_init_is_noop() -> None:
assert get_analytics() is None
record(EventType.COMMAND, user_id=1, chat_id=2, payload={"a": 1}) # не должно бросать
record_message(user_id=1, username="vasya", chat_id=2)
async def test_client_buffers_and_flushes_rows() -> None:
client, fake = _client()
client.record(EventType.COMMAND, user_id=11, username="vasya", chat_id=100, payload={"command": "инфо"})
client.record(EventType.PURIFICATION, user_id=11, username="vasya", chat_id=100, payload={"success": True})
assert client.pending == 2
await client.flush()
assert client.pending == 0
assert len(fake.events) == 2
row = fake.events[0]
assert isinstance(row[0], datetime)
assert row[1] == "command"
assert row[2] == 11
assert row[3] == "vasya"
assert row[4] == 100
assert row[5] == {"command": "инфо"}
assert row[6] == __version__
assert row[7] is False
async def test_message_counter_is_aggregated_per_day() -> None:
client, fake = _client()
client.record_message(user_id=11, username="vasya", chat_id=100)
client.record_message(user_id=11, username="vasya", chat_id=100)
client.record_message(user_id=12, username=None, chat_id=100)
assert client.pending == 3
await client.flush()
assert client.pending == 0
assert len(fake.messages) == 2
vasya = next(value for key, value in fake.messages.items() if key[1] == 11)
assert vasya == ["vasya", 2]
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.events[0][3] == "nickname"
async def test_record_triggers_flush_at_batch_size() -> None:
client, _ = _client(analytics_batch_size=3)
client.record(EventType.COMMAND, user_id=1)
client.record(EventType.COMMAND, user_id=1)
assert not client._flush_now.is_set()
client.record(EventType.COMMAND, 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.COMMAND, 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(analytics_enabled=False)
client.record(EventType.COMMAND, user_id=1)
client.record_message(user_id=1, username="vasya", chat_id=1)
await client.flush()
assert client.pending == 0
assert fake.events == []
assert fake.messages == {}
# --- запись в Postgres ---
async def test_write_db_uses_cursor_executemany(monkeypatch: pytest.MonkeyPatch) -> None:
from common.analytics import client as analytics_client
pool = FakePool()
monkeypatch.setattr(analytics_client, "get_pool", lambda: pool)
await analytics_client._write_db(
[[datetime.now(UTC), "command", 1, "vasya", 100, {"command": "инфо"}, "0.7.2", False]],
{(datetime.now(UTC).date(), 1, 100): ["vasya", 3]},
)
calls = pool.conn.cursor_obj.calls
assert calls[0][0] == "executemany"
assert calls[1][0] == "execute"
assert "messages_daily" in calls[1][1]
# --- деградация без Postgres ---
async def test_flush_failure_logs_fallback_and_drops(caplog: pytest.LogCaptureFixture) -> None:
fake = FakeWriter(fail=True)
client = AnalyticsClient(_settings(), writer=fake)
client.record(EventType.COMMAND, user_id=1, username="vasya", chat_id=100)
with caplog.at_level("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.COMMAND, user_id=1, payload={"text": "x" * (PAYLOAD_MAX_LENGTH * 2)})
await client.flush()
payload = fake.events[0][5]
assert "_truncated" in payload
assert len(payload["_truncated"]) == PAYLOAD_MAX_LENGTH
# --- жизненный цикл ---
async def test_init_and_close_flush_pending_events() -> None:
fake = FakeWriter()
client = await init_analytics(_settings(), writer=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.events) == 2
# --- middleware (транспортные события) ---
def _message(**overrides: Any) -> Any:
"""Реальное сообщение aiogram для проверки middleware."""
from aiogram.types import Chat, Message, User
fields: dict[str, Any] = {
"message_id": 5,
"date": datetime.now(UTC),
"chat": Chat(id=-100, type="supergroup", title="Клан"),
"from_user": User(id=7, is_bot=False, first_name="Вася", username="vasya"),
"text": "/инфо",
}
fields.update(overrides)
return Message(**fields)
async def _handler(event: Any, data: dict[str, Any]) -> str:
return "handled"
async def test_middleware_counts_message_and_records_command(monkeypatch: pytest.MonkeyPatch) -> None:
from bot.middlewares.analytics import AnalyticsMiddleware
settings = _settings(chat_id=-100)
monkeypatch.setattr("bot.middlewares.analytics.get_settings", lambda: settings)
fake = FakeWriter()
client = await init_analytics(settings, writer=fake)
assert await AnalyticsMiddleware()(_handler, _message(), {}) == "handled"
await client.flush()
assert [row[1] for row in fake.events] == ["command"]
assert fake.events[0][5] == {"command": "инфо", "args_count": 0}
assert fake.events[0][3] == "vasya"
assert len(fake.messages) == 1
assert next(iter(fake.messages.values())) == ["vasya", 1]
async def test_middleware_skips_other_chats_and_bots(monkeypatch: pytest.MonkeyPatch) -> None:
from bot.middlewares.analytics import AnalyticsMiddleware
settings = _settings(chat_id=-100)
monkeypatch.setattr("bot.middlewares.analytics.get_settings", lambda: settings)
fake = FakeWriter()
client = await init_analytics(settings, writer=fake)
from aiogram.types import Chat, User
await AnalyticsMiddleware()(_handler, _message(chat=Chat(id=-999, type="supergroup")), {})
await AnalyticsMiddleware()(
_handler,
_message(from_user=User(id=7, is_bot=True, first_name="Bot")),
{},
)
await client.flush()
assert fake.events == []
assert fake.messages == {}
async def test_middleware_never_breaks_handling(monkeypatch: pytest.MonkeyPatch) -> None:
from bot.middlewares.analytics import AnalyticsMiddleware
settings = _settings(chat_id=-100)
monkeypatch.setattr("bot.middlewares.analytics.get_settings", lambda: settings)
def boom(*args: Any, **kwargs: Any) -> None:
raise RuntimeError("аналитика сломалась")
monkeypatch.setattr("bot.middlewares.analytics.record_message", boom)
assert await AnalyticsMiddleware()(_handler, _message(), {}) == "handled"