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