Обёртка в Docker
This commit is contained in:
parent
10b48c77a7
commit
4b8982c37e
|
|
@ -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: mpakobecx/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.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
env/*.env
|
||||||
|
!env/*.env.example
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|
|
||||||
|
|
@ -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
|
bot/ # сервис бота: handlers, services, keyboards, notifications
|
||||||
sql/ # миграции Flyway (V__*.sql, R__*.sql)
|
sql/ # миграции Flyway (V__*.sql, R__*.sql)
|
||||||
images/ # статичные картинки (приветствие)
|
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).
|
Схема управляется миграциями **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
|
||||||
|
```
|
||||||
|
|
||||||
|
Registry, перенос данных, TLS и firewall — см. [`deploy/README.md`](deploy/README.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`).
|
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from bot.bot_factory import create_bot, create_dispatcher
|
from bot.bot_factory import create_bot, create_dispatcher
|
||||||
|
from bot.services.heartbeat import run_heartbeat
|
||||||
from bot.services.notifications import run_notifications
|
from bot.services.notifications import run_notifications
|
||||||
from common import __version__
|
from common import __version__
|
||||||
from common.config import get_settings
|
from common.config import get_settings
|
||||||
|
|
@ -26,12 +27,14 @@ async def main() -> None:
|
||||||
dp = create_dispatcher()
|
dp = create_dispatcher()
|
||||||
|
|
||||||
notifications_task = asyncio.create_task(run_notifications(bot))
|
notifications_task = asyncio.create_task(run_notifications(bot))
|
||||||
|
heartbeat_task = asyncio.create_task(run_heartbeat())
|
||||||
try:
|
try:
|
||||||
await bot.delete_webhook(drop_pending_updates=True)
|
await bot.delete_webhook(drop_pending_updates=True)
|
||||||
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
|
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
|
||||||
finally:
|
finally:
|
||||||
notifications_task.cancel()
|
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 close_pool()
|
||||||
await bot.session.close()
|
await bot.session.close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ from aiogram import Router
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
from aiogram.types import Message
|
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.config import get_settings
|
||||||
from common.texts import economy, system
|
from common.texts import economy, system
|
||||||
from common.texts.render import render
|
from common.texts.render import render
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@ from aiogram.filters import Command
|
||||||
from aiogram.types import CallbackQuery, Message
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
|
||||||
from bot.keyboards import get_keyboard_donat, get_keyboard_main, get_keyboard_title
|
from bot.keyboards import get_keyboard_donat, get_keyboard_main, get_keyboard_title
|
||||||
from bot.services import access, shop as shop_service
|
from bot.services import access
|
||||||
from common.texts import shop as shop_texts, system
|
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
|
from common.texts.render import render
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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"
|
timezone: str = "Europe/Moscow"
|
||||||
|
|
||||||
|
#: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически).
|
||||||
|
heartbeat_path: str = "/tmp/dispelevilbot.heartbeat"
|
||||||
|
|
||||||
@field_validator("admin_ids", mode="before")
|
@field_validator("admin_ids", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _parse_admin_ids(cls, value: object) -> object:
|
def _parse_admin_ids(cls, value: object) -> object:
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,4 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
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}/mpakobecx/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,26 @@
|
||||||
|
# Прод-контур. Скопируйте в 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
|
||||||
|
|
||||||
|
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,24 @@
|
||||||
|
# Тестовый контур. Скопируйте в 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
|
||||||
|
|
||||||
|
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]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["tests"]
|
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