feat(api): Core-API mit Authentifizierung, CRUD und Monatsreport

- Anmeldung über Argon2id und JWT in httpOnly-Cookies, Refresh mit echter
  Rotation über die neue Tabelle refresh_token
- AuthProvider-Protokoll als Vorbereitung für OIDC, Administrator-Anlage beim
  Erststart mit erzwungenem Passwortwechsel
- CRUD für Konten, Kategorien (zweistufiger Baum), Firmen, Recurrences,
  Preisversionen, Buchungen, Budgets, Vorlagen und Sparziele
- Fälligkeiten mit Overlay-Logik: abrufen, bestätigen, auslassen, zurücksetzen
- Kontosalden zum Stichtag, Monatsübersicht mit Plan-Ist-Vergleich
- SECRET_KEY jetzt mindestens 32 Zeichen; Platzhalter in Produktion abgelehnt
- 61 neue Integrationstests, insgesamt 148 grün

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:40:37 +02:00
co-authored by Claude Opus 5
parent 70d73cf8d3
commit b586d27b77
46 changed files with 4866 additions and 21 deletions
+304
View File
@@ -0,0 +1,304 @@
"""Integrationstests der CRUD-Endpunkte."""
from httpx import AsyncClient
# --- Konten --------------------------------------------------------------------
async def test_konto_lebenszyklus(auth_client: AsyncClient) -> None:
angelegt = await auth_client.post(
"/api/accounts",
json={
"name": "Girokonto",
"type": "checking",
"iban_last4": "4711",
"opening_balance": "1500.00",
"opening_balance_date": "2026-01-01",
},
)
assert angelegt.status_code == 201
konto = angelegt.json()
assert konto["opening_balance"] == "1500.00"
geaendert = await auth_client.patch(
f"/api/accounts/{konto['id']}", json={"name": "Gehaltskonto", "sort_order": 3}
)
assert geaendert.status_code == 200
assert geaendert.json()["name"] == "Gehaltskonto"
assert geaendert.json()["iban_last4"] == "4711" # unverändert
liste = (await auth_client.get("/api/accounts")).json()
assert [item["name"] for item in liste] == ["Gehaltskonto"]
geloescht = await auth_client.delete(f"/api/accounts/{konto['id']}")
assert geloescht.status_code == 200
assert (await auth_client.get(f"/api/accounts/{konto['id']}")).status_code == 404
async def test_doppelter_kontoname_wird_abgewiesen(auth_client: AsyncClient) -> None:
payload = {"name": "Girokonto", "opening_balance_date": "2026-01-01"}
assert (await auth_client.post("/api/accounts", json=payload)).status_code == 201
zweites = await auth_client.post("/api/accounts", json=payload)
assert zweites.status_code == 409
assert zweites.json()["code"] == "conflict"
async def test_unbekanntes_feld_wird_abgewiesen(auth_client: AsyncClient) -> None:
antwort = await auth_client.post(
"/api/accounts",
json={"name": "X", "opening_balance_date": "2026-01-01", "tippfehler": 1},
)
assert antwort.status_code == 422
assert antwort.json()["code"] == "validation_error"
async def test_ungueltige_farbe_wird_abgewiesen(auth_client: AsyncClient) -> None:
antwort = await auth_client.post(
"/api/accounts",
json={"name": "X", "opening_balance_date": "2026-01-01", "color": "blau"},
)
assert antwort.status_code == 422
# --- Kategorien ----------------------------------------------------------------
async def test_kategoriebaum_ist_zweistufig(auth_client: AsyncClient, seeded: dict) -> None:
baum = (await auth_client.get("/api/categories")).json()
wohnen = next(item for item in baum if item["name"] == "Wohnen")
assert wohnen["parent_id"] is None
assert {kind["name"] for kind in wohnen["children"]} == {
"Miete",
"Nebenkosten",
"Strom",
"Internet",
"Rundfunkbeitrag",
}
assert next(k for k in wohnen["children"] if k["name"] == "Miete")["is_fixed_cost"] is True
async def test_dritte_ebene_wird_verweigert(auth_client: AsyncClient, seeded: dict) -> None:
antwort = await auth_client.post(
"/api/categories",
json={"name": "Netflix", "kind": "expense", "parent_id": seeded["streaming"]},
)
assert antwort.status_code == 422
assert antwort.json()["code"] == "category_too_deep"
async def test_unterkategorie_erbt_die_richtung(auth_client: AsyncClient, seeded: dict) -> None:
baum = (await auth_client.get("/api/categories")).json()
wohnen = next(item for item in baum if item["name"] == "Wohnen")
# Absichtlich die falsche Richtung mitschicken der Elternknoten gewinnt.
antwort = await auth_client.post(
"/api/categories",
json={"name": "Gartenpflege", "kind": "income", "parent_id": wohnen["id"]},
)
assert antwort.status_code == 201
assert antwort.json()["kind"] == "expense"
async def test_verwendete_kategorie_kann_nicht_geloescht_werden(
auth_client: AsyncClient, seeded: dict
) -> None:
await auth_client.post(
"/api/recurrences",
json={
"kind": "expense",
"title": "Miete",
"category_id": seeded["miete"],
"account_id": seeded["account_id"],
"amount": "950.00",
"rrule": "FREQ=MONTHLY;BYMONTHDAY=1",
"dtstart": "2026-01-01",
},
)
antwort = await auth_client.delete(f"/api/categories/{seeded['miete']}")
assert antwort.status_code == 409
assert antwort.json()["code"] == "category_in_use"
# --- Firmen --------------------------------------------------------------------
async def test_firma_wird_normalisiert(auth_client: AsyncClient) -> None:
antwort = await auth_client.post(
"/api/merchants", json={"name": "Netflix International B.V.", "domain": "netflix.com"}
)
assert antwort.status_code == 201
firma = antwort.json()
assert firma["normalized_name"] == "netflixinternational"
assert firma["logo_status"] == "pending"
assert firma["logo_asset_id"] is None
async def test_firmensuche(auth_client: AsyncClient) -> None:
for name in ["Netflix", "Spotify", "Deutsche Telekom AG"]:
await auth_client.post("/api/merchants", json={"name": name})
treffer = (await auth_client.get("/api/merchants", params={"q": "telekom"})).json()
assert treffer["total"] == 1
assert treffer["items"][0]["name"] == "Deutsche Telekom AG"
alle = (await auth_client.get("/api/merchants")).json()
assert alle["total"] == 3
async def test_doppelte_firma_wird_abgewiesen(auth_client: AsyncClient) -> None:
await auth_client.post("/api/merchants", json={"name": "Netflix"})
# Andere Schreibweise, gleicher normalisierter Name.
antwort = await auth_client.post("/api/merchants", json={"name": "NETFLIX"})
assert antwort.status_code == 409
async def test_firma_loeschen_loest_verweise(auth_client: AsyncClient, seeded: dict) -> None:
firma = (await auth_client.post("/api/merchants", json={"name": "Rewe"})).json()
buchung = (
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Wocheneinkauf",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"merchant_id": firma["id"],
"amount": "84.30",
"booking_date": "2026-03-05",
},
)
).json()
assert (await auth_client.delete(f"/api/merchants/{firma['id']}")).status_code == 200
danach = (await auth_client.get(f"/api/transactions/{buchung['id']}")).json()
assert danach["merchant_id"] is None
# --- Buchungen -----------------------------------------------------------------
async def test_buchung_mit_falscher_kategorierichtung(
auth_client: AsyncClient, seeded: dict
) -> None:
antwort = await auth_client.post(
"/api/transactions",
json={
"kind": "income",
"title": "Falsch einsortiert",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": "10.00",
"booking_date": "2026-03-01",
},
)
assert antwort.status_code == 422
assert antwort.json()["code"] == "category_kind_mismatch"
async def test_buchungen_filtern_und_blaettern(auth_client: AsyncClient, seeded: dict) -> None:
for tag in range(1, 6):
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": f"Einkauf {tag}",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": "20.00",
"booking_date": f"2026-03-0{tag}",
},
)
seite = (await auth_client.get("/api/transactions", params={"limit": 2, "offset": 0})).json()
assert seite["total"] == 5
assert len(seite["items"]) == 2
# Neueste zuerst.
assert seite["items"][0]["booking_date"] == "2026-03-05"
zeitraum = (
await auth_client.get(
"/api/transactions", params={"from": "2026-03-02", "to": "2026-03-03"}
)
).json()
assert zeitraum["total"] == 2
suche = (await auth_client.get("/api/transactions", params={"q": "Einkauf 4"})).json()
assert suche["total"] == 1
# --- Budgets und Sparziele -----------------------------------------------------
async def test_budget_wird_auf_den_monatsersten_normalisiert(
auth_client: AsyncClient, seeded: dict
) -> None:
antwort = await auth_client.post(
"/api/budgets",
json={
"category_id": seeded["lebensmittel"],
"period_month": "2026-03-17",
"limit_amount": "450.00",
},
)
assert antwort.status_code == 201
assert antwort.json()["period_month"] == "2026-03-01"
async def test_budget_pro_kategorie_und_monat_nur_einmal(
auth_client: AsyncClient, seeded: dict
) -> None:
payload = {
"category_id": seeded["lebensmittel"],
"period_month": "2026-03-01",
"limit_amount": "450.00",
}
await auth_client.post("/api/budgets", json=payload)
zweites = await auth_client.post("/api/budgets", json=payload)
assert zweites.status_code == 409
async def test_budgetvorlage(auth_client: AsyncClient, seeded: dict) -> None:
antwort = await auth_client.post(
"/api/budget-templates",
json={
"category_id": seeded["lebensmittel"],
"valid_from": "2026-01-15",
"limit_amount": "500.00",
},
)
assert antwort.status_code == 201
assert antwort.json()["valid_from"] == "2026-01-01"
assert antwort.json()["valid_until"] is None
async def test_sparziel_lebenszyklus(auth_client: AsyncClient) -> None:
angelegt = await auth_client.post(
"/api/savings-goals",
json={"name": "Neues Fahrrad", "target_amount": "1800.00", "target_date": "2027-04-01"},
)
assert angelegt.status_code == 201
ziel = angelegt.json()
assert ziel["current_amount"] == "0.00"
geaendert = await auth_client.patch(
f"/api/savings-goals/{ziel['id']}", json={"current_amount": "450.00"}
)
assert geaendert.json()["current_amount"] == "450.00"
await auth_client.patch(f"/api/savings-goals/{ziel['id']}", json={"is_archived": True})
assert (await auth_client.get("/api/savings-goals")).json() == []
assert (
len((await auth_client.get("/api/savings-goals", params={"include_archived": True})).json())
== 1
)