feat(bot): точка входа, конфиг, главное меню, healthcheck #29

Merged
Eleboba merged 1 commits from feat/4-entrypoint-menu into dev 2026-10-14 16:56:08 +03:00
23 changed files with 698 additions and 29 deletions
Showing only changes of commit 0ce5285da1 - Show all commits
+1
View File
@@ -4,6 +4,7 @@
# --- Telegram (#4, #25) --- # --- Telegram (#4, #25) ---
TELEGRAM_BOT_TOKEN= # (secret) токен от @BotFather TELEGRAM_BOT_TOKEN= # (secret) токен от @BotFather
ADMIN_IDS= # Telegram ID первых администраторов через запятую ADMIN_IDS= # Telegram ID первых администраторов через запятую
LOG_LEVEL=INFO
# --- База данных --- # --- База данных ---
POSTGRES_PASSWORD=tgit # (secret на сервере) пароль Postgres в Compose POSTGRES_PASSWORD=tgit # (secret на сервере) пароль Postgres в Compose
+2
View File
@@ -20,4 +20,6 @@ COPY --from=build /opt/venv /opt/venv
WORKDIR /srv/tgit WORKDIR /srv/tgit
COPY config ./config COPY config ./config
USER tgit 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"] CMD ["python", "-m", "app"]
View File
+8
View File
@@ -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()
+58
View File
@@ -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()
View File
+51
View File
@@ -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)
View File
+57
View File
@@ -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
+49
View File
@@ -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)
+35
View File
@@ -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()
+24
View File
@@ -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)
+44 -16
View File
@@ -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 asyncio
import contextlib
import logging import logging
import signal
from aiogram import Bot, Dispatcher
from app import __version__ 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") log = logging.getLogger("tgit")
async def run(stop: asyncio.Event) -> None: def build_dispatcher(settings: Settings, texts: Texts) -> Dispatcher:
log.info("tgit %s started", __version__) admin_ids = settings.access.admin_ids
await stop.wait()
log.info("tgit stopped") 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: def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") settings = Settings()
setup_logging(settings.runtime.log_level)
async def _main() -> None: asyncio.run(run(settings))
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())
+51
View File
@@ -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))
+36
View File
@@ -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: "Отменено. Главное меню — кнопками внизу."
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]
+24
View File
@@ -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
View File
@@ -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}")
+23
View File
@@ -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)
+41
View File
@@ -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
+12
View File
@@ -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"
-13
View File
@@ -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)