from datetime import date, timedelta
from decimal import Decimal
from app.formats import eur
from app.models.tables import (Account, Loan, PlannedItem, RecurringItem,
Scenario, ScenarioModifier, ScenarioPlannedItem)
H = {"Authorization": "Bearer test-key"}
def test_pages_require_login(client):
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/hilfe"):
r = client.get(path, follow_redirects=False)
assert r.status_code in (302, 303), path
assert r.headers["location"] == "/login"
def test_pages_render_after_login(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/hilfe"):
r = client.get(path)
assert r.status_code == 200
assert "Finanzberatung" in r.text
assert "Gebrauchsanleitung" in client.get("/hilfe").text
def test_index_shows_account_anchor(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
with_anchor = Account(bank="dkb", iban="DE-GUI-1", name="Mit Anker", type="giro")
without_anchor = Account(bank="vr", iban="DE-GUI-2", name="Ohne Anker", type="giro")
db.add_all([with_anchor, without_anchor])
db.flush()
with_anchor.anchor_date = date(2026, 6, 30)
with_anchor.anchor_balance = Decimal("321.00")
db.commit()
r = client.get("/")
assert r.status_code == 200
assert "Anker: 321,00" in r.text
assert "30.06.2026" in r.text
assert "kein Anker" in r.text
# Inline-Pflegeformular fuer den Anker muss je Konto vorhanden sein.
assert 'name="anchor_date"' in r.text
assert 'name="anchor_balance"' in r.text
def test_gui_zeigt_deutsche_betragsformate(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
acc = Account(bank="dkb", iban="DE-FMT-1", name="Formatkonto", type="giro")
db.add(acc)
db.flush()
acc.anchor_date = date(2026, 6, 30)
acc.anchor_balance = Decimal("12345.60")
db.commit()
r = client.get("/")
assert "12.345,60" in r.text
assert "12345.60" not in r.text
def test_planung_zeigt_deutsche_labels(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(RecurringItem(name="Miete-Label-Test", amount=Decimal("-600.00"),
rhythm="monthly", due_day=1))
db.commit()
r = client.get("/planung")
assert "monatlich" in r.text
assert "-600,00" in r.text
def test_betragsfelder_haben_amount_typ_und_deutsche_platzhalter(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
planung = client.get("/planung").text
# Fixposten-, Einmalposten-, Kredit-Formulare senden deutsche Eingaben:
assert planung.count('data-type="amount"') >= 4
assert '-49,99' in planung and '-2000,00' in planung
assert '-49.99' not in planung and '-2000.00' not in planung
# Anker-Formular wird nur je Konto gerendert - ohne Konto in der DB gaebe
# es keine Zeile mit dem Anker-Input (Template-Zweig "Keine Konten
# angelegt"), daher hier ein Konto anlegen wie in den Nachbartests.
db.add(Account(bank="dkb", iban="DE-AMT-1", name="Amount-Test", type="giro"))
db.commit()
index = client.get("/").text
assert 'placeholder="500,00"' in index
def test_import_page_has_dropzone_and_list(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/import")
assert r.status_code == 200
assert 'id="dropzone"' in r.text
assert 'id="imports"' in r.text
def test_import_list_fragment_requires_login(client):
r = client.get("/import/list", follow_redirects=False)
assert r.status_code in (302, 303)
def test_import_list_fragment_renders_after_login(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/import/list")
assert r.status_code == 200
assert "Noch keine Imports" in r.text
def test_preview_fragment_shows_balance_ok_none_for_format_without_saldo(
client, tmp_path, monkeypatch):
# UX-Regel: Saldo-Status ist IMMER sichtbar, auch wenn das Format (hier
# HVB-CSV) keine Saldodaten liefert - dreiwertig statt faelschlich "rot".
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
from tests.test_import_api import _hvb_csv_bytes
sid = client.post("/api/imports/upload", headers=H,
files={"file": ("HVB_gui.csv", _hvb_csv_bytes(), "text/csv")}
).json()["id"]
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get(f"/import/{sid}/preview-fragment")
assert r.status_code == 200
assert "Saldo-Prüfung: nicht verfügbar (Format ohne Saldodaten)" in r.text
def test_preview_fragment_shows_balance_ok_true_for_vr_csv(client, tmp_path, monkeypatch):
monkeypatch.setenv("FB_INBOX_DIR", str(tmp_path / "inbox"))
monkeypatch.setenv("FB_UPLOADS_DIR", str(tmp_path / "uploads"))
from app.config import get_settings
get_settings.cache_clear()
from tests.test_import_api import _vr_csv_bytes
sid = client.post("/api/imports/upload", headers=H,
files={"file": ("VR_gui.csv", _vr_csv_bytes(), "text/csv")}
).json()["id"]
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get(f"/import/{sid}/preview-fragment")
assert r.status_code == 200
assert "Saldo plausibel" in r.text
def test_version_visible(client):
from pathlib import Path
version = (Path(__file__).resolve().parent.parent / "VERSION").read_text().strip()
r = client.get("/api/version", headers={"Authorization": "Bearer test-key"})
assert r.status_code == 200 and r.json()["version"] == version
client.post("/login", data={"username": "admin", "password": "geheim"})
assert f"v{version}" in client.get("/").text
def test_buchungen_pagination(client, db):
from app.models.tables import Account, Transaction
acc = Account(bank="dkb", iban="DE01", name="G", type="giro")
db.add(acc)
db.flush()
base = date(2026, 1, 1)
for i in range(1, 61):
purpose = "MARKER51" if i == 51 else f"TX{i}"
db.add(Transaction(
account_id=acc.id, booking_date=base + timedelta(days=i),
amount=Decimal("10.00"), purpose=purpose, counterparty="X",
status="confirmed", dedup_hash=f"hash-{i}",
))
db.commit()
client.post("/login", data={"username": "admin", "password": "geheim"})
r1 = client.get("/buchungen")
assert r1.status_code == 200
assert "Seite 1 von 2" in r1.text
assert "Weiter" in r1.text
assert "MARKER51" not in r1.text
r2 = client.get("/buchungen?page=2")
assert r2.status_code == 200
assert "Seite 2 von 2" in r2.text
assert "Zurück" in r2.text
assert "MARKER51" in r2.text
def test_buchungen_empty_filter_fields_no_422(client):
# HTML-Formulare senden bei nicht ausgefüllten Feldern leere Strings, nicht
# "kein Parameter" — die GUI-Route muss das tolerant behandeln statt mit
# einem rohen 422 zu scheitern.
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get(
"/buchungen"
"?account_id=2&date_from=&date_to=&category_id=&q=&status=confirmed"
)
assert r.status_code == 200
def test_buchungen_filled_filters_scope_to_account(client, db):
from app.models.tables import Account, Transaction
acc_a = Account(bank="dkb", iban="DE01", name="Konto A", type="giro")
acc_b = Account(bank="dkb", iban="DE02", name="Konto B", type="giro")
db.add_all([acc_a, acc_b])
db.flush()
db.add(Transaction(
account_id=acc_a.id, booking_date=date(2026, 6, 15),
amount=Decimal("10.00"), purpose="MARKER-A", counterparty="X",
status="confirmed", dedup_hash="hash-a",
))
db.add(Transaction(
account_id=acc_b.id, booking_date=date(2026, 6, 15),
amount=Decimal("20.00"), purpose="MARKER-B", counterparty="Y",
status="confirmed", dedup_hash="hash-b",
))
db.commit()
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get(
f"/buchungen?account_id={acc_a.id}&date_from=2026-06-01&date_to=2026-06-30"
)
assert r.status_code == 200
assert "MARKER-A" in r.text
assert "MARKER-B" not in r.text
def test_planning_page_has_all_sections(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/planung")
assert r.status_code == 200
for heading in ("Wiederkehrende Posten", "Einmalposten", "Kredite", "Szenarien"):
assert heading in r.text
def test_planung_fixposten_zeigt_start_und_ende(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(RecurringItem(name="Befristet-GUI", amount=Decimal("-5.00"),
rhythm="monthly", due_day=1,
start_date=date(2026, 1, 1), end_date=date(2026, 12, 31)))
db.commit()
r = client.get("/planung").text
assert "
Start | " in r and "Ende | " in r
assert "01.01.2026" in r and "31.12.2026" in r
assert 'name="start_date"' in r and 'name="end_date"' in r
def test_planning_page_shows_empty_suggestions_hint(client):
# UX-Regel: das "Vorschläge aus Buchungen"-Fieldset ist immer sichtbar,
# auch ohne Daten - statt komplett zu verschwinden zeigt es einen Hinweis.
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/planung")
assert r.status_code == 200
assert "Vorschläge aus Buchungen" in r.text
assert "Keine Vorschläge" in r.text
assert ("Erkannt werden monatliche, vierteljährliche und jährliche "
"Serien; Betrag = jeweils letzte Buchung.") in r.text
def test_vorschlaege_zeigen_rhythmus_und_start(client, db):
# Synthetische vierteljaehrliche Serie relativ zu date.today(), da die
# Route suggest_recurring() ohne today-Injektion aufruft (echter
# Aktiv-Check gegen date.today()). Schrittweite ~91 Tage rueckwaerts,
# letzte Buchung 30 Tage vor heute (innerhalb des Aktiv-Fensters).
from app.models.tables import Transaction
client.post("/login", data={"username": "admin", "password": "geheim"})
acc = Account(bank="dkb", iban="DE-SUG-1", name="S", type="giro")
db.add(acc)
db.flush()
today = date.today()
booking_dates = [today - timedelta(days=d) for d in (303, 212, 121, 30)]
for d in booking_dates:
db.add(Transaction(account_id=acc.id, booking_date=d, amount=Decimal("-55.08"),
purpose="p", counterparty="Rundfunk Synth", status="confirmed",
dedup_hash=f"sug-{d.isoformat()}"))
db.commit()
r = client.get("/planung").text
letzte_buchung = booking_dates[-1]
assert "vierteljährlich" in r
assert letzte_buchung.strftime("%d.%m.%Y") in r
assert 'name="start_date"' in r
def test_salden_page_stichtag_and_month_overview(client, db):
from app.engine.loans import add_months
from app.models.tables import Transaction
# Drei Monate relativ zu "heute" verwenden statt fester Kalenderdaten,
# damit der Test unabhaengig vom Ausfuehrungsdatum immer genau 3
# Monatsanfangs-Zeilen (frühester Buchungsmonat bis heute) erzeugt.
today = date.today()
m0 = date(today.year, today.month, 1)
m1 = add_months(m0, -1)
m2 = add_months(m0, -2)
acc = Account(bank="dkb", iban="DE-SALDEN-1", name="Salden-Konto", type="giro")
db.add(acc)
db.flush()
acc.anchor_date = m2
acc.anchor_balance = Decimal("1000.00")
db.flush()
db.add_all([
Transaction(account_id=acc.id, booking_date=m2 + timedelta(days=5),
amount=Decimal("111.11"), purpose="SALDEN-TEST-1",
counterparty="X", status="confirmed", dedup_hash="salden-h1"),
Transaction(account_id=acc.id, booking_date=m1 + timedelta(days=3),
amount=Decimal("22.22"), purpose="SALDEN-TEST-2",
counterparty="X", status="confirmed", dedup_hash="salden-h2"),
Transaction(account_id=acc.id, booking_date=m0 + timedelta(days=2),
amount=Decimal("-33.33"), purpose="SALDEN-TEST-3",
counterparty="X", status="confirmed", dedup_hash="salden-h3"),
])
db.commit()
client.post("/login", data={"username": "admin", "password": "geheim"})
# Stichtag zwischen Buchung 2 und Buchung 3: Saldo = Anker + Buchung1 + Buchung2.
stichtag = m1 + timedelta(days=10)
expected_stichtag = Decimal("1000.00") + Decimal("111.11") + Decimal("22.22")
r = client.get(f"/salden?stichtag={stichtag.isoformat()}")
assert r.status_code == 200
assert eur(expected_stichtag) in r.text
# Monatsuebersicht: genau 3 Monatszeilen (m2, m1, m0).
assert r.text.count('data-month="') == 3
for m in (m0, m1, m2):
assert f'data-month="{m.isoformat()}"' in r.text
# Monatsanfangs-Saldo fuer m1 = Stand Ende Vortag von m1 (letzter Tag von
# m2), d.h. NACH Buchung 1 (in m2), aber VOR Buchung 2 (die faellt erst
# in m1 selbst und darf den Monatsanfang nicht verfaelschen).
expected_m1_start = Decimal("1000.00") + Decimal("111.11")
assert eur(expected_m1_start) in r.text
def test_salden_stichtag_garbage_falls_back_to_today(client):
# Lenient wie die Filter auf /buchungen: ein kaputter stichtag-Parameter
# darf nie zu 422/500 fuehren, sondern degradiert auf "heute".
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/salden?stichtag=garbage")
assert r.status_code == 200
assert date.today().strftime('%d.%m.%Y') in r.text
def test_planung_hat_bearbeiten_formulare(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(RecurringItem(name="EditR", amount=Decimal("-5.00"), rhythm="monthly", due_day=1))
db.add(PlannedItem(name="EditP", amount=Decimal("-7.00"), due=date(2026, 8, 1)))
db.add(Loan(name="EditL", principal=Decimal("1000.00"),
annual_rate_pct=Decimal("4.50"), term_months=12,
payout_date=date(2026, 8, 1), repayment_type="annuity"))
db.commit()
r = client.get("/planung").text
assert r.count(">Bearbeiten") >= 3
for fragment in ('hx-patch="/api/recurring/', 'hx-patch="/api/planned/',
'hx-patch="/api/loans/'):
assert fragment in r, fragment
def test_szenarien_hat_bearbeiten_formular(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Scenario(name="EditS", description="d"))
db.commit()
r = client.get("/szenarien").text
assert r.count(">Bearbeiten") >= 1
assert 'hx-patch="/api/scenarios/' in r
def test_salden_shows_ohne_anker_footnote(client, db):
# UX-Regel: Konten ohne Anker sind immer sichtbar als relative Werte
# gekennzeichnet, nicht stillschweigend wie geankerte Konten dargestellt.
acc = Account(bank="vr", iban="DE-SALDEN-2", name="Ohne-Anker-Konto", type="giro")
db.add(acc)
db.commit()
client.post("/login", data={"username": "admin", "password": "geheim"})
r = client.get("/salden")
assert r.status_code == 200
assert "ohne Anker" in r.text and "relative Werte" in r.text
def test_szenario_gui_ende_und_einmalzahlungen(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="GUI-Ende", description="")
db.add(sc)
db.flush()
db.add(ScenarioModifier(scenario_id=sc.id, target_type="recurring",
target_id=1, kind="ende", value=Decimal("0"),
end_date=date(2026, 8, 31)))
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-GUI",
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
db.commit()
r = client.get("/szenarien").text
assert 'value="ende"' in r # neue Art im Dropdown
assert 'name="end_date"' in r # Datumsfeld im Modifikator-Formular
assert "31.08.2026" in r # ende-Modifikator zeigt Datum
assert "Einträge" in r
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
assert "Zufluss-GUI" in r and "5.000,00" in r and "30.07.2026" in r
def test_szenario_eintraege_tabelle(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="Eintraege-GUI", description="")
db.add(sc)
db.flush()
db.add(ScenarioModifier(scenario_id=sc.id, target_type="recurring",
target_id=1, kind="ende", value=Decimal("0"),
end_date=date(2026, 8, 31)))
db.add(ScenarioModifier(scenario_id=sc.id, target_type="category",
target_id=1, kind="percent", value=Decimal("25.00")))
db.add(ScenarioPlannedItem(scenario_id=sc.id, name="Zufluss-Eintrag",
amount=Decimal("5000.00"), due=date(2026, 7, 30)))
db.commit()
r = client.get("/szenarien").text
assert "Einträge" in r
# alte details-Abschnitte sind ersetzt
assert "Modifikatoren" not in r
assert "Einmalzahlungen in diesem Szenario" not in r
# gemischte Zeilen in EINER Tabelle
assert "31.08.2026" in r and "25,00" in r and "Zufluss-Eintrag" in r
assert ">Einmalzahlung<" in r
# Inline-Edit je Typ
assert f'hx-patch="/api/scenarios/{sc.id}/modifiers/' in r
assert f'hx-patch="/api/scenarios/{sc.id}/planned/' in r
# Anlege-Formular: Eintragsart inkl. Einmalzahlung + Endpunkt-Wechsel-Attribute
assert 'value="einmal"' in r
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
assert "onEntryArtChange" in r and "onModKindChange" not in r
def test_szenarien_seite_und_nav(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Scenario(name="Seiten-Test", description="d"))
db.commit()
r = client.get("/szenarien")
assert r.status_code == 200
assert "Seiten-Test" in r.text and "Neues Szenario anlegen" in r.text
assert 'href="/szenarien"' in r.text # Nav-Punkt
# Planung enthaelt keine Szenarien-Sektion mehr:
p = client.get("/planung").text
assert "Seiten-Test" not in p and "Neues Szenario anlegen" not in p
assert "Wiederkehrende Posten" in p and "Kredite" in p
def test_szenarien_seite_braucht_login(client):
r = client.get("/szenarien", follow_redirects=False)
assert r.status_code in (302, 303) and r.headers["location"] == "/login"
def test_neuer_eintrag_formular_struktur(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Scenario(name="UX-Test", description=""))
db.commit()
r = client.get("/szenarien").text
assert 'class="entry-form"' in r
assert 'class="entry-art"' in r # Eintragsart-Zeile
assert 'class="value-label"' in r # dynamisches Wert-Label
assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern
assert '