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:
2026-10-15 18:45:16 +03:00
co-authored by Claude Opus 5.5
parent b70797c805
commit 282920e06b
24 changed files with 1071 additions and 16 deletions
+3 -2
View File
@@ -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]:
+48
View File
@@ -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"
+18
View File
@@ -1,8 +1,12 @@
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from sqlalchemy.ext.asyncio import AsyncEngine
from app.config import AccessSettings, RuntimeSettings, Settings, TelegramSettings
from app.db.migrate import upgrade
from app.db.session import SessionMaker, make_engine, make_sessionmaker
from app.texts import Texts, load_texts
ROOT = Path(__file__).resolve().parent.parent
@@ -22,3 +26,17 @@ def settings(tmp_path: Path) -> Settings:
access=AccessSettings(admin_ids=frozenset({ADMIN})),
runtime=RuntimeSettings(config_dir=ROOT / "config", heartbeat_file=tmp_path / "hb"),
)
@pytest.fixture
async def engine() -> AsyncIterator[AsyncEngine]:
"""Fresh in-memory SQLite with all migrations applied — the same path the bot takes at startup."""
eng = make_engine("sqlite+aiosqlite://")
await upgrade(eng)
yield eng
await eng.dispose()
@pytest.fixture
def sessions(engine: AsyncEngine) -> SessionMaker:
return make_sessionmaker(engine)
View File
+30
View File
@@ -0,0 +1,30 @@
from datetime import timedelta
from sqlalchemy import func, select
from app.db.maintenance import cleanup_expired
from app.db.models import AccessAttempt, ContainerSnapshot, Draft, User, utcnow
from app.db.session import SessionMaker
async def test_cleanup_removes_only_expired(sessions: SessionMaker) -> None:
now = utcnow()
async with sessions.begin() as s:
s.add_all([User(tg_id=1, full_name="a"), User(tg_id=2, full_name="b")])
await s.flush() # no relationship() to order inserts: users first for the FK
s.add_all(
[
Draft(user_id=1, base_sha="a" * 40, expires_at=now - timedelta(minutes=1)),
Draft(user_id=2, base_sha="b" * 40, expires_at=now + timedelta(hours=1)),
ContainerSnapshot(container="c", at=now - timedelta(days=2), restart_count=0, cpu_pct=0, mem_pct=0),
ContainerSnapshot(container="c", at=now, restart_count=0, cpu_pct=0, mem_pct=0),
AccessAttempt(tg_id=9, at=now - timedelta(days=2), ok=False),
]
)
removed = await cleanup_expired(sessions, now)
assert removed == {"drafts": 1, "snapshots": 1, "access_attempts": 1}
async with sessions() as s:
assert await s.scalar(select(func.count()).select_from(Draft)) == 1
assert await s.scalar(select(func.count()).select_from(ContainerSnapshot)) == 1
+82
View File
@@ -0,0 +1,82 @@
from datetime import timedelta
import pytest
from alembic.autogenerate import compare_metadata
from alembic.migration import MigrationContext
from sqlalchemy import inspect, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncEngine
from app.db.migrate import downgrade, upgrade
from app.db.models import Base, ChangeOp, Draft, DraftChange, User, utcnow
from app.db.session import SessionMaker, make_engine
TABLES = {
"users",
"invites",
"access_attempts",
"fsm_state",
"drafts",
"draft_changes",
"pushes",
"pipeline_runs",
"container_snapshots",
"alerts",
"alert_deliveries",
"log_queries",
"agent_requests",
}
async def table_names(engine: AsyncEngine) -> set[str]:
async with engine.connect() as conn:
return set(await conn.run_sync(lambda c: inspect(c).get_table_names())) - {"alembic_version"}
async def test_upgrade_creates_all_tables(engine: AsyncEngine) -> None:
assert await table_names(engine) == TABLES
async def test_models_match_migrations(engine: AsyncEngine) -> None:
# A model change without a migration shows up here as a non-empty diff.
async with engine.connect() as conn:
diff = await conn.run_sync(lambda c: compare_metadata(MigrationContext.configure(c), Base.metadata))
assert diff == []
async def test_downgrade_to_base_and_back() -> None:
eng = make_engine("sqlite+aiosqlite://")
try:
await upgrade(eng)
await downgrade(eng, "base")
assert await table_names(eng) == set()
await upgrade(eng)
assert await table_names(eng) == TABLES
finally:
await eng.dispose()
INSERT_USER = text(
"INSERT INTO users (tg_id, full_name, role, is_active, joined_at) VALUES (:id, 'u', :role, 1, '2026-01-01')"
)
async def test_enum_check_rejects_garbage(engine: AsyncEngine) -> None:
async with engine.begin() as conn:
await conn.execute(INSERT_USER, {"id": 1, "role": "admin"})
with pytest.raises(IntegrityError):
async with engine.begin() as conn:
await conn.execute(INSERT_USER, {"id": 2, "role": "root"})
async def test_deleting_draft_cascades_to_changes(sessions: SessionMaker) -> None:
async with sessions.begin() as s:
s.add(User(tg_id=1, full_name="a"))
await s.flush()
draft = Draft(user_id=1, base_sha="0" * 40, expires_at=utcnow() + timedelta(hours=1))
draft.changes.append(DraftChange(path="services/a/main.py", op=ChangeOp.ADD, content=b"print(1)"))
s.add(draft)
async with sessions.begin() as s:
await s.execute(text("DELETE FROM drafts"))
async with sessions() as s:
assert (await s.execute(text("SELECT count(*) FROM draft_changes"))).scalar_one() == 0
+3 -2
View File
@@ -23,8 +23,9 @@ def test_age_decides(tmp_path: Path) -> None:
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()
async with asyncio.timeout(2):
while not hb.exists(): # noqa: ASYNC110 - polling the filesystem; touch runs in a worker thread
await asyncio.sleep(0.01)
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task