Files
moneyfyandClaude Opus 5 b586d27b77 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
2026-09-09 13:40:37 +02:00

305 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
)