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:
moneyfy
2026-09-09 13:12:08 +02:00
co-authored by Claude Opus 5
commit 0b06775be3
40 changed files with 2236 additions and 0 deletions
View File
+78
View File
@@ -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()
+57
View File
@@ -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)
+26
View File
@@ -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"}