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 '