feat(bot): модель данных, миграции Alembic, FSM в БД, TTL-чистка
- app/db/models.py: 13 таблиц (ARCHITECTURE §2.10); перечисления — VARCHAR + CHECK, JSON → JSONB на Postgres, BIGINT/INTEGER-ключи для SQLite - миграция 0001 (автогенерация + ручная чистка: импорт sa.Text, дубли CHECK) - миграции применяются при старте бота программно (app.db.migrate), script_location из пакета - SqlStorage: FSM aiogram поверх fsm_state — состояние переживает рестарт (перенесено из #4) - cleanup_loop: истёкшие черновики, снимки контейнеров и попытки входа старше суток - тесты: upgrade/downgrade, дрейф моделей и миграций, CHECK, CASCADE, FSM, чистка Refs #5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ from aiogram.types import InlineKeyboardMarkup, ReplyKeyboardMarkup
|
||||
|
||||
from app.bot.menu import Nav, NavAction
|
||||
from app.config import Settings
|
||||
from app.db.session import SessionMaker
|
||||
from app.main import build_dispatcher
|
||||
from app.texts import Texts
|
||||
from tests.conftest import ADMIN, OUTSIDER
|
||||
@@ -11,8 +12,8 @@ from tests.fakes import TgClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tg(settings: Settings, texts: Texts) -> TgClient:
|
||||
return TgClient(build_dispatcher(settings, texts))
|
||||
def tg(settings: Settings, texts: Texts, sessions: SessionMaker) -> TgClient:
|
||||
return TgClient(build_dispatcher(settings, texts, sessions))
|
||||
|
||||
|
||||
def buttons(markup: ReplyKeyboardMarkup) -> list[str]:
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.fsm.storage.base import StorageKey
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.bot.fsm_storage import SqlStorage, key_to_str
|
||||
from app.db.models import FsmState
|
||||
from app.db.session import SessionMaker
|
||||
|
||||
KEY = StorageKey(bot_id=42, chat_id=7, user_id=7)
|
||||
|
||||
|
||||
class Flow(StatesGroup):
|
||||
name = State()
|
||||
|
||||
|
||||
async def test_state_and_data_survive_a_new_storage(sessions: SessionMaker) -> None:
|
||||
first = SqlStorage(sessions)
|
||||
await first.set_state(KEY, Flow.name)
|
||||
await first.set_data(KEY, {"path": "services/a", "page": 2})
|
||||
|
||||
# A fresh storage on the same DB = the bot after a restart
|
||||
second = SqlStorage(sessions)
|
||||
assert await second.get_state(KEY) == Flow.name.state
|
||||
assert await second.get_data(KEY) == {"path": "services/a", "page": 2}
|
||||
|
||||
|
||||
async def test_unknown_key_is_empty(sessions: SessionMaker) -> None:
|
||||
storage = SqlStorage(sessions)
|
||||
assert await storage.get_state(KEY) is None
|
||||
assert await storage.get_data(KEY) == {}
|
||||
|
||||
|
||||
async def test_cleared_state_removes_row(sessions: SessionMaker) -> None:
|
||||
storage = SqlStorage(sessions)
|
||||
await storage.set_state(KEY, "x")
|
||||
await storage.set_data(KEY, {"a": 1})
|
||||
await storage.set_state(KEY, None)
|
||||
await storage.set_data(KEY, {})
|
||||
await storage.close()
|
||||
|
||||
async with sessions() as s:
|
||||
assert await s.scalar(select(func.count()).select_from(FsmState)) == 0
|
||||
|
||||
|
||||
def test_key_includes_every_part() -> None:
|
||||
key = StorageKey(bot_id=1, chat_id=2, user_id=3, thread_id=4, business_connection_id="b", destiny="d")
|
||||
assert key_to_str(key) == "1:2:3:4:b:d"
|
||||
assert key_to_str(KEY) == "42:7:7:::default"
|
||||
Reference in New Issue
Block a user