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,349 @@
|
||||
"""Integrationstests: Posten anlegen, Fälligkeiten abrufen, bestätigen, Monatsreport."""
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
async def anlegen(client: AsyncClient, seeded: dict, **overrides) -> dict:
|
||||
"""Legt ein Abo an; einzelne Felder lassen sich überschreiben."""
|
||||
payload = {
|
||||
"kind": "expense",
|
||||
"title": "Netflix Standard",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "13.99",
|
||||
"rrule": "FREQ=MONTHLY;BYMONTHDAY=15",
|
||||
"dtstart": "2026-01-15",
|
||||
"business_day_shift": "none",
|
||||
}
|
||||
payload.update(overrides)
|
||||
antwort = await client.post("/api/recurrences", json=payload)
|
||||
assert antwort.status_code == 201, antwort.text
|
||||
return antwort.json()
|
||||
|
||||
|
||||
# --- Der geforderte Durchstich -------------------------------------------------
|
||||
|
||||
|
||||
async def test_durchstich_anlegen_abrufen_bestaetigen_report(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
"""Recurrence anlegen → Fälligkeiten abrufen → mit abweichendem Betrag
|
||||
bestätigen → der Monatsreport weist das Ist aus."""
|
||||
posten = await anlegen(auth_client, seeded)
|
||||
assert posten["amount"] == "13.99"
|
||||
assert len(posten["amount_versions"]) == 1
|
||||
assert posten["annual_burden"] == "167.88" # zwölf Monate à 13,99
|
||||
|
||||
# Fälligkeiten des ersten Quartals abrufen.
|
||||
faelligkeiten = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-01-01", "to": "2026-03-31"})
|
||||
).json()
|
||||
assert [item["nominal_date"] for item in faelligkeiten] == [
|
||||
"2026-01-15",
|
||||
"2026-02-15",
|
||||
"2026-03-15",
|
||||
]
|
||||
assert all(item["status"] == "planned" for item in faelligkeiten)
|
||||
assert all(item["occurrence_id"] is None for item in faelligkeiten)
|
||||
|
||||
# Februar mit abweichendem Betrag und Datum bestätigen.
|
||||
bestaetigt = await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"actual_amount": "15.49",
|
||||
"actual_date": "2026-02-17",
|
||||
"note": "Preis erhöht",
|
||||
},
|
||||
)
|
||||
assert bestaetigt.status_code == 200
|
||||
body = bestaetigt.json()
|
||||
assert body["status"] == "confirmed"
|
||||
assert body["amount"] == "13.99" # Soll bleibt stehen
|
||||
assert body["actual_amount"] == "15.49"
|
||||
assert body["effective_amount"] == "15.49"
|
||||
assert body["effective_date"] == "2026-02-17"
|
||||
assert body["occurrence_id"] is not None
|
||||
|
||||
# Der Monatsreport weist Soll und Ist getrennt aus.
|
||||
report = (await auth_client.get("/api/reports/month", params={"month": "2026-02-01"})).json()
|
||||
assert report["month"] == "2026-02-01"
|
||||
assert report["planned"]["expenses"] == "13.99"
|
||||
assert report["actual"]["expenses"] == "15.49"
|
||||
assert report["confirmed_count"] == 1
|
||||
assert report["open_count"] == 0
|
||||
|
||||
# Im Januar ist nichts bestätigt – dort bleibt das Ist leer.
|
||||
januar = (await auth_client.get("/api/reports/month", params={"month": "2026-01-01"})).json()
|
||||
assert januar["planned"]["expenses"] == "13.99"
|
||||
assert januar["actual"]["expenses"] == "0.00"
|
||||
assert januar["open_count"] == 1
|
||||
|
||||
|
||||
async def test_report_verrechnet_einkuenfte_und_fixkosten(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Gehalt",
|
||||
kind="income",
|
||||
category_id=seeded["gehalt"],
|
||||
amount="3200.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=28",
|
||||
dtstart="2026-01-28",
|
||||
)
|
||||
await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Miete",
|
||||
category_id=seeded["miete"],
|
||||
amount="950.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-01-01",
|
||||
)
|
||||
await anlegen(auth_client, seeded) # Netflix – Abos zählen laut Seed als Fixkosten
|
||||
await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Wocheneinkauf",
|
||||
category_id=seeded["lebensmittel"],
|
||||
amount="120.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=5",
|
||||
dtstart="2026-01-05",
|
||||
is_variable=True,
|
||||
)
|
||||
|
||||
report = (await auth_client.get("/api/reports/month", params={"month": "2026-03-10"})).json()
|
||||
|
||||
assert report["planned"]["income"] == "3200.00"
|
||||
assert report["planned"]["expenses"] == "1083.99"
|
||||
assert report["planned"]["balance"] == "2116.01"
|
||||
# Miete und Netflix sind Fixkostenkategorien, Lebensmittel nicht.
|
||||
assert report["fixed_costs"] == "963.99"
|
||||
assert report["variable_costs"] == "120.00"
|
||||
assert report["available_after_fixed"] == "2236.01"
|
||||
|
||||
|
||||
async def test_report_vergleicht_mit_dem_vormonat(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await anlegen(auth_client, seeded, amount="10.00")
|
||||
await auth_client.post(
|
||||
f"/api/recurrences/{posten['id']}/amount-versions",
|
||||
json={"amount": "20.00", "valid_from": "2026-03-01"},
|
||||
)
|
||||
|
||||
report = (await auth_client.get("/api/reports/month", params={"month": "2026-03-01"})).json()
|
||||
|
||||
assert report["planned"]["expenses"] == "20.00"
|
||||
assert report["previous_planned"]["expenses"] == "10.00"
|
||||
assert report["delta_to_previous"]["expenses"] == "10.00"
|
||||
assert report["delta_to_previous"]["balance"] == "-10.00"
|
||||
|
||||
|
||||
# --- Anlegen und Prüfen --------------------------------------------------------
|
||||
|
||||
|
||||
async def test_ungueltige_rrule_wird_abgewiesen(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/recurrences",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Kaputt",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "5.00",
|
||||
"rrule": "FREQ=QUARTERLY",
|
||||
"dtstart": "2026-01-01",
|
||||
},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "validation_error"
|
||||
|
||||
|
||||
async def test_dtstart_in_der_rrule_wird_abgewiesen(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/recurrences",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Mit DTSTART",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "5.00",
|
||||
"rrule": "DTSTART=20260101;FREQ=MONTHLY",
|
||||
"dtstart": "2026-01-01",
|
||||
},
|
||||
)
|
||||
assert antwort.status_code == 422
|
||||
|
||||
|
||||
async def test_falsche_kategorierichtung_wird_abgewiesen(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/recurrences",
|
||||
json={
|
||||
"kind": "income",
|
||||
"title": "Gehalt in Ausgabenkategorie",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "3000.00",
|
||||
"rrule": "FREQ=MONTHLY",
|
||||
"dtstart": "2026-01-01",
|
||||
},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "category_kind_mismatch"
|
||||
|
||||
|
||||
async def test_negativer_betrag_wird_abgewiesen(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
antwort = await auth_client.post(
|
||||
"/api/recurrences",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Negativ",
|
||||
"category_id": seeded["streaming"],
|
||||
"account_id": seeded["account_id"],
|
||||
"amount": "-5.00",
|
||||
"rrule": "FREQ=MONTHLY",
|
||||
"dtstart": "2026-01-01",
|
||||
},
|
||||
)
|
||||
assert antwort.status_code == 422
|
||||
|
||||
|
||||
# --- Preisversionen ------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_preisversion_wirkt_ab_dem_stichtag(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await anlegen(auth_client, seeded, amount="9.99")
|
||||
|
||||
await auth_client.post(
|
||||
f"/api/recurrences/{posten['id']}/amount-versions",
|
||||
json={"amount": "13.99", "valid_from": "2026-04-01", "note": "Preiserhöhung"},
|
||||
)
|
||||
|
||||
vorschau = (
|
||||
await auth_client.get(
|
||||
f"/api/recurrences/{posten['id']}/preview",
|
||||
params={"from": "2026-01-01", "to": "2026-06-30"},
|
||||
)
|
||||
).json()
|
||||
|
||||
betraege = {item["nominal_date"]: item["amount"] for item in vorschau}
|
||||
assert betraege["2026-03-15"] == "9.99"
|
||||
assert betraege["2026-04-15"] == "13.99"
|
||||
|
||||
# Der Basisbetrag folgt der jüngsten Version.
|
||||
detail = (await auth_client.get(f"/api/recurrences/{posten['id']}")).json()
|
||||
assert detail["amount"] == "13.99"
|
||||
assert len(detail["amount_versions"]) == 2
|
||||
|
||||
|
||||
async def test_doppelte_preisversion_wird_abgewiesen(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await anlegen(auth_client, seeded)
|
||||
|
||||
antwort = await auth_client.post(
|
||||
f"/api/recurrences/{posten['id']}/amount-versions",
|
||||
json={"amount": "20.00", "valid_from": "2026-01-15"},
|
||||
)
|
||||
assert antwort.status_code == 409
|
||||
|
||||
|
||||
# --- Vorschau, Raten, Verträge -------------------------------------------------
|
||||
|
||||
|
||||
async def test_vorschau_liefert_die_naechsten_termine(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await anlegen(auth_client, seeded)
|
||||
|
||||
vorschau = (
|
||||
await auth_client.get(
|
||||
f"/api/recurrences/{posten['id']}/preview",
|
||||
params={"from": "2026-01-01", "to": "2026-03-31"},
|
||||
)
|
||||
).json()
|
||||
|
||||
assert [item["nominal_date"] for item in vorschau] == [
|
||||
"2026-01-15",
|
||||
"2026-02-15",
|
||||
"2026-03-15",
|
||||
]
|
||||
assert vorschau[0]["recurrence_title"] == "Netflix Standard"
|
||||
|
||||
|
||||
async def test_kredit_zeigt_restschuld_und_raten(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Autokredit",
|
||||
category_id=seeded["kredite"],
|
||||
amount="250.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-01-01",
|
||||
installments_total=36,
|
||||
principal_amount="9000.00",
|
||||
)
|
||||
|
||||
assert posten["installments"]["total"] == 36
|
||||
assert posten["installments"]["final_due_date"] == "2028-12-01"
|
||||
|
||||
vorschau = (
|
||||
await auth_client.get(
|
||||
f"/api/recurrences/{posten['id']}/preview",
|
||||
params={"from": "2026-01-01", "to": "2032-12-31"},
|
||||
)
|
||||
).json()
|
||||
assert len(vorschau) == 36
|
||||
assert vorschau[0]["installment_number"] == 1
|
||||
assert vorschau[-1]["installment_number"] == 36
|
||||
|
||||
|
||||
async def test_vertrag_kuendigen_beendet_die_serie(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Handyvertrag",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-03-01",
|
||||
contract_start="2026-03-01",
|
||||
contract_min_term_months=24,
|
||||
contract_notice_period_days=90,
|
||||
contract_auto_renew_months=12,
|
||||
)
|
||||
|
||||
assert posten["contract_term"]["term_end"] == "2028-02-29"
|
||||
assert posten["contract_term"]["notice_deadline"] == "2027-12-01"
|
||||
assert posten["contract_term"]["renews_on"] == "2028-03-01"
|
||||
|
||||
gekuendigt = (await auth_client.post(f"/api/recurrences/{posten['id']}/cancel")).json()
|
||||
assert gekuendigt["contract_cancelled_at"] == "2028-02-29"
|
||||
assert gekuendigt["contract_term"]["is_cancelled"] is True
|
||||
|
||||
vorschau = (
|
||||
await auth_client.get(
|
||||
f"/api/recurrences/{posten['id']}/preview",
|
||||
params={"from": "2026-01-01", "to": "2030-12-31"},
|
||||
)
|
||||
).json()
|
||||
assert vorschau[-1]["nominal_date"] == "2028-02-01"
|
||||
|
||||
|
||||
async def test_ruecklage_wird_ausgewiesen(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await anlegen(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Kfz-Versicherung",
|
||||
amount="612.00",
|
||||
rrule="FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15",
|
||||
dtstart="2026-01-15",
|
||||
reserve_enabled=True,
|
||||
)
|
||||
|
||||
assert posten["monthly_reserve"] == "51.00"
|
||||
assert posten["annual_burden"] == "612.00"
|
||||
Reference in New Issue
Block a user