Files
moneyfy/backend/tests/test_api_occurrences.py
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

341 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 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"