- config: pydantic-settings по подсистемам, ADMIN_IDS через запятую - texts.yaml + валидация при старте (лишний/пропущенный ключ — ошибка сразу) - меню: reply-клавиатура разделов (Участники — только админам), инлайн «Назад»/«Отмена» с редактированием сообщения на месте, /start, /menu, /help - AccessMiddleware (outer) на сообщениях и кнопках: до #25 пускает только ADMIN_IDS, постороннему — одна фраза в сутки, устаревшие кнопки молча гасятся - heartbeat-файл + HEALTHCHECK в Dockerfile; JSON-логи - FSM пока в памяти (фабрика), хранилище в БД — в #5 - тесты на фейковой сессии aiogram через настоящий Dispatcher Refs #4 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
76 lines
2.8 KiB
Python
76 lines
2.8 KiB
Python
import pytest
|
|
from aiogram.methods import AnswerCallbackQuery, EditMessageText, SendMessage
|
|
from aiogram.types import InlineKeyboardMarkup, ReplyKeyboardMarkup
|
|
|
|
from app.bot.menu import Nav, NavAction
|
|
from app.config import Settings
|
|
from app.main import build_dispatcher
|
|
from app.texts import Texts
|
|
from tests.conftest import ADMIN, OUTSIDER
|
|
from tests.fakes import TgClient
|
|
|
|
|
|
@pytest.fixture
|
|
def tg(settings: Settings, texts: Texts) -> TgClient:
|
|
return TgClient(build_dispatcher(settings, texts))
|
|
|
|
|
|
def buttons(markup: ReplyKeyboardMarkup) -> list[str]:
|
|
return [b.text for row in markup.keyboard for b in row]
|
|
|
|
|
|
async def test_admin_start_shows_welcome_and_full_menu(tg: TgClient, texts: Texts) -> None:
|
|
await tg.send(ADMIN, "/start")
|
|
|
|
[reply] = tg.session.sent(SendMessage)
|
|
assert reply.text == texts.start.welcome
|
|
assert isinstance(reply.reply_markup, ReplyKeyboardMarkup)
|
|
m = texts.menu
|
|
assert buttons(reply.reply_markup) == [m.commit, m.status, m.logs, m.alerts, m.members]
|
|
|
|
|
|
async def test_outsider_gets_one_refusal_then_silence(tg: TgClient, texts: Texts) -> None:
|
|
await tg.send(OUTSIDER, "/start")
|
|
await tg.send(OUTSIDER, "hello")
|
|
await tg.send(OUTSIDER, texts.menu.commit)
|
|
|
|
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.no_access]
|
|
|
|
|
|
async def test_outsider_stale_button_is_swallowed(tg: TgClient) -> None:
|
|
await tg.press(OUTSIDER, Nav(action=NavAction.BACK).pack())
|
|
|
|
assert tg.session.sent(EditMessageText) == []
|
|
assert len(tg.session.sent(AnswerCallbackQuery)) == 1
|
|
|
|
|
|
@pytest.mark.parametrize("section", ["commit", "status", "logs", "alerts", "members"])
|
|
async def test_menu_button_opens_section_with_nav(tg: TgClient, texts: Texts, section: str) -> None:
|
|
await tg.send(ADMIN, getattr(texts.menu, section))
|
|
|
|
[reply] = tg.session.sent(SendMessage)
|
|
assert reply.text == getattr(texts.section, section)
|
|
assert isinstance(reply.reply_markup, InlineKeyboardMarkup)
|
|
labels = [b.text for row in reply.reply_markup.inline_keyboard for b in row]
|
|
assert labels == [texts.menu.back, texts.menu.cancel]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("action", "expected"),
|
|
[(NavAction.BACK, "placeholder"), (NavAction.CANCEL, "cancelled")],
|
|
)
|
|
async def test_nav_edits_section_message_in_place(tg: TgClient, texts: Texts, action: NavAction, expected: str) -> None:
|
|
await tg.press(ADMIN, Nav(action=action).pack())
|
|
|
|
[edit] = tg.session.sent(EditMessageText)
|
|
want = texts.menu.placeholder if expected == "placeholder" else texts.section.cancelled
|
|
assert edit.text == want
|
|
assert tg.session.sent(SendMessage) == []
|
|
|
|
|
|
async def test_menu_and_help_commands(tg: TgClient, texts: Texts) -> None:
|
|
await tg.send(ADMIN, "/menu")
|
|
await tg.send(ADMIN, "/help")
|
|
|
|
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.menu.placeholder, texts.help.text]
|