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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user