diff --git a/.env.example b/.env.example index 744b6f8..dbc60ed 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ # --- Telegram (#4, #25) --- TELEGRAM_BOT_TOKEN= # (secret) токен от @BotFather ADMIN_IDS= # Telegram ID первых администраторов через запятую +LOG_LEVEL=INFO # --- База данных --- POSTGRES_PASSWORD=tgit # (secret на сервере) пароль Postgres в Compose diff --git a/Dockerfile b/Dockerfile index ce1e03c..012be5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,4 +20,6 @@ COPY --from=build /opt/venv /opt/venv WORKDIR /srv/tgit COPY config ./config USER tgit +# Liveness: the bot touches a heartbeat file while its event loop runs (app/health.py) +HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 CMD ["python", "-m", "app.health"] CMD ["python", "-m", "app"] diff --git a/app/bot/__init__.py b/app/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/fsm.py b/app/bot/fsm.py new file mode 100644 index 0000000..4633dfc --- /dev/null +++ b/app/bot/fsm.py @@ -0,0 +1,8 @@ +"""FSM storage factory. In-memory for now; Postgres-backed storage that survives restarts arrives with the DB (#5).""" + +from aiogram.fsm.storage.base import BaseStorage +from aiogram.fsm.storage.memory import MemoryStorage + + +def make_storage() -> BaseStorage: + return MemoryStorage() diff --git a/app/bot/menu.py b/app/bot/menu.py new file mode 100644 index 0000000..c13f9b0 --- /dev/null +++ b/app/bot/menu.py @@ -0,0 +1,58 @@ +"""Main menu (reply keyboard) and in-section navigation (inline keyboard), SPEC §4.0.""" + +from enum import StrEnum + +from aiogram.filters.callback_data import CallbackData +from aiogram.types import InlineKeyboardMarkup, ReplyKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder + +from app.texts import Texts + + +class Section(StrEnum): + COMMIT = "commit" + STATUS = "status" + LOGS = "logs" + ALERTS = "alerts" + MEMBERS = "members" + + +class NavAction(StrEnum): + BACK = "back" + CANCEL = "cancel" + + +class Nav(CallbackData, prefix="nav"): + # Telegram limits callback_data to 64 bytes: only short ids here, never paths (ARCHITECTURE §2.1). + action: NavAction + + +def section_by_button(texts: Texts) -> dict[str, Section]: + m = texts.menu + return { + m.commit: Section.COMMIT, + m.status: Section.STATUS, + m.logs: Section.LOGS, + m.alerts: Section.ALERTS, + m.members: Section.MEMBERS, + } + + +def main_menu(texts: Texts, *, is_admin: bool) -> ReplyKeyboardMarkup: + m = texts.menu + kb = ReplyKeyboardBuilder() + kb.button(text=m.commit) + kb.button(text=m.status) + kb.button(text=m.logs) + kb.button(text=m.alerts) + if is_admin: + kb.button(text=m.members) + kb.adjust(2, 2, 1) + return kb.as_markup(resize_keyboard=True, is_persistent=True, input_field_placeholder=m.placeholder) + + +def section_nav(texts: Texts) -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + kb.button(text=texts.menu.back, callback_data=Nav(action=NavAction.BACK)) + kb.button(text=texts.menu.cancel, callback_data=Nav(action=NavAction.CANCEL)) + return kb.as_markup() diff --git a/app/bot/middlewares/__init__.py b/app/bot/middlewares/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/middlewares/access.py b/app/bot/middlewares/access.py new file mode 100644 index 0000000..64ecfbb --- /dev/null +++ b/app/bot/middlewares/access.py @@ -0,0 +1,51 @@ +"""Access gate on every update — messages and button presses alike (SPEC A-5). + +Until invites land (#25) the only members are the bootstrap admins from `ADMIN_IDS`. +""" + +import time +from collections.abc import Awaitable, Callable +from typing import Any + +from aiogram import BaseMiddleware +from aiogram.types import CallbackQuery, Message, TelegramObject + +from app.texts import Texts + +IsMember = Callable[[int], Awaitable[bool]] + +NO_ACCESS_REPLY_EVERY = 24 * 3600.0 + + +class AccessMiddleware(BaseMiddleware): + def __init__(self, is_member: IsMember, texts: Texts, clock: Callable[[], float] = time.monotonic) -> None: + self._is_member = is_member + self._texts = texts + self._clock = clock + # tg_id -> when we last told this outsider "invite only"; answering every message would let them spam us. + self._last_refusal: dict[int, float] = {} + + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], + ) -> Any: + user = data.get("event_from_user") + if user is not None and await self._is_member(user.id): + return await handler(event, data) + if user is not None: + await self._refuse(event, user.id) + return None + + async def _refuse(self, event: TelegramObject, tg_id: int) -> None: + if isinstance(event, CallbackQuery): + await event.answer() # stop the spinner on a stale button, say nothing + return + now = self._clock() + last = self._last_refusal.get(tg_id) + if last is not None and now - last < NO_ACCESS_REPLY_EVERY: + return + self._last_refusal[tg_id] = now + if isinstance(event, Message): + await event.answer(self._texts.start.no_access) diff --git a/app/bot/routers/__init__.py b/app/bot/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/bot/routers/common.py b/app/bot/routers/common.py new file mode 100644 index 0000000..9ce708c --- /dev/null +++ b/app/bot/routers/common.py @@ -0,0 +1,57 @@ +"""/start, /help, /menu, main menu buttons and in-section navigation.""" + +from aiogram import F, Router +from aiogram.filters import Command, CommandStart +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message + +from app.bot.menu import Nav, NavAction, Section, main_menu, section_by_button, section_nav +from app.texts import Texts + + +def build_router(texts: Texts, admin_ids: frozenset[int]) -> Router: + router = Router(name="common") + sections = section_by_button(texts) + section_text = { + Section.COMMIT: texts.section.commit, + Section.STATUS: texts.section.status, + Section.LOGS: texts.section.logs, + Section.ALERTS: texts.section.alerts, + Section.MEMBERS: texts.section.members, + } + + def is_admin(message: Message) -> bool: + return message.from_user is not None and message.from_user.id in admin_ids + + @router.message(CommandStart()) + async def start(message: Message, state: FSMContext) -> None: + await state.clear() + await message.answer(texts.start.welcome, reply_markup=main_menu(texts, is_admin=is_admin(message))) + + @router.message(Command("menu")) + async def menu(message: Message, state: FSMContext) -> None: + await state.clear() + await message.answer(texts.menu.placeholder, reply_markup=main_menu(texts, is_admin=is_admin(message))) + + @router.message(Command("help")) + async def help_(message: Message) -> None: + await message.answer(texts.help.text) + + @router.message(F.text.in_(sections)) + async def open_section(message: Message, state: FSMContext) -> None: + section = sections[message.text or ""] + if section is Section.MEMBERS and not is_admin(message): + return + await state.clear() + await message.answer(section_text[section], reply_markup=section_nav(texts)) + + @router.callback_query(Nav.filter()) + async def navigate(query: CallbackQuery, callback_data: Nav, state: FSMContext) -> None: + # At a section's root "Back" and "Cancel" both close it; nested screens will override "Back" per section. + await state.clear() + text = texts.section.cancelled if callback_data.action is NavAction.CANCEL else texts.menu.placeholder + if isinstance(query.message, Message): + await query.message.edit_text(text) + await query.answer() + + return router diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..64ed71b --- /dev/null +++ b/app/config.py @@ -0,0 +1,49 @@ +"""Typed configuration from the environment / `.env` — one settings class per subsystem (ARCHITECTURE §2.11).""" + +from pathlib import Path +from typing import Annotated + +from pydantic import Field, SecretStr, field_validator +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict + +_ENV = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + +class TelegramSettings(BaseSettings): + model_config = SettingsConfigDict(**_ENV, env_prefix="TELEGRAM_") + + bot_token: SecretStr + + +class AccessSettings(BaseSettings): + model_config = _ENV + + # Bootstrap admins (SPEC A-8). Comma-separated in the env: ADMIN_IDS=1,2 + admin_ids: Annotated[frozenset[int], NoDecode] = frozenset() + + @field_validator("admin_ids", mode="before") + @classmethod + def _split_ids(cls, value: object) -> object: + if isinstance(value, str): + return frozenset(int(part) for part in value.replace(" ", "").split(",") if part) + return value + + +class RuntimeSettings(BaseSettings): + model_config = _ENV + + log_level: str = "INFO" + # Non-secret configs (texts, thresholds, prompts). Relative to the working dir: /srv/tgit in Docker. + config_dir: Path = Path("config") + # The heartbeat file is touched while the event loop is alive; the Docker healthcheck reads its age. + heartbeat_file: Path = Path("/tmp/tgit-heartbeat") + heartbeat_interval: float = Field(default=10.0, gt=0) + heartbeat_max_age: float = Field(default=30.0, gt=0) + + +class Settings(BaseSettings): + model_config = _ENV + + telegram: TelegramSettings = Field(default_factory=TelegramSettings) + access: AccessSettings = Field(default_factory=AccessSettings) + runtime: RuntimeSettings = Field(default_factory=RuntimeSettings) diff --git a/app/health.py b/app/health.py new file mode 100644 index 0000000..e9ee0a8 --- /dev/null +++ b/app/health.py @@ -0,0 +1,35 @@ +"""Liveness: a background task touches a file while the event loop runs; the Docker healthcheck checks its age. + +A stuck loop stops touching the file, so the container turns unhealthy even though the process is still up. +`python -m app.health` exits 0 when healthy, 1 otherwise. +""" + +import asyncio +import sys +import time +from pathlib import Path + +from app.config import RuntimeSettings + + +async def heartbeat(path: Path, interval: float) -> None: + while True: + await asyncio.to_thread(path.touch) + await asyncio.sleep(interval) + + +def is_alive(path: Path, max_age: float, now: float | None = None) -> bool: + try: + age = (time.time() if now is None else now) - path.stat().st_mtime + except FileNotFoundError: + return False + return age <= max_age + + +def main() -> None: + rt = RuntimeSettings() + sys.exit(0 if is_alive(rt.heartbeat_file, rt.heartbeat_max_age) else 1) + + +if __name__ == "__main__": + main() diff --git a/app/logs_setup.py b/app/logs_setup.py new file mode 100644 index 0000000..24cd0b2 --- /dev/null +++ b/app/logs_setup.py @@ -0,0 +1,24 @@ +"""JSON logs to stdout, one object per line. Never log message contents or secrets (SPEC §5.3).""" + +import json +import logging +from datetime import UTC, datetime + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + entry = { + "ts": datetime.fromtimestamp(record.created, UTC).isoformat(timespec="milliseconds"), + "level": record.levelname, + "logger": record.name, + "msg": record.getMessage(), + } + if record.exc_info: + entry["exc"] = self.formatException(record.exc_info) + return json.dumps(entry, ensure_ascii=False) + + +def setup_logging(level: str) -> None: + handler = logging.StreamHandler() + handler.setFormatter(JsonFormatter()) + logging.basicConfig(level=level.upper(), handlers=[handler], force=True) diff --git a/app/main.py b/app/main.py index d1defc2..4199ad0 100644 --- a/app/main.py +++ b/app/main.py @@ -1,28 +1,56 @@ -"""Entry point. For now a stub that idles until stopped; the bot itself lands in #4.""" +"""Entry point: settings → texts → dispatcher → long polling (ADR-002).""" import asyncio +import contextlib import logging -import signal + +from aiogram import Bot, Dispatcher from app import __version__ +from app.bot.fsm import make_storage +from app.bot.middlewares.access import AccessMiddleware +from app.bot.routers.common import build_router +from app.config import Settings +from app.health import heartbeat +from app.logs_setup import setup_logging +from app.texts import Texts, load_texts log = logging.getLogger("tgit") -async def run(stop: asyncio.Event) -> None: - log.info("tgit %s started", __version__) - await stop.wait() - log.info("tgit stopped") +def build_dispatcher(settings: Settings, texts: Texts) -> Dispatcher: + admin_ids = settings.access.admin_ids + + async def is_member(tg_id: int) -> bool: + return tg_id in admin_ids + + dp = Dispatcher(storage=make_storage()) + access = AccessMiddleware(is_member, texts) + # Outer middleware: runs before filters, so nothing — not even a stale button — reaches a handler unchecked. + dp.message.outer_middleware(access) + dp.callback_query.outer_middleware(access) + dp.include_router(build_router(texts, admin_ids)) + return dp + + +async def run(settings: Settings) -> None: + texts = load_texts(settings.runtime.config_dir) + dp = build_dispatcher(settings, texts) + rt = settings.runtime + beat = asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)) + bot = Bot(settings.telegram.bot_token.get_secret_value()) + log.info("tgit %s starting, %d bootstrap admin(s)", __version__, len(settings.access.admin_ids)) + try: + await dp.start_polling(bot, handle_signals=True) + finally: + beat.cancel() + with contextlib.suppress(asyncio.CancelledError): + await beat + await bot.session.close() + log.info("tgit stopped") def main() -> None: - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") - - async def _main() -> None: - stop = asyncio.Event() - loop = asyncio.get_running_loop() - for sig in (signal.SIGINT, signal.SIGTERM): - loop.add_signal_handler(sig, stop.set) - await run(stop) - - asyncio.run(_main()) + settings = Settings() + setup_logging(settings.runtime.log_level) + asyncio.run(run(settings)) diff --git a/app/texts.py b/app/texts.py new file mode 100644 index 0000000..5308771 --- /dev/null +++ b/app/texts.py @@ -0,0 +1,51 @@ +"""UI texts from `config/texts.yaml`, validated at startup so a missing key fails fast instead of mid-dialog.""" + +from pathlib import Path + +import yaml +from pydantic import BaseModel, ConfigDict + + +class _Strict(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class MenuTexts(_Strict): + commit: str + status: str + logs: str + alerts: str + members: str + back: str + cancel: str + placeholder: str + + +class StartTexts(_Strict): + welcome: str + no_access: str + + +class HelpTexts(_Strict): + text: str + + +class SectionTexts(_Strict): + commit: str + status: str + logs: str + alerts: str + members: str + cancelled: str + + +class Texts(_Strict): + menu: MenuTexts + start: StartTexts + help: HelpTexts + section: SectionTexts + + +def load_texts(config_dir: Path) -> Texts: + with (config_dir / "texts.yaml").open(encoding="utf-8") as f: + return Texts.model_validate(yaml.safe_load(f)) diff --git a/config/texts.yaml b/config/texts.yaml new file mode 100644 index 0000000..becd9e2 --- /dev/null +++ b/config/texts.yaml @@ -0,0 +1,36 @@ +# Тексты интерфейса бота. Ключи читает app/texts.py; отсутствующий ключ — ошибка при старте. + +menu: + commit: "📝 Коммит" + status: "🩺 Статус" + logs: "📜 Логи" + alerts: "🚨 Алерты" + members: "👥 Участники" + back: "⬅️ Назад" + cancel: "✖️ Отмена" + placeholder: "Выберите раздел" + +start: + welcome: |- + Привет! Я tgit — git в телеграме. + + 📝 Коммит — изменить файлы и запушить + 🩺 Статус — контейнеры и их health + 📜 Логи — хвост, свой grep или вопрос агенту + 🚨 Алерты — падения, перегрузка, список + no_access: "Доступ к боту — только по приглашению. Попросите QR-код у администратора." + +help: + text: |- + /menu — главное меню из любого места + /help — эта справка + + Разделы открываются кнопками внизу. В любом шаге есть «⬅️ Назад» и «✖️ Отмена». + +section: + commit: "📝 Коммит\n\nРаздел в разработке." + status: "🩺 Статус\n\nРаздел в разработке." + logs: "📜 Логи\n\nРаздел в разработке." + alerts: "🚨 Алерты\n\nРаздел в разработке." + members: "👥 Участники\n\nРаздел в разработке." + cancelled: "Отменено. Главное меню — кнопками внизу." diff --git a/tests/bot/__init__.py b/tests/bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/bot/test_common.py b/tests/bot/test_common.py new file mode 100644 index 0000000..cfc510f --- /dev/null +++ b/tests/bot/test_common.py @@ -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] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b743f75 --- /dev/null +++ b/tests/conftest.py @@ -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"), + ) diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..272e7ef --- /dev/null +++ b/tests/fakes.py @@ -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}") diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..a711c81 --- /dev/null +++ b/tests/test_config.py @@ -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) diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..287400f --- /dev/null +++ b/tests/test_health.py @@ -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 diff --git a/tests/test_logs_setup.py b/tests/test_logs_setup.py new file mode 100644 index 0000000..0644b42 --- /dev/null +++ b/tests/test_logs_setup.py @@ -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" diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index 2bfdee8..0000000 --- a/tests/test_main.py +++ /dev/null @@ -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)