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