Files
moneyfy/backend/tests/test_api_reports.py
T
moneyfyandClaude Opus 5 0adf154049 feat(reports): Auswertungen, Kalender, Budgets und Export
Backend:
- Gemeinsame Bewegungsschicht flows(), auf der alle Berichte aufbauen; Plan und
  Ist bleiben dabei getrennt
- Forecast, Kategorien mit Drilldown, Abo-Übersicht, Jahresvergleich,
  Cashflow-Kalender, Budget-Ampel, Sparziel-Fortschritt, gebündeltes Dashboard
- Budgetübertrag über Monatsgrenzen, Budgets auf Oberkategorien schließen
  Unterkategorien ein
- Export als CSV (BOM, Semikolon, deutsches Dezimaltrennzeichen) und XLSX mit
  typisierten Beträgen

Frontend:
- Dashboard, Cashflow-Kalender mit Bestätigen direkt am Tag, Budget-, Sparziel-
  und Auswertungsseite
- Diagrammpalette gegen beide Flächen auf Kontrast und Farbfehlsichtigkeit
  geprüft; Grün/Rot als Serienpaar verworfen
- Einnahmen/Ausgaben und kumulierter Saldo in getrennten Diagrammen statt auf
  zwei Größenachsen
- Recharts in einen eigenen Chunk ausgelagert

27 neue Backend-Tests (235 gesamt), 16 neue Frontend-Tests (65 gesamt)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 16:50:22 +02:00

624 lines
21 KiB
Python
Raw 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 Auswertungen."""
import io
from httpx import AsyncClient
from openpyxl import load_workbook
async def posten(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 haushalt(client: AsyncClient, seeded: dict) -> None:
"""Ein realistisches Grundgerüst: Gehalt, Miete, Abo, jährliche Versicherung."""
await posten(
client,
seeded,
title="Gehalt",
kind="income",
category_id=seeded["gehalt"],
amount="3200.00",
rrule="FREQ=MONTHLY;BYMONTHDAY=28",
dtstart="2026-01-28",
)
await posten(
client,
seeded,
title="Miete",
category_id=seeded["miete"],
amount="950.00",
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart="2026-01-01",
)
await posten(client, seeded)
await posten(
client,
seeded,
title="Kfz-Versicherung",
category_id=seeded["miete"],
amount="612.00",
rrule="FREQ=YEARLY;BYMONTH=7;BYMONTHDAY=1",
dtstart="2026-07-01",
reserve_enabled=True,
)
# --- Forecast ------------------------------------------------------------------
async def test_forecast_setzt_jaehrliche_posten_in_den_richtigen_monat(
auth_client: AsyncClient, seeded: dict
) -> None:
"""Akzeptanzkriterium: der jährliche Posten taucht nur im Juli auf."""
await haushalt(auth_client, seeded)
antwort = await auth_client.get(
"/api/reports/forecast", params={"months": 12, "start": "2026-01-01"}
)
assert antwort.status_code == 200
monate = antwort.json()["months"]
assert len(monate) == 12
assert monate[0]["month"] == "2026-01-01"
assert monate[11]["month"] == "2026-12-01"
nach_monat = {monat["month"]: monat for monat in monate}
# Januar bis Juni: Miete 950 + Netflix 13,99
assert nach_monat["2026-01-01"]["expenses"] == "963.99"
assert nach_monat["2026-06-01"]["expenses"] == "963.99"
# Juli zusätzlich die Jahresprämie
assert nach_monat["2026-07-01"]["expenses"] == "1575.99"
assert nach_monat["2026-08-01"]["expenses"] == "963.99"
async def test_forecast_summiert_den_kontostand_auf(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
monate = (
await auth_client.get("/api/reports/forecast", params={"months": 3, "start": "2026-01-01"})
).json()["months"]
# Eröffnungssaldo 1000 plus dem Saldo jedes Monats.
assert monate[0]["balance"] == "2236.01"
assert monate[0]["cumulative_balance"] == "3236.01"
assert monate[1]["cumulative_balance"] == "5472.02"
assert monate[2]["cumulative_balance"] == "7708.03"
async def test_forecast_ohne_daten_ist_flach(auth_client: AsyncClient, seeded: dict) -> None:
monate = (await auth_client.get("/api/reports/forecast", params={"months": 2})).json()["months"]
assert all(monat["income"] == "0.00" for monat in monate)
assert all(monat["cumulative_balance"] == "1000.00" for monat in monate)
# --- Kategorien ----------------------------------------------------------------
async def test_kategorien_mit_drilldown(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
bericht = (
await auth_client.get(
"/api/reports/categories", params={"from": "2026-03-01", "to": "2026-03-31"}
)
).json()
assert bericht["total"] == "963.99"
namen = {gruppe["name"]: gruppe for gruppe in bericht["categories"]}
assert namen["Wohnen"]["amount"] == "950.00"
# Der Drilldown zeigt die Unterkategorie.
assert [kind["name"] for kind in namen["Wohnen"]["children"]] == ["Miete"]
assert namen["Abos & Medien"]["amount"] == "13.99"
async def test_kategorien_koennen_einkuenfte_zeigen(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
bericht = (
await auth_client.get(
"/api/reports/categories",
params={"from": "2026-03-01", "to": "2026-03-31", "kind": "income"},
)
).json()
assert bericht["total"] == "3200.00"
assert bericht["categories"][0]["name"] == "Einkünfte"
async def test_kategorien_weisen_verdrehten_zeitraum_ab(
auth_client: AsyncClient, seeded: dict
) -> None:
antwort = await auth_client.get(
"/api/reports/categories", params={"from": "2026-03-31", "to": "2026-03-01"}
)
assert antwort.status_code == 422
assert antwort.json()["code"] == "invalid_date_range"
# --- Abo-Übersicht -------------------------------------------------------------
async def test_aboübersicht_mit_jahreskosten(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
bericht = (await auth_client.get("/api/reports/subscriptions")).json()
nach_titel = {eintrag["title"]: eintrag for eintrag in bericht["entries"]}
assert nach_titel["Miete"]["annual_cost"] == "11400.00"
assert nach_titel["Netflix"]["annual_cost"] == "167.88"
assert nach_titel["Kfz-Versicherung"]["annual_cost"] == "612.00"
assert nach_titel["Kfz-Versicherung"]["monthly_cost"] == "51.00"
# Absteigend nach Jahreskosten sortiert.
assert next(eintrag["title"] for eintrag in bericht["entries"]) == "Miete"
assert bericht["total_annual"] == "12179.88"
assert bericht["total_monthly"] == "1014.99"
async def test_raten_zaehlen_nicht_in_die_abosumme(auth_client: AsyncClient, seeded: dict) -> None:
await posten(auth_client, seeded)
await posten(
auth_client,
seeded,
title="Autokredit",
category_id=seeded["kredite"],
amount="250.00",
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart="2026-01-01",
installments_total=36,
)
bericht = (await auth_client.get("/api/reports/subscriptions")).json()
kredit = next(e for e in bericht["entries"] if e["title"] == "Autokredit")
assert kredit["is_installment"] is True
# Nur Netflix zählt in die Summe.
assert bericht["total_annual"] == "167.88"
async def test_ablaufende_kuendigungsfrist_wird_gemeldet(
auth_client: AsyncClient, seeded: dict
) -> None:
from datetime import date, timedelta
# Vertrag so legen, dass der Kündigungstermin in 30 Tagen liegt.
heute = date.today()
laufzeitende = heute + timedelta(days=30 + 90)
vertragsbeginn = laufzeitende + timedelta(days=1) - timedelta(days=365)
await posten(
auth_client,
seeded,
title="Handyvertrag",
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart=vertragsbeginn.isoformat(),
contract_start=vertragsbeginn.isoformat(),
contract_min_term_months=12,
contract_notice_period_days=90,
)
bericht = (await auth_client.get("/api/reports/subscriptions")).json()
fristen = {eintrag["title"] for eintrag in bericht["upcoming_deadlines"]}
assert "Handyvertrag" in fristen
eintrag = next(e for e in bericht["entries"] if e["title"] == "Handyvertrag")
assert 25 <= eintrag["days_until_notice"] <= 35
# --- Jahresvergleich -----------------------------------------------------------
async def test_jahresvergleich(auth_client: AsyncClient, seeded: dict) -> None:
# Ein Abo, das erst 2026 beginnt 2025 gibt es also nichts.
await posten(auth_client, seeded, amount="10.00")
bericht = (await auth_client.get("/api/reports/year-comparison", params={"year": 2026})).json()
assert bericht["year"] == 2026
zeile = next(z for z in bericht["rows"] if z["name"] == "Abos & Medien")
assert zeile["current"] == "120.00" # zwölf Monate à 10
assert zeile["previous"] == "0.00"
assert zeile["delta"] == "120.00"
# --- Kalender ------------------------------------------------------------------
async def test_kalender_zeigt_faelligkeiten_und_verlauf(
auth_client: AsyncClient, seeded: dict
) -> None:
await haushalt(auth_client, seeded)
kalender = (
await auth_client.get("/api/reports/calendar", params={"month": "2026-03-01"})
).json()
assert kalender["month"] == "2026-03-01"
assert len(kalender["days"]) == 31
nach_tag = {tag["date"]: tag for tag in kalender["days"]}
# Am 1. März ist die Miete fällig.
assert [eintrag["title"] for eintrag in nach_tag["2026-03-01"]["entries"]] == ["Miete"]
assert nach_tag["2026-03-01"]["net"] == "-950.00"
# Der 15. trägt Netflix, der 28. das Gehalt.
assert [eintrag["title"] for eintrag in nach_tag["2026-03-15"]["entries"]] == ["Netflix"]
assert [eintrag["title"] for eintrag in nach_tag["2026-03-28"]["entries"]] == ["Gehalt"]
# Der laufende Kontostand schreibt sich über den Monat fort.
# Eröffnung 1000, nach der Miete am 1. noch 50, nach dem Gehalt am 28. wieder hoch.
assert nach_tag["2026-03-01"]["running_balance"] == "50.00"
assert nach_tag["2026-03-15"]["running_balance"] == "36.01"
assert nach_tag["2026-03-28"]["running_balance"] == "3236.01"
assert kalender["closing_balance"] == nach_tag["2026-03-31"]["running_balance"]
assert kalender["lowest_balance_on"] is not None
async def test_kalender_kennzeichnet_werktage(auth_client: AsyncClient, seeded: dict) -> None:
kalender = (
await auth_client.get("/api/reports/calendar", params={"month": "2026-04-01"})
).json()
nach_tag = {tag["date"]: tag for tag in kalender["days"]}
assert nach_tag["2026-04-03"]["is_business_day"] is False # Karfreitag
assert nach_tag["2026-04-06"]["is_business_day"] is False # Ostermontag
assert nach_tag["2026-04-07"]["is_business_day"] is True
async def test_kalender_liefert_das_nominale_datum_mit(
auth_client: AsyncClient, seeded: dict
) -> None:
"""Der Kalender muss Bestätigen ermöglichen dafür braucht er den Schlüssel."""
eintrag = await posten(
auth_client,
seeded,
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart="2026-02-01",
business_day_shift="next",
)
kalender = (
await auth_client.get("/api/reports/calendar", params={"month": "2026-02-01"})
).json()
nach_tag = {tag["date"]: tag for tag in kalender["days"]}
# 01.02.2026 ist ein Sonntag; gezahlt wird am 02.02.
assert nach_tag["2026-02-01"]["entries"] == []
posten_eintrag = nach_tag["2026-02-02"]["entries"][0]
assert posten_eintrag["occurrence_date"] == "2026-02-01"
assert posten_eintrag["recurrence_id"] == eintrag["id"]
assert posten_eintrag["status"] == "planned"
# --- Budget-Ampel --------------------------------------------------------------
async def test_budget_ampel_schaltet_richtig(auth_client: AsyncClient, seeded: dict) -> None:
await auth_client.post(
"/api/budgets",
json={
"category_id": seeded["lebensmittel"],
"period_month": "2026-03-01",
"limit_amount": "400.00",
},
)
async def verbrauche(betrag: str) -> None:
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Einkauf",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": betrag,
"booking_date": "2026-03-05",
},
)
async def status() -> dict:
antwort = await auth_client.get("/api/reports/budgets", params={"month": "2026-03-01"})
return antwort.json()[0]
await verbrauche("100.00")
assert (await status())["state"] == "ok" # 25 %
await verbrauche("219.00")
assert (await status())["state"] == "ok" # 79,75 %
await verbrauche("2.00")
aktuell = await status()
assert aktuell["state"] == "warning" # 80,25 %
assert aktuell["spent"] == "321.00"
await verbrauche("100.00")
ueberschritten = await status()
assert ueberschritten["state"] == "exceeded"
assert ueberschritten["remaining"] == "-21.00"
async def test_budget_auf_oberkategorie_umfasst_unterkategorien(
auth_client: AsyncClient, seeded: dict
) -> None:
baum = (await auth_client.get("/api/categories")).json()
lebenshaltung = next(k for k in baum if k["name"] == "Lebenshaltung")
await auth_client.post(
"/api/budgets",
json={
"category_id": lebenshaltung["id"],
"period_month": "2026-03-01",
"limit_amount": "500.00",
},
)
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Einkauf",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": "120.00",
"booking_date": "2026-03-05",
},
)
status = (await auth_client.get("/api/reports/budgets", params={"month": "2026-03-01"})).json()
assert status[0]["spent"] == "120.00"
async def test_budgetvorlage_gilt_ab_dem_monat(auth_client: AsyncClient, seeded: dict) -> None:
await auth_client.post(
"/api/budget-templates",
json={
"category_id": seeded["lebensmittel"],
"valid_from": "2026-02-01",
"limit_amount": "450.00",
},
)
januar = (await auth_client.get("/api/reports/budgets", params={"month": "2026-01-01"})).json()
maerz = (await auth_client.get("/api/reports/budgets", params={"month": "2026-03-01"})).json()
assert januar == []
assert maerz[0]["limit_amount"] == "450.00"
assert maerz[0]["is_template"] is True
async def test_rollover_uebertraegt_den_rest(auth_client: AsyncClient, seeded: dict) -> None:
for monat in ("2026-01-01", "2026-02-01"):
await auth_client.post(
"/api/budgets",
json={
"category_id": seeded["lebensmittel"],
"period_month": monat,
"limit_amount": "400.00",
"rollover": True,
},
)
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Einkauf Januar",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": "300.00",
"booking_date": "2026-01-10",
},
)
februar = (await auth_client.get("/api/reports/budgets", params={"month": "2026-02-01"})).json()
assert februar[0]["carried_over"] == "100.00"
assert februar[0]["available"] == "500.00"
# --- Sparziele -----------------------------------------------------------------
async def test_sparziel_fortschritt_und_rate(auth_client: AsyncClient) -> None:
from datetime import date
from dateutil.relativedelta import relativedelta
ziel_datum = date.today() + relativedelta(months=10)
await auth_client.post(
"/api/savings-goals",
json={
"name": "Neues Fahrrad",
"target_amount": "2000.00",
"current_amount": "500.00",
"target_date": ziel_datum.isoformat(),
"monthly_contribution": "100.00",
},
)
fortschritt = (await auth_client.get("/api/reports/savings-goals")).json()[0]
assert fortschritt["remaining_amount"] == "1500.00"
assert fortschritt["ratio"] == 0.25
assert fortschritt["months_left"] == 10
assert fortschritt["required_monthly"] == "150.00"
# 100 Euro reichen bei 150 nötigen nicht.
assert fortschritt["is_on_track"] is False
async def test_erreichtes_sparziel_braucht_keine_rate(auth_client: AsyncClient) -> None:
from datetime import date
await auth_client.post(
"/api/savings-goals",
json={
"name": "Urlaubskasse",
"target_amount": "1000.00",
"current_amount": "1000.00",
"target_date": (date.today().replace(year=date.today().year + 1)).isoformat(),
},
)
fortschritt = (await auth_client.get("/api/reports/savings-goals")).json()[0]
assert fortschritt["ratio"] == 1.0
assert fortschritt["required_monthly"] == "0.00"
# --- Dashboard -----------------------------------------------------------------
async def test_dashboard_buendelt_alles(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
dashboard = (
await auth_client.get("/api/reports/dashboard", params={"month": "2026-03-01"})
).json()
assert dashboard["month"]["month"] == "2026-03-01"
assert dashboard["month"]["available_after_fixed"] == "2185.01"
assert len(dashboard["forecast"]) == 12
assert len(dashboard["categories"]) == 2
assert dashboard["total_balance"] == "1000.00"
assert dashboard["budgets"] == []
assert dashboard["goals"] == []
# --- Export --------------------------------------------------------------------
async def test_export_buchungen_als_csv(auth_client: AsyncClient, seeded: dict) -> None:
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Wocheneinkauf",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": "84.30",
"booking_date": "2026-03-05",
},
)
antwort = await auth_client.get(
"/api/export/transactions",
params={"format": "csv", "from": "2026-01-01", "to": "2026-12-31"},
)
assert antwort.status_code == 200
assert antwort.headers["content-type"].startswith("text/csv")
assert "moneyfy-buchungen" in antwort.headers["content-disposition"]
inhalt = antwort.content
# Excel erkennt UTF-8 nur mit BOM zuverlässig.
assert inhalt.startswith(b"\xef\xbb\xbf")
text = inhalt.decode("utf-8-sig")
zeilen = text.strip().splitlines()
assert zeilen[0].startswith("Datum;Titel;Richtung;Betrag")
# Deutsches Dezimaltrennzeichen.
assert "84,30" in zeilen[1]
assert "Lebenshaltung · Lebensmittel" in zeilen[1]
async def test_export_buchungen_als_xlsx(auth_client: AsyncClient, seeded: dict) -> None:
await auth_client.post(
"/api/transactions",
json={
"kind": "expense",
"title": "Wocheneinkauf",
"category_id": seeded["lebensmittel"],
"account_id": seeded["account_id"],
"amount": "84.30",
"booking_date": "2026-03-05",
},
)
antwort = await auth_client.get("/api/export/transactions", params={"format": "xlsx"})
assert antwort.status_code == 200
assert antwort.content[:2] == b"PK"
mappe = load_workbook(io.BytesIO(antwort.content))
blatt = mappe["Buchungen"]
kopf = [zelle.value for zelle in blatt[1]]
assert kopf[:4] == ["Datum", "Titel", "Richtung", "Betrag"]
# Der Betrag ist eine Zahl, keine Zeichenkette Excel kann damit rechnen.
assert blatt.cell(row=2, column=4).value == 84.3
assert "€" in blatt.cell(row=2, column=4).number_format
async def test_export_posten(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
antwort = await auth_client.get("/api/export/recurrences", params={"format": "xlsx"})
mappe = load_workbook(io.BytesIO(antwort.content))
blatt = mappe["Posten"]
titel = [blatt.cell(row=zeile, column=1).value for zeile in range(2, blatt.max_row + 1)]
assert set(titel) == {"Gehalt", "Kfz-Versicherung", "Miete", "Netflix"}
async def test_export_monatsauswertung(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
antwort = await auth_client.get(
"/api/export/month", params={"format": "xlsx", "month": "2026-03-01"}
)
mappe = load_workbook(io.BytesIO(antwort.content))
assert mappe.sheetnames == ["Bewegungen", "Kennzahlen"]
bewegungen = mappe["Bewegungen"]
titel = [bewegungen.cell(row=zeile, column=2).value for zeile in range(2, 5)]
assert set(titel) == {"Miete", "Netflix", "Gehalt"}
kennzahlen = {
mappe["Kennzahlen"].cell(row=zeile, column=1).value: mappe["Kennzahlen"]
.cell(row=zeile, column=2)
.value
for zeile in range(2, mappe["Kennzahlen"].max_row + 1)
}
assert kennzahlen["Ausgaben (Plan)"] == 963.99
assert kennzahlen["Einnahmen (Plan)"] == 3200.0
async def test_export_monatsauswertung_als_csv(auth_client: AsyncClient, seeded: dict) -> None:
await haushalt(auth_client, seeded)
antwort = await auth_client.get(
"/api/export/month", params={"format": "csv", "month": "2026-03-01"}
)
text = antwort.content.decode("utf-8-sig")
assert "Datum;Titel;Kategorie" in text
# Die Kennzahlen folgen nach einer Leerzeile im selben Dokument.
assert "Kennzahl;Betrag" in text
assert "Verfügbar nach Fixkosten" in text
async def test_export_weist_verdrehten_zeitraum_ab(auth_client: AsyncClient) -> None:
antwort = await auth_client.get(
"/api/export/transactions", params={"from": "2026-12-31", "to": "2026-01-01"}
)
assert antwort.status_code == 422
assert antwort.json()["code"] == "invalid_date_range"
async def test_export_verlangt_anmeldung(client: AsyncClient) -> None:
assert (await client.get("/api/export/transactions")).status_code == 401