"""Bot command invocations must be logged to ``EventLog``. Covers the three branches in ``handle_command``: * ``command_handled`` — a successful invocation (here exercised via the helper directly so the test stays focused on the persistence shape). * ``command_rate_limited`` — caller hit the cooldown. * ``command_failed`` — an exception bubbled out of dispatch. The dashboard reads these rows via ``GET /api/status`` so the test also asserts the row is filterable by ``event_type=command_*``. """ from __future__ import annotations import pytest from fastapi.testclient import TestClient from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession def _bootstrap_app(): """Bring up the app once so migrations run against the temp DB.""" from notify_bridge_server.main import app return app async def _seed_user_and_bot(name: str = "Test bot"): """Create a User + TelegramBot, return the bot row.""" from notify_bridge_server.database.engine import get_engine from notify_bridge_server.database.models import TelegramBot, User engine = get_engine() async with AsyncSession(engine) as session: user = User(username=f"u_{name}", hashed_password="x") session.add(user) await session.commit() await session.refresh(user) bot = TelegramBot(user_id=user.id, name=name, token="dummy") session.add(bot) await session.commit() await session.refresh(bot) return bot async def _read_events(event_type: str, bot_id: int): """Filter by bot_id so tests don't leak rows into each other. The temp DB is shared across tests in this module — without this filter a row left by an earlier test would make the next assertion flaky depending on collection order. """ from notify_bridge_server.database.engine import get_engine from notify_bridge_server.database.models import EventLog engine = get_engine() async with AsyncSession(engine) as session: result = await session.exec( select(EventLog) .where(EventLog.event_type == event_type) .where(EventLog.telegram_bot_id == bot_id) ) return list(result.all()) def test_format_command_subject_no_args(tmp_data_dir) -> None: # noqa: ARG001 from notify_bridge_server.commands.handler import _format_command_subject assert _format_command_subject("latest", "") == "/latest" assert _format_command_subject("help", None) == "/help" def test_format_command_subject_with_args(tmp_data_dir) -> None: # noqa: ARG001 from notify_bridge_server.commands.handler import _format_command_subject assert _format_command_subject("search", "sunset") == "/search sunset" # Trailing whitespace must not leak into the dashboard label. assert _format_command_subject("search", "sunset ") == "/search sunset" def test_normalize_issuer_keeps_identity_drops_extras(tmp_data_dir) -> None: # noqa: ARG001 """Telegram ``from`` is whitelisted to identity fields only.""" from notify_bridge_server.commands.handler import _normalize_issuer assert _normalize_issuer(None) is None assert _normalize_issuer({}) is None raw = { "id": 1234, "username": "alex", "first_name": "Alex", "last_name": "", "language_code": "ru", # already captured separately — must drop "is_premium": True, # must not leak into our log } assert _normalize_issuer(raw) == { "id": 1234, "username": "alex", "first_name": "Alex", } def test_log_command_handled_persists_row(tmp_data_dir) -> None: # noqa: ARG001 """``command_handled`` row carries bot + provenance + media count.""" import asyncio from notify_bridge_server.commands.base import CommandResponse from notify_bridge_server.commands.handler import _log_command_event app = _bootstrap_app() with TestClient(app): async def run() -> None: bot = await _seed_user_and_bot("HandledBot") await _log_command_event( bot=bot, chat_id="123456", cmd="latest", args="", locale="en", event_type="command_handled", responses=[CommandResponse(text="ok", media=[{"type": "photo"}])], ctx_tuples=[], # universal command path: no tracker context ) rows = await _read_events("command_handled", bot.id) assert len(rows) == 1 row = rows[0] assert row.user_id == bot.user_id assert row.telegram_bot_id == bot.id assert row.bot_name == "HandledBot" assert row.collection_id == "123456" assert row.collection_name == "/latest" assert row.assets_count == 1 assert row.details["command"] == "latest" assert row.details["chat_id"] == "123456" assert row.details["responses_count"] == 1 asyncio.run(run()) def test_log_command_rate_limited_carries_wait_seconds(tmp_data_dir) -> None: # noqa: ARG001 import asyncio from notify_bridge_server.commands.base import CommandResponse from notify_bridge_server.commands.handler import _log_command_event app = _bootstrap_app() with TestClient(app): async def run() -> None: bot = await _seed_user_and_bot("ThrottledBot") await _log_command_event( bot=bot, chat_id="42", cmd="random", args="", locale="en", event_type="command_rate_limited", responses=[CommandResponse(text="cooldown")], ctx_tuples=[], extra_details={"wait_seconds": 7}, ) rows = await _read_events("command_rate_limited", bot.id) assert len(rows) == 1 assert rows[0].details["wait_seconds"] == 7 assert rows[0].assets_count == 0 # text-only response asyncio.run(run()) def test_log_command_failed_records_error(tmp_data_dir) -> None: # noqa: ARG001 import asyncio from notify_bridge_server.commands.handler import _log_command_event app = _bootstrap_app() with TestClient(app): async def run() -> None: bot = await _seed_user_and_bot("BrokenBot") await _log_command_event( bot=bot, chat_id="9", cmd="albums", args="", locale="ru", event_type="command_failed", responses=[], ctx_tuples=[], extra_details={"error": "RuntimeError: boom"}, ) rows = await _read_events("command_failed", bot.id) assert len(rows) == 1 assert rows[0].details["error"] == "RuntimeError: boom" assert rows[0].details["locale"] == "ru" asyncio.run(run()) def test_log_command_event_handles_db_error_gracefully(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001 """A logging failure must NOT raise — the user still gets their reply.""" import asyncio from notify_bridge_server.commands import handler as handler_mod from notify_bridge_server.commands.base import CommandResponse app = _bootstrap_app() with TestClient(app): async def run() -> None: bot = await _seed_user_and_bot("StillRepliesBot") def boom() -> object: raise RuntimeError("db gone") monkeypatch.setattr(handler_mod, "get_engine", boom) # Must not raise. await handler_mod._log_command_event( bot=bot, chat_id="1", cmd="help", args="", locale="en", event_type="command_handled", responses=[CommandResponse(text="hi")], ctx_tuples=[], ) asyncio.run(run())