Обёртка в Docker
This commit is contained in:
parent
10b48c77a7
commit
d7dca63911
|
|
@ -0,0 +1,31 @@
|
|||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# venv / кеши
|
||||
.venv
|
||||
.venv11
|
||||
venv
|
||||
env
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*.egg-info
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.mypy_cache
|
||||
|
||||
# IDE / локальное
|
||||
.idea
|
||||
.vscode
|
||||
AGENTS.md
|
||||
checklist.md
|
||||
*.log
|
||||
|
||||
# инфраструктура и документация (в образ не нужны)
|
||||
deploy
|
||||
.gitea
|
||||
tests
|
||||
docs
|
||||
*.md
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
# Без под-фильтров: срабатывает на любой push (ветки и теги). Комбинация
|
||||
# branches+tags в Forgejo не поддерживается и помечает workflow как проблемный.
|
||||
push:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.mpakosrv.ru
|
||||
IMAGE_NAME: mpakobecex/dispelevilbot-bot
|
||||
|
||||
jobs:
|
||||
lint-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Установка зависимостей
|
||||
run: pip install -r requirements-dev.txt
|
||||
# В Forgejo 1.21 `continue-on-error` игнорируется, поэтому неблокирующий
|
||||
# режим для black сделан через shell. ruff обязателен.
|
||||
- name: Ruff
|
||||
run: ruff check .
|
||||
- name: Black
|
||||
run: black --check . || echo "::warning::black нашёл отличия в форматировании (пока не блокирует)"
|
||||
- name: Pytest
|
||||
run: pytest -q
|
||||
|
||||
build-push:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-test
|
||||
# Собираем и пушим образ. Автодеплой НЕ делаем — выкатка вручную (test -> prod).
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Логин в Gitea Registry
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
- name: Сборка и публикация образа
|
||||
run: |
|
||||
set -e
|
||||
IMAGE="$REGISTRY/$IMAGE_NAME"
|
||||
SHA="${GITHUB_SHA:-unknown}"
|
||||
SHORT_SHA="$(echo "$SHA" | cut -c1-12)"
|
||||
TAGS="$IMAGE:$SHORT_SHA $IMAGE:latest"
|
||||
case "${GITHUB_REF:-}" in
|
||||
refs/tags/*) TAGS="$TAGS $IMAGE:${GITHUB_REF_NAME:-${GITHUB_REF#refs/tags/}}" ;;
|
||||
esac
|
||||
ARGS=""
|
||||
for t in $TAGS; do ARGS="$ARGS -t $t"; done
|
||||
docker build $ARGS .
|
||||
for t in $TAGS; do docker push "$t"; done
|
||||
|
|
@ -15,6 +15,8 @@ venv/
|
|||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
env/*.env
|
||||
!env/*.env.example
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
# Развёртывание
|
||||
|
||||
Клонируем репозиторий и поднимаем контур Compose-ом. Контуры `test` и `prod`
|
||||
разделяются именем проекта (`-p`) и файлом окружения (`--env-file`) — у каждого
|
||||
свои БД и volume'ы, на одной машине не пересекаются.
|
||||
|
||||
## 1. Клонировать
|
||||
|
||||
```bash
|
||||
git clone ssh://git@<gitea-host>:2222/mpakobecex/DispelEvilBot.git
|
||||
cd DispelEvilBot
|
||||
```
|
||||
|
||||
## 2. Подготовить окружение
|
||||
|
||||
```bash
|
||||
cp env/test.env.example env/test.env
|
||||
# заполнить: BOT_TOKEN, DB_PASSWORD, тестовые чаты (TEST_CHAT_ID/PROD_CHAT_ID), ADMIN_IDS
|
||||
```
|
||||
|
||||
## 3. Логин в registry (если пакет приватный)
|
||||
|
||||
```bash
|
||||
docker login git.mpakosrv.ru -u mpakobecex -p <ТОКЕН с read:package>
|
||||
```
|
||||
|
||||
Если пакет сделать публичным — логин не нужен.
|
||||
|
||||
## 4. Запуск
|
||||
|
||||
```bash
|
||||
# тестовый контур
|
||||
docker compose -p test --env-file env/test.env -f docker-compose.prod.yml up -d
|
||||
|
||||
# прод (когда понадобится)
|
||||
cp env/prod.env.example env/prod.env
|
||||
docker compose -p prod --env-file env/prod.env -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
Логи:
|
||||
```bash
|
||||
docker compose -p test --env-file env/test.env -f docker-compose.prod.yml logs -f
|
||||
```
|
||||
|
||||
## Что происходит при старте
|
||||
|
||||
1. `postgres` поднимается и проходит healthcheck;
|
||||
2. `migrate` (**Flyway**) накатывает миграции из `sql/` — для пустой БД с нуля;
|
||||
3. `bot` стартует (`Запуск DispelEvilBot v<версия> (APP_ENV=...)`).
|
||||
|
||||
## Обновление и откат
|
||||
|
||||
```bash
|
||||
# обновление: поменять IMAGE_TAG в env/<контур>.env, затем
|
||||
docker compose -p <контур> --env-file env/<контур>.env -f docker-compose.prod.yml pull
|
||||
docker compose -p <контур> --env-file env/<контур>.env -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
Откат — вернуть предыдущий `IMAGE_TAG` (например, commit-sha) и повторить.
|
||||
|
||||
## Контуры
|
||||
|
||||
| Контур | env-файл | БД | APP_ENV | Бот |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| test | `env/test.env` | `dispel_evil_test` | `dev` | тестовый |
|
||||
| prod | `env/prod.env` | `dispel_evil` | `prod` | боевой |
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
См. `env/prod.env.example` / `env/test.env.example`.
|
||||
|
||||
## Перенос данных на прод (позже, короткое окно простоя)
|
||||
|
||||
1. Остановить старого бота.
|
||||
2. `pg_dump -Fc -f dump.pgc <старая_бд>`.
|
||||
3. Поднять прод-контур (миграции создадут чистую схему).
|
||||
4. Загрузить данные: `pg_restore --data-only --disable-triggers -d dispel_evil dump.pgc`.
|
||||
5. Сверить: число пользователей, `SUM(money.amount)`, число сообщений.
|
||||
6. Переключить бота, проверить команды.
|
||||
|
||||
Бэкап старой БД обязателен. Дубли в `tg_users`/`de_users`/`unsubscribe` при загрузке
|
||||
нужно устранить до создания PK (миграция `V12` делает дедуп, но при загрузке поверх
|
||||
миграций дедуп — вручную).
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Непривилегированный пользователь
|
||||
RUN useradd --create-home --uid 1000 app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY common ./common
|
||||
COPY bot ./bot
|
||||
COPY images ./images
|
||||
|
||||
USER app
|
||||
|
||||
# Живость процесса: бот обновляет файл-heartbeat (см. bot/services/heartbeat.py).
|
||||
# Путь должен совпадать с HEARTBEAT_PATH (по умолчанию /tmp/dispelevilbot.heartbeat).
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||
CMD python -c "import os,sys,time; p='/tmp/dispelevilbot.heartbeat'; sys.exit(0 if os.path.exists(p) and time.time()-os.path.getmtime(p) < 120 else 1)"
|
||||
|
||||
CMD ["python", "-m", "bot"]
|
||||
26
README.md
26
README.md
|
|
@ -10,6 +10,11 @@ common/ # общий пакет: config, logging, db, repositories, models,
|
|||
bot/ # сервис бота: handlers, services, keyboards, notifications
|
||||
sql/ # миграции Flyway (V__*.sql, R__*.sql)
|
||||
images/ # статичные картинки (приветствие)
|
||||
deploy/ # материалы деплоя (Caddyfile, инструкции)
|
||||
tests/ # тесты
|
||||
Dockerfile # образ бота
|
||||
docker-compose.yml / docker-compose.prod.yml
|
||||
.gitea/ # CI (Gitea Actions)
|
||||
```
|
||||
|
||||
## Требования
|
||||
|
|
@ -37,6 +42,25 @@ APP_ENV=prod python -m bot # prod
|
|||
## База данных
|
||||
|
||||
Схема управляется миграциями **Flyway** в `sql/` (`V__*.sql` — versioned, `R__*.sql` — repeatable).
|
||||
При Docker-развёртывании накат выполняется автоматически сервисом `migrate` перед ботом.
|
||||
|
||||
## Docker
|
||||
|
||||
Локальный запуск (postgres + migrate + bot):
|
||||
|
||||
```bash
|
||||
cp .env.example .env # заполнить
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом:
|
||||
|
||||
```bash
|
||||
docker compose -p prod --env-file env/prod.env -f docker-compose.prod.yml up -d
|
||||
docker compose -p test --env-file env/test.env -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
Пошаговое развёртывание контуров `test`/`prod` — см. [`DEPLOY.md`](DEPLOY.md).
|
||||
|
||||
## Разработка
|
||||
|
||||
|
|
@ -49,7 +73,7 @@ pytest
|
|||
|
||||
## Версионирование
|
||||
|
||||
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.1`.
|
||||
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.2`.
|
||||
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import asyncio
|
|||
import logging
|
||||
|
||||
from bot.bot_factory import create_bot, create_dispatcher
|
||||
from bot.services.heartbeat import run_heartbeat
|
||||
from bot.services.notifications import run_notifications
|
||||
from common import __version__
|
||||
from common.config import get_settings
|
||||
|
|
@ -26,12 +27,14 @@ async def main() -> None:
|
|||
dp = create_dispatcher()
|
||||
|
||||
notifications_task = asyncio.create_task(run_notifications(bot))
|
||||
heartbeat_task = asyncio.create_task(run_heartbeat())
|
||||
try:
|
||||
await bot.delete_webhook(drop_pending_updates=True)
|
||||
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
|
||||
finally:
|
||||
notifications_task.cancel()
|
||||
await asyncio.gather(notifications_task, return_exceptions=True)
|
||||
heartbeat_task.cancel()
|
||||
await asyncio.gather(notifications_task, heartbeat_task, return_exceptions=True)
|
||||
await close_pool()
|
||||
await bot.session.close()
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ from aiogram import Router
|
|||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
|
||||
from bot.services import access, economy as economy_service
|
||||
from bot.services import access
|
||||
from bot.services import economy as economy_service
|
||||
from common.config import get_settings
|
||||
from common.texts import economy, system
|
||||
from common.texts.render import render
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ from aiogram.filters import Command
|
|||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from bot.keyboards import get_keyboard_donat, get_keyboard_main, get_keyboard_title
|
||||
from bot.services import access, shop as shop_service
|
||||
from common.texts import shop as shop_texts, system
|
||||
from bot.services import access
|
||||
from bot.services import shop as shop_service
|
||||
from common.texts import shop as shop_texts
|
||||
from common.texts import system
|
||||
from common.texts.render import render
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
"""Heartbeat для Docker healthcheck.
|
||||
|
||||
Бот периодически обновляет mtime файла; healthcheck считает контейнер живым,
|
||||
если файл свежий. Если event loop завис — heartbeat перестаёт обновляться.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from common.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HEARTBEAT_INTERVAL = 30
|
||||
|
||||
|
||||
async def run_heartbeat() -> None:
|
||||
"""Обновлять файл-heartbeat каждые ``HEARTBEAT_INTERVAL`` секунд."""
|
||||
path = Path(get_settings().heartbeat_path)
|
||||
while True:
|
||||
try:
|
||||
path.touch()
|
||||
except OSError:
|
||||
logger.exception("Не удалось обновить heartbeat-файл %s", path)
|
||||
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
||||
|
|
@ -45,6 +45,9 @@ class Settings(BaseSettings):
|
|||
|
||||
timezone: str = "Europe/Moscow"
|
||||
|
||||
#: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически).
|
||||
heartbeat_path: str = "/tmp/dispelevilbot.heartbeat"
|
||||
|
||||
@field_validator("admin_ids", mode="before")
|
||||
@classmethod
|
||||
def _parse_admin_ids(cls, value: object) -> object:
|
||||
|
|
|
|||
|
|
@ -7,4 +7,4 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.1"
|
||||
__version__ = "0.2"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
# Прод/test-контур: образы тянутся из Gitea Registry.
|
||||
#
|
||||
# Контуры разделяются именем compose-проекта (-p) и файлом окружения (ENV_FILE):
|
||||
# ENV_FILE=env/prod.env docker compose -p prod -f docker-compose.prod.yml up -d
|
||||
# ENV_FILE=env/test.env docker compose -p test -f docker-compose.prod.yml up -d
|
||||
#
|
||||
# Имя проекта задаёт отдельные volume'ы (pgdata) — у prod и test свои БД.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-dispel_evil}
|
||||
POSTGRES_USER: ${DB_USER:-de_admin}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?задайте DB_PASSWORD}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-de_admin} -d ${DB_NAME:-dispel_evil}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
migrate:
|
||||
image: flyway/flyway:10
|
||||
command: ["migrate"]
|
||||
environment:
|
||||
FLYWAY_URL: jdbc:postgresql://postgres:5432/${DB_NAME:-dispel_evil}
|
||||
FLYWAY_USER: ${DB_USER:-de_admin}
|
||||
FLYWAY_PASSWORD: ${DB_PASSWORD:?задайте DB_PASSWORD}
|
||||
FLYWAY_LOCATIONS: filesystem:/flyway/sql
|
||||
FLYWAY_BASELINE_ON_MIGRATE: "true"
|
||||
FLYWAY_CONNECT_RETRIES: "10"
|
||||
volumes:
|
||||
- ./sql:/flyway/sql:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
bot:
|
||||
image: ${REGISTRY:-git.mpakosrv.ru}/mpakobecex/dispelevilbot-bot:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ${ENV_FILE:-env/prod.env}
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# --- Reverse proxy и TLS (Блок 2.4) ---
|
||||
# Включается, когда появится админка/Mini App (Блоки 4–5).
|
||||
# Раскомментировать и положить deploy/Caddyfile:
|
||||
#
|
||||
# caddy:
|
||||
# image: caddy:2
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "80:80"
|
||||
# - "443:443"
|
||||
# volumes:
|
||||
# - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
# - caddy_data:/data
|
||||
# - caddy_config:/config
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
# caddy_data:
|
||||
# caddy_config:
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
# Базовый compose для локального запуска и разработки.
|
||||
# Секреты берутся из .env (см. .env.example).
|
||||
#
|
||||
# docker compose up --build
|
||||
#
|
||||
name: dispelevilbot
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-dispel_evil}
|
||||
POSTGRES_USER: ${DB_USER:-de_admin}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?задайте DB_PASSWORD в .env}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-de_admin} -d ${DB_NAME:-dispel_evil}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
# ports:
|
||||
# - "5432:5432" # раскомментировать для доступа к БД с хоста
|
||||
|
||||
# Накат миграций Flyway перед стартом бота (идемпотентно).
|
||||
migrate:
|
||||
image: flyway/flyway:10
|
||||
command: ["migrate"]
|
||||
environment:
|
||||
FLYWAY_URL: jdbc:postgresql://postgres:5432/${DB_NAME:-dispel_evil}
|
||||
FLYWAY_USER: ${DB_USER:-de_admin}
|
||||
FLYWAY_PASSWORD: ${DB_PASSWORD:?задайте DB_PASSWORD в .env}
|
||||
FLYWAY_LOCATIONS: filesystem:/flyway/sql
|
||||
FLYWAY_BASELINE_ON_MIGRATE: "true"
|
||||
FLYWAY_CONNECT_RETRIES: "10"
|
||||
volumes:
|
||||
- ./sql:/flyway/sql:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
bot:
|
||||
build: .
|
||||
image: dispelevilbot-bot:local
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Внутри сети compose БД доступна по имени сервиса.
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Прод-контур. Скопируйте в env/prod.env и заполните реальными значениями.
|
||||
# env/*.env в git не попадают.
|
||||
#
|
||||
# Используется и для интерполяции, и как env_file сервиса:
|
||||
# docker compose -p prod --env-file env/prod.env -f docker-compose.prod.yml up -d
|
||||
ENV_FILE=env/prod.env
|
||||
|
||||
APP_ENV=prod
|
||||
|
||||
# Образ в реестре
|
||||
REGISTRY=git.mpakosrv.ru
|
||||
IMAGE_TAG=v0.2
|
||||
|
||||
BOT_TOKEN=
|
||||
|
||||
# Postgres (для контейнера бота host = имя сервиса compose)
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=dispel_evil
|
||||
DB_USER=de_admin
|
||||
DB_PASSWORD=
|
||||
DB_POOL_MIN=1
|
||||
DB_POOL_MAX=10
|
||||
DB_STATEMENT_TIMEOUT_MS=15000
|
||||
|
||||
PROD_CHAT_ID=
|
||||
TEST_CHAT_ID=
|
||||
ALERT_CHAT_ID=
|
||||
ADMIN_IDS=
|
||||
TIMEZONE=Europe/Moscow
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# Тестовый контур. Скопируйте в env/test.env и заполните.
|
||||
# Отдельные токен бота, чаты и БД от прода.
|
||||
#
|
||||
# docker compose -p test --env-file env/test.env -f docker-compose.prod.yml up -d
|
||||
ENV_FILE=env/test.env
|
||||
|
||||
APP_ENV=dev
|
||||
|
||||
# Образ в реестре
|
||||
REGISTRY=git.mpakosrv.ru
|
||||
IMAGE_TAG=v0.2
|
||||
|
||||
BOT_TOKEN=
|
||||
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=dispel_evil_test
|
||||
DB_USER=de_admin
|
||||
DB_PASSWORD=
|
||||
DB_POOL_MIN=1
|
||||
DB_POOL_MAX=10
|
||||
DB_STATEMENT_TIMEOUT_MS=15000
|
||||
|
||||
PROD_CHAT_ID=
|
||||
TEST_CHAT_ID=
|
||||
ALERT_CHAT_ID=
|
||||
ADMIN_IDS=
|
||||
TIMEZONE=Europe/Moscow
|
||||
|
|
@ -13,3 +13,5 @@ target-version = ["py312"]
|
|||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
# Чтобы pytest видел пакеты из корня репозитория (common, bot) при запуске из CI.
|
||||
pythonpath = ["."]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
"""Тесты рендера шаблонов (без БД и сети)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from common.texts.render import (
|
||||
TemplateError,
|
||||
format_tops,
|
||||
format_user_list,
|
||||
format_usernames,
|
||||
render,
|
||||
validate,
|
||||
)
|
||||
|
||||
|
||||
def test_render_substitutes_and_escapes():
|
||||
template = "Привет, {username}!"
|
||||
result = render(template, {"username": "<b>bad</b>"})
|
||||
assert result == "Привет, <b>bad</b>!"
|
||||
|
||||
|
||||
def test_render_empty_value():
|
||||
assert render("Ник: {nick}", {"nick": None}) == "Ник: "
|
||||
|
||||
|
||||
def test_render_unknown_tag_raises():
|
||||
with pytest.raises(TemplateError):
|
||||
render("Значение {unknown}", {})
|
||||
|
||||
|
||||
def test_render_keeps_literal_braces_untouched():
|
||||
# Одиночная фигурная скобка не считается тегом.
|
||||
assert render("текст { не тег", {}) == "текст { не тег"
|
||||
|
||||
|
||||
def test_validate_reports_unknown_tags():
|
||||
assert validate("Норм {username}, плохо {nope} {bad2}") == ["bad2", "nope"]
|
||||
|
||||
|
||||
def test_format_usernames_escapes_and_joins():
|
||||
rows = [{"username": "a"}, {"username": None}, {"username": "b&c"}]
|
||||
assert format_usernames(rows) == "@a @b&c"
|
||||
|
||||
|
||||
def test_format_user_list_numbering():
|
||||
rows = [{"username": "u1", "nick": "Nick1"}, {"username": None, "nick": None}]
|
||||
assert format_user_list(rows) == "1: @u1 - Nick1\n2: — - "
|
||||
|
||||
|
||||
def test_format_tops():
|
||||
rows = [{"nick": "N1", "count": 3}, {"nick": None, "count": 1}]
|
||||
assert format_tops(rows) == "1) N1 - 3\n2) — - 1"
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
"""Проверка версии проекта."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from common import __version__
|
||||
|
||||
|
||||
def test_version_is_set():
|
||||
assert __version__
|
||||
assert __version__ == "0.2"
|
||||
Loading…
Reference in New Issue