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:
@@ -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]
|
||||
@@ -0,0 +1,24 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import AccessSettings, RuntimeSettings, Settings, TelegramSettings
|
||||
from app.texts import Texts, load_texts
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ADMIN = 1001
|
||||
OUTSIDER = 2002
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def texts() -> Texts:
|
||||
return load_texts(ROOT / "config")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path: Path) -> Settings:
|
||||
return Settings(
|
||||
telegram=TelegramSettings(bot_token="42:TEST-TOKEN"),
|
||||
access=AccessSettings(admin_ids=frozenset({ADMIN})),
|
||||
runtime=RuntimeSettings(config_dir=ROOT / "config", heartbeat_file=tmp_path / "hb"),
|
||||
)
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
"""Offline Telegram: a fake aiogram session that records outgoing API calls and fabricates replies."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime
|
||||
from itertools import count
|
||||
from typing import Any
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.session.base import BaseSession
|
||||
from aiogram.methods import EditMessageText, SendMessage, TelegramMethod
|
||||
from aiogram.methods.base import TelegramType
|
||||
from aiogram.types import Chat, Message, Update, User
|
||||
|
||||
BOT_TOKEN = "42:TEST-TOKEN"
|
||||
|
||||
|
||||
class FakeSession(BaseSession):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.requests: list[TelegramMethod[Any]] = []
|
||||
self._ids = count(1000)
|
||||
|
||||
async def make_request(
|
||||
self,
|
||||
bot: Bot,
|
||||
method: TelegramMethod[TelegramType],
|
||||
timeout: int | None = None, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
|
||||
) -> TelegramType:
|
||||
self.requests.append(method)
|
||||
if isinstance(method, SendMessage | EditMessageText):
|
||||
chat_id = method.chat_id if isinstance(method.chat_id, int) else 0
|
||||
return Message( # type: ignore[return-value]
|
||||
message_id=next(self._ids),
|
||||
date=datetime.now(UTC),
|
||||
chat=Chat(id=chat_id, type="private"),
|
||||
text=method.text,
|
||||
)
|
||||
return True # type: ignore[return-value]
|
||||
|
||||
async def stream_content(
|
||||
self,
|
||||
url: str,
|
||||
headers: dict[str, Any] | None = None,
|
||||
timeout: int = 30, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
|
||||
chunk_size: int = 65536,
|
||||
raise_for_status: bool = True,
|
||||
) -> AsyncGenerator[bytes, None]: # pragma: no cover - not used
|
||||
raise NotImplementedError
|
||||
yield b""
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
def sent(self, method_type: type[TelegramMethod[Any]]) -> list[Any]:
|
||||
return [r for r in self.requests if isinstance(r, method_type)]
|
||||
|
||||
|
||||
class TgClient:
|
||||
"""Feeds updates through a real Dispatcher (filters, middlewares, FSM) on top of FakeSession."""
|
||||
|
||||
def __init__(self, dp: Dispatcher) -> None:
|
||||
self.dp = dp
|
||||
self.session = FakeSession()
|
||||
self.bot = Bot(BOT_TOKEN, session=self.session)
|
||||
self._update_ids = count(1)
|
||||
self._message_ids = count(1)
|
||||
|
||||
@staticmethod
|
||||
def user(tg_id: int) -> dict[str, Any]:
|
||||
return {"id": tg_id, "is_bot": False, "first_name": f"u{tg_id}", "username": f"u{tg_id}"}
|
||||
|
||||
async def send(self, tg_id: int, text: str) -> None:
|
||||
message = {
|
||||
"message_id": next(self._message_ids),
|
||||
"date": int(datetime.now(UTC).timestamp()),
|
||||
"chat": {"id": tg_id, "type": "private"},
|
||||
"from": self.user(tg_id),
|
||||
"text": text,
|
||||
}
|
||||
if text.startswith("/"):
|
||||
cmd = text.split()[0]
|
||||
message["entities"] = [{"type": "bot_command", "offset": 0, "length": len(cmd)}]
|
||||
await self._feed({"message": message})
|
||||
|
||||
async def press(self, tg_id: int, data: str, message_text: str = "section") -> None:
|
||||
callback = {
|
||||
"id": str(next(self._update_ids)),
|
||||
"from": self.user(tg_id),
|
||||
"chat_instance": "ci",
|
||||
"data": data,
|
||||
"message": {
|
||||
"message_id": next(self._message_ids),
|
||||
"date": int(datetime.now(UTC).timestamp()),
|
||||
"chat": {"id": tg_id, "type": "private"},
|
||||
"from": {"id": 42, "is_bot": True, "first_name": "tgit"},
|
||||
"text": message_text,
|
||||
},
|
||||
}
|
||||
await self._feed({"callback_query": callback})
|
||||
|
||||
async def _feed(self, payload: dict[str, Any]) -> None:
|
||||
update = Update.model_validate({"update_id": next(self._update_ids), **payload}, context={"bot": self.bot})
|
||||
await self.dp.feed_update(self.bot, update)
|
||||
|
||||
|
||||
def as_user(tg_id: int) -> User:
|
||||
return User(id=tg_id, is_bot=False, first_name=f"u{tg_id}")
|
||||
@@ -0,0 +1,23 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.config import AccessSettings
|
||||
from app.texts import load_texts
|
||||
|
||||
|
||||
def test_admin_ids_parsed_from_comma_separated_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ADMIN_IDS", "1, 2,3,")
|
||||
assert AccessSettings(_env_file=None).admin_ids == frozenset({1, 2, 3}) # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_admin_ids_default_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("ADMIN_IDS", raising=False)
|
||||
assert AccessSettings(_env_file=None).admin_ids == frozenset() # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_texts_with_missing_key_fail_fast(tmp_path: Path) -> None:
|
||||
(tmp_path / "texts.yaml").write_text("menu: {commit: x}\n", encoding="utf-8")
|
||||
with pytest.raises(ValidationError):
|
||||
load_texts(tmp_path)
|
||||
@@ -0,0 +1,41 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import health
|
||||
|
||||
|
||||
def test_missing_file_is_dead(tmp_path: Path) -> None:
|
||||
assert not health.is_alive(tmp_path / "hb", max_age=30)
|
||||
|
||||
|
||||
def test_age_decides(tmp_path: Path) -> None:
|
||||
hb = tmp_path / "hb"
|
||||
hb.touch()
|
||||
os.utime(hb, (1000, 1000))
|
||||
assert health.is_alive(hb, max_age=30, now=1029)
|
||||
assert not health.is_alive(hb, max_age=30, now=1031)
|
||||
|
||||
|
||||
async def test_heartbeat_touches_file(tmp_path: Path) -> None:
|
||||
hb = tmp_path / "hb"
|
||||
task = asyncio.create_task(health.heartbeat(hb, interval=10))
|
||||
await asyncio.sleep(0)
|
||||
assert hb.exists()
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("touch", "code"), [(True, 0), (False, 1)])
|
||||
def test_cli_exit_code(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, touch: bool, code: int) -> None:
|
||||
hb = tmp_path / "hb"
|
||||
if touch:
|
||||
hb.touch()
|
||||
monkeypatch.setenv("HEARTBEAT_FILE", str(hb))
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
health.main()
|
||||
assert exc.value.code == code
|
||||
@@ -0,0 +1,12 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.logs_setup import JsonFormatter
|
||||
|
||||
|
||||
def test_json_formatter_one_object_per_line() -> None:
|
||||
record = logging.LogRecord("tgit", logging.INFO, __file__, 1, "hello %s", ("мир",), None)
|
||||
entry = json.loads(JsonFormatter().format(record))
|
||||
assert entry["msg"] == "hello мир"
|
||||
assert entry["level"] == "INFO"
|
||||
assert entry["logger"] == "tgit"
|
||||
@@ -1,13 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
from app.main import run
|
||||
|
||||
|
||||
async def test_run_returns_when_stop_is_set() -> None:
|
||||
stop = asyncio.Event()
|
||||
task = asyncio.create_task(run(stop))
|
||||
await asyncio.sleep(0)
|
||||
assert not task.done()
|
||||
|
||||
stop.set()
|
||||
await asyncio.wait_for(task, timeout=1)
|
||||
Reference in New Issue
Block a user