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,340 @@
|
||||
"""Integrationstests der Fälligkeiten-Overlays und der Saldoberechnung."""
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
async def abo(client: AsyncClient, seeded: dict, **overrides) -> dict:
|
||||
payload = {
|
||||
"kind": "expense",
|
||||
"title": "Netflix",
|
||||
"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()
|
||||
|
||||
|
||||
async def test_bestaetigen_ohne_betrag_uebernimmt_das_soll(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await abo(auth_client, seeded)
|
||||
|
||||
body = (
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={"recurrence_id": posten["id"], "occurrence_date": "2026-02-15"},
|
||||
)
|
||||
).json()
|
||||
|
||||
assert body["actual_amount"] == "13.99"
|
||||
assert body["effective_amount"] == "13.99"
|
||||
assert body["status"] == "confirmed"
|
||||
|
||||
|
||||
async def test_auslassen_nimmt_die_faelligkeit_aus_der_rechnung(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await abo(auth_client, seeded)
|
||||
|
||||
body = (
|
||||
await auth_client.post(
|
||||
"/api/occurrences/skip",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"note": "Monat geschenkt",
|
||||
},
|
||||
)
|
||||
).json()
|
||||
assert body["status"] == "skipped"
|
||||
assert body["effective_amount"] == "0.00"
|
||||
|
||||
report = (await auth_client.get("/api/reports/month", params={"month": "2026-02-01"})).json()
|
||||
assert report["planned"]["expenses"] == "0.00"
|
||||
assert report["skipped_count"] == 1
|
||||
|
||||
|
||||
async def test_zuruecksetzen_entfernt_die_abweichung(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await abo(auth_client, seeded)
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"actual_amount": "20.00",
|
||||
},
|
||||
)
|
||||
|
||||
zurueck = (
|
||||
await auth_client.post(
|
||||
"/api/occurrences/reset",
|
||||
json={"recurrence_id": posten["id"], "occurrence_date": "2026-02-15"},
|
||||
)
|
||||
).json()
|
||||
|
||||
assert zurueck["status"] == "planned"
|
||||
assert zurueck["actual_amount"] is None
|
||||
assert zurueck["occurrence_id"] is None
|
||||
|
||||
|
||||
async def test_bestaetigung_an_einem_termin_ohne_faelligkeit(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
posten = await abo(auth_client, seeded)
|
||||
|
||||
antwort = await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={"recurrence_id": posten["id"], "occurrence_date": "2026-02-16"},
|
||||
)
|
||||
|
||||
assert antwort.status_code == 422
|
||||
assert antwort.json()["code"] == "occurrence_not_due"
|
||||
|
||||
|
||||
async def test_nominales_datum_bleibt_schluessel_trotz_verschiebung(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
"""Der 01.02.2026 ist ein Sonntag; der Zahltag rutscht auf den 02.02."""
|
||||
posten = await abo(
|
||||
auth_client,
|
||||
seeded,
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
|
||||
dtstart="2026-02-01",
|
||||
business_day_shift="next",
|
||||
)
|
||||
|
||||
faelligkeiten = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-02-01", "to": "2026-02-28"})
|
||||
).json()
|
||||
assert faelligkeiten[0]["nominal_date"] == "2026-02-01"
|
||||
assert faelligkeiten[0]["due_date"] == "2026-02-02"
|
||||
|
||||
# Bestätigt wird über das nominale Datum.
|
||||
bestaetigt = await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={"recurrence_id": posten["id"], "occurrence_date": "2026-02-01"},
|
||||
)
|
||||
assert bestaetigt.status_code == 200
|
||||
assert bestaetigt.json()["due_date"] == "2026-02-02"
|
||||
|
||||
|
||||
async def test_verschobene_faelligkeit_zaehlt_im_zahlmonat(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
"""Der 31.05.2026 ist ein Sonntag – die Zahlung gehört in den Juni."""
|
||||
await abo(
|
||||
auth_client,
|
||||
seeded,
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=-1",
|
||||
dtstart="2026-01-31",
|
||||
business_day_shift="next",
|
||||
)
|
||||
|
||||
mai = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-05-01", "to": "2026-05-31"})
|
||||
).json()
|
||||
juni = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-06-01", "to": "2026-06-30"})
|
||||
).json()
|
||||
|
||||
assert [item["nominal_date"] for item in mai] == []
|
||||
assert [item["nominal_date"] for item in juni] == ["2026-05-31", "2026-06-30"]
|
||||
|
||||
# Nach nominalem Datum gefiltert sieht es anders aus.
|
||||
nominal = (
|
||||
await auth_client.get(
|
||||
"/api/occurrences",
|
||||
params={"from": "2026-05-01", "to": "2026-05-31", "by_due_date": False},
|
||||
)
|
||||
).json()
|
||||
assert [item["nominal_date"] for item in nominal] == ["2026-05-31"]
|
||||
|
||||
|
||||
async def test_faelligkeiten_lassen_sich_filtern(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
await abo(auth_client, seeded)
|
||||
await abo(
|
||||
auth_client,
|
||||
seeded,
|
||||
title="Gehalt",
|
||||
kind="income",
|
||||
category_id=seeded["gehalt"],
|
||||
amount="3000.00",
|
||||
rrule="FREQ=MONTHLY;BYMONTHDAY=28",
|
||||
dtstart="2026-01-28",
|
||||
)
|
||||
|
||||
einkuenfte = (
|
||||
await auth_client.get(
|
||||
"/api/occurrences",
|
||||
params={"from": "2026-02-01", "to": "2026-02-28", "kind": "income"},
|
||||
)
|
||||
).json()
|
||||
assert [item["recurrence_title"] for item in einkuenfte] == ["Gehalt"]
|
||||
|
||||
alle = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-02-01", "to": "2026-02-28"})
|
||||
).json()
|
||||
assert len(alle) == 2
|
||||
# Sortiert nach tatsächlichem Zahltag.
|
||||
assert [item["effective_date"] for item in alle] == ["2026-02-15", "2026-02-28"]
|
||||
|
||||
|
||||
async def test_deaktivierte_posten_erscheinen_nicht(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
posten = await abo(auth_client, seeded)
|
||||
await auth_client.patch(f"/api/recurrences/{posten['id']}", json={"is_active": False})
|
||||
|
||||
ohne = (
|
||||
await auth_client.get("/api/occurrences", params={"from": "2026-02-01", "to": "2026-02-28"})
|
||||
).json()
|
||||
mit = (
|
||||
await auth_client.get(
|
||||
"/api/occurrences",
|
||||
params={"from": "2026-02-01", "to": "2026-02-28", "include_inactive": True},
|
||||
)
|
||||
).json()
|
||||
|
||||
assert ohne == []
|
||||
assert len(mit) == 1
|
||||
|
||||
|
||||
# --- Kontosalden ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_saldo_beruecksichtigt_buchungen_und_bestaetigungen(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
konto = seeded["account_id"]
|
||||
posten = await abo(auth_client, seeded)
|
||||
|
||||
await auth_client.post(
|
||||
"/api/transactions",
|
||||
json={
|
||||
"kind": "expense",
|
||||
"title": "Wocheneinkauf",
|
||||
"category_id": seeded["lebensmittel"],
|
||||
"account_id": konto,
|
||||
"amount": "84.30",
|
||||
"booking_date": "2026-02-05",
|
||||
},
|
||||
)
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"actual_amount": "13.99",
|
||||
},
|
||||
)
|
||||
|
||||
saldo = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-02-28"})
|
||||
).json()
|
||||
|
||||
assert saldo["opening_balance"] == "1000.00"
|
||||
assert saldo["booked_transactions"] == "-84.30"
|
||||
assert saldo["booked_occurrences"] == "-13.99"
|
||||
assert saldo["balance"] == "901.71"
|
||||
|
||||
|
||||
async def test_saldo_zaehlt_nur_bis_zum_stichtag(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
konto = seeded["account_id"]
|
||||
posten = await abo(auth_client, seeded)
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={"recurrence_id": posten["id"], "occurrence_date": "2026-02-15"},
|
||||
)
|
||||
|
||||
davor = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-02-14"})
|
||||
).json()
|
||||
danach = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-02-15"})
|
||||
).json()
|
||||
|
||||
assert davor["balance"] == "1000.00"
|
||||
assert danach["balance"] == "986.01"
|
||||
|
||||
|
||||
async def test_saldo_folgt_dem_istdatum(auth_client: AsyncClient, seeded: dict) -> None:
|
||||
"""Wird eine Zahlung später erfasst, zählt sie erst ab dem Ist-Datum."""
|
||||
konto = seeded["account_id"]
|
||||
posten = await abo(auth_client, seeded)
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"actual_date": "2026-03-02",
|
||||
},
|
||||
)
|
||||
|
||||
februar = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-02-28"})
|
||||
).json()
|
||||
maerz = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-03-31"})
|
||||
).json()
|
||||
|
||||
assert februar["balance"] == "1000.00"
|
||||
assert maerz["balance"] == "986.01"
|
||||
|
||||
|
||||
async def test_geplante_faelligkeiten_veraendern_den_saldo_nicht(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
konto = seeded["account_id"]
|
||||
await abo(auth_client, seeded)
|
||||
|
||||
saldo = (
|
||||
await auth_client.get(f"/api/accounts/{konto}/balance", params={"as_of": "2026-12-31"})
|
||||
).json()
|
||||
|
||||
assert saldo["balance"] == "1000.00"
|
||||
|
||||
|
||||
async def test_abweichendes_konto_bei_der_bestaetigung(
|
||||
auth_client: AsyncClient, seeded: dict
|
||||
) -> None:
|
||||
zweitkonto = (
|
||||
await auth_client.post(
|
||||
"/api/accounts",
|
||||
json={
|
||||
"name": "Kreditkarte",
|
||||
"type": "credit_card",
|
||||
"opening_balance": "0.00",
|
||||
"opening_balance_date": "2026-01-01",
|
||||
},
|
||||
)
|
||||
).json()
|
||||
posten = await abo(auth_client, seeded)
|
||||
|
||||
await auth_client.post(
|
||||
"/api/occurrences/confirm",
|
||||
json={
|
||||
"recurrence_id": posten["id"],
|
||||
"occurrence_date": "2026-02-15",
|
||||
"account_id": zweitkonto["id"],
|
||||
},
|
||||
)
|
||||
|
||||
giro = (
|
||||
await auth_client.get(
|
||||
f"/api/accounts/{seeded['account_id']}/balance", params={"as_of": "2026-12-31"}
|
||||
)
|
||||
).json()
|
||||
karte = (
|
||||
await auth_client.get(
|
||||
f"/api/accounts/{zweitkonto['id']}/balance", params={"as_of": "2026-12-31"}
|
||||
)
|
||||
).json()
|
||||
|
||||
assert giro["balance"] == "1000.00"
|
||||
assert karte["balance"] == "-13.99"
|
||||
Reference in New Issue
Block a user