35a3008896
Bot commands were the only user-initiated path that didn't surface in the dashboard. They now produce ``command_handled`` / ``command_rate_limited`` / ``command_failed`` rows in ``EventLog`` alongside tracker and action events. Backend - ``EventLog`` gains nullable ``command_tracker_id`` / ``telegram_bot_id`` FKs plus deletion-snapshot name columns (idempotent migration). - New ``_log_command_event`` helper emits one row per invocation at the three branches in ``handle_command``. Logging failures are swallowed so they cannot block the user-visible reply. - Telegram ``from`` is captured in poller and webhook, whitelisted to identity fields by ``_normalize_issuer`` (drops ``language_code`` and any future PII), persisted under ``details.issuer``. - ``/api/status`` resolves live ``CommandTracker`` / ``TelegramBot`` names (mirroring the action pattern) and exposes ``tracker_id``, ``command_tracker_id``, ``telegram_bot_id`` so the frontend can deep-link. Frontend - Event rows are now clickable and open a detail modal with full provenance (bot → chat → issuer → provider), raw ``details`` JSON, and per-entity action buttons. - Buttons use the existing ``requestHighlight`` + ``goto`` crosslink pattern, so clicking lands on the entity's list page with that specific card scrolled into view and pulsing. - Auto-refresh dropdown (Off / 10s / 30s / 1m / 5m) persisted in ``localStorage``; ticker pauses while the tab is hidden. - Event-type filter, dashboard verb labels, and gradients extended for the three new ``command_*`` types. - Filled in pre-existing missing i18n keys (``common.hide`` / ``common.show`` / ``commandConfig.noCommandsForProvider``). Tests - New ``test_command_event_logging.py`` covers subject formatting, issuer normalization, the three event branches, and graceful failure when the DB is unreachable. ``pytest packages/server/tests/`` → 96/96.
228 lines
7.9 KiB
Python
228 lines
7.9 KiB
Python
"""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())
|