feat: Projektfundament mit Datenmodell, Migrationen und Seed
- Repo-Struktur für Backend, Frontend, Dokumentation und Gitea-Workflows - FastAPI-Skeleton mit pydantic-settings und RFC-7807-artigem Fehlerformat - Vollständiges Datenmodell (15 Tabellen) als SQLAlchemy-2.0-Modelle - Alembic gegen die Async-Engine inklusive Erstmigration - Idempotenter Seed für den deutschen Kategoriebaum und Standardregeln - Endpunkte /api/health und /api/version - pytest-Infrastruktur mit transaktionsisolierten Fixtures Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
"""Gemeinsame Test-Fixtures. Nutzt eine separate Testdatenbank."""
|
||||
|
||||
import os
|
||||
|
||||
# Muss vor dem ersten Import der Anwendung gesetzt sein, da die Settings gecacht werden.
|
||||
os.environ.setdefault(
|
||||
"DATABASE_URL",
|
||||
os.environ.get(
|
||||
"TEST_DATABASE_URL",
|
||||
"postgresql+asyncpg://moneyfy:moneyfy@127.0.0.1:5432/moneyfy_test",
|
||||
),
|
||||
)
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key")
|
||||
os.environ.setdefault("ENVIRONMENT", "test")
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import Base
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def engine():
|
||||
"""Legt das Schema einmal pro Testlauf frisch an."""
|
||||
test_engine = create_async_engine(str(settings.database_url), poolclass=None)
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield test_engine
|
||||
await test_engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session(engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Eine Session je Test, am Ende wird zurückgerollt."""
|
||||
connection = await engine.connect()
|
||||
transaction = await connection.begin()
|
||||
# `create_savepoint` sorgt dafür, dass session.commit() nur den Savepoint freigibt
|
||||
# und der abschließende Rollback wirklich alle Testdaten entfernt.
|
||||
maker = async_sessionmaker(
|
||||
bind=connection,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
join_transaction_mode="create_savepoint",
|
||||
)
|
||||
async with maker() as db_session:
|
||||
yield db_session
|
||||
await transaction.rollback()
|
||||
await connection.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""HTTP-Client gegen die App, alle Requests laufen in der Test-Transaktion."""
|
||||
|
||||
async def _override() -> AsyncGenerator[AsyncSession, None]:
|
||||
yield session
|
||||
|
||||
fastapi_app.dependency_overrides[get_session] = _override
|
||||
transport = ASGITransport(app=fastapi_app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||
yield http_client
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Tests des Stammdaten-Seeds."""
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Category, NotificationRule
|
||||
from app.models.enums import EntryKind
|
||||
from app.services.seed import seed_all
|
||||
|
||||
|
||||
async def test_seed_erzeugt_kategoriebaum(session: AsyncSession) -> None:
|
||||
await seed_all(session)
|
||||
|
||||
total = (await session.execute(select(func.count()).select_from(Category))).scalar_one()
|
||||
assert total == 35
|
||||
|
||||
roots = (
|
||||
(await session.execute(select(Category).where(Category.parent_id.is_(None))))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert {root.name for root in roots} == {
|
||||
"Wohnen",
|
||||
"Versicherungen",
|
||||
"Abos & Medien",
|
||||
"Mobilität",
|
||||
"Lebenshaltung",
|
||||
"Finanzen",
|
||||
"Einkünfte",
|
||||
}
|
||||
|
||||
einkuenfte = next(root for root in roots if root.name == "Einkünfte")
|
||||
assert einkuenfte.kind is EntryKind.INCOME
|
||||
|
||||
miete = (await session.execute(select(Category).where(Category.name == "Miete"))).scalar_one()
|
||||
assert miete.is_fixed_cost is True
|
||||
assert miete.kind is EntryKind.EXPENSE
|
||||
assert miete.parent_id is not None
|
||||
|
||||
|
||||
async def test_seed_ist_idempotent(session: AsyncSession) -> None:
|
||||
first = await seed_all(session)
|
||||
second = await seed_all(session)
|
||||
|
||||
assert first["categories"] == 35
|
||||
assert second["categories"] == 0
|
||||
assert second["notification_rules"] == 0
|
||||
|
||||
total = (await session.execute(select(func.count()).select_from(Category))).scalar_one()
|
||||
assert total == 35
|
||||
|
||||
|
||||
async def test_seed_legt_benachrichtigungsregeln_an(session: AsyncSession) -> None:
|
||||
await seed_all(session)
|
||||
rules = (await session.execute(select(NotificationRule))).scalars().all()
|
||||
assert len(rules) == 4
|
||||
assert all(rule.is_active is False for rule in rules)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Tests der Systemendpunkte und des Fehlerformats."""
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
async def test_health_meldet_datenbank_ok(client: AsyncClient) -> None:
|
||||
response = await client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["database"] == "ok"
|
||||
# Zeitstempel muss in Europe/Berlin geliefert werden (+01:00 oder +02:00).
|
||||
assert body["time"].endswith(("+01:00", "+02:00"))
|
||||
|
||||
|
||||
async def test_version_liefert_stammdaten(client: AsyncClient) -> None:
|
||||
body = (await client.get("/api/version")).json()
|
||||
assert body["name"] == "moneyfy"
|
||||
assert body["timezone"] == "Europe/Berlin"
|
||||
assert body["currency"] == "EUR"
|
||||
|
||||
|
||||
async def test_unbekannte_route_liefert_fehlerformat(client: AsyncClient) -> None:
|
||||
response = await client.get("/api/gibt-es-nicht")
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Not Found", "code": "not_found"}
|
||||
Reference in New Issue
Block a user