feat(bot): точка входа, конфиг, главное меню, доступ для ADMIN_IDS, healthcheck

- 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>
This commit is contained in:
2026-10-13 23:53:52 +03:00
co-authored by Claude Opus 5.5
parent bbd4ac5bc1
commit 0ce5285da1
23 changed files with 698 additions and 29 deletions
View File
+75
View File
@@ -0,0 +1,75 @@
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]