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

350 lines
12 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: 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"