Files
bin/finance/tests/test_gui.py
T

718 lines
31 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.
import re
from datetime import date, timedelta
from decimal import Decimal
from app.config import get_settings
from app.formats import eur
from app.models.tables import (Account, Category, Loan, PlannedItem, RecurringItem,
Scenario, ScenarioModifier, ScenarioPlannedItem,
ScenarioRecurringItem)
H = {"Authorization": "Bearer test-key"}
def test_pages_require_login(client):
for path in ("/", "/buchungen", "/import", "/salden", "/planung", "/szenarien", "/aufteilung", "/hilfe"):
r = client.get(path, follow_redirects=False)
assert r.status_code in (302, 303), path
assert r.headers["location"] == "/login"
def test_aufteilung_fragment_erfordert_login(client):
r = client.get("/aufteilung/kategorie", follow_redirects=False)
assert r.status_code in (302, 303)
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", "/aufteilung", "/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
# Punkt-Dezimal darf nur im maschinenlesbaren data-sort-Attribut
# (Ausbaustufe 11, static/sort.js) stehen, nicht im sichtbaren Text.
visible = re.sub(r' data-sort="[^"]*"', '', r.text)
assert "12345.60" not in visible
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 "<th>Start</th>" in r and "<th>Ende</th>" 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</button>") >= 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</button>") >= 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 "<summary>Modifikatoren</summary>" 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 "FBSzenario.aenderungArt" in r and "onEntryArtChange" 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 aenderung-form"' in r and 'class="entry-form zahlung-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 '<hr' in r # Durchrechnen abgesetzt
def test_grafana_links_use_public_url_when_set(client, monkeypatch):
client.post("/login", data={"username": "admin", "password": "geheim"})
monkeypatch.setenv("FB_GRAFANA_PUBLIC_URL", "https://fb.example.de/grafana/")
get_settings.cache_clear()
try:
r = client.get("/")
assert r.status_code == 200
# iframe + Anmelde-Link nutzen die öffentliche URL, kein :8097 mehr.
assert "https://fb.example.de/grafana/d/finanzen/finanzen" in r.text
assert ":8097" not in r.text
# Nav-Link im base-Template ebenfalls.
assert 'href="https://fb.example.de/grafana"' in r.text
finally:
get_settings.cache_clear()
def test_grafana_links_fallback_to_host_port_when_unset(client, monkeypatch):
client.post("/login", data={"username": "admin", "password": "geheim"})
monkeypatch.delenv("FB_GRAFANA_PUBLIC_URL", raising=False)
get_settings.cache_clear()
try:
r = client.get("/")
assert r.status_code == 200
assert ":8097/d/finanzen/finanzen" in r.text
assert "fb.example.de" not in r.text
finally:
get_settings.cache_clear()
def test_szenarien_seite_hat_loeschen_button(client, db):
from html.parser import HTMLParser
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="Wegwerf", description="", include_recurring=True,
include_planned=True)
db.add(sc)
db.commit()
r = client.get("/szenarien")
# Verify delete button is present
assert f'hx-delete="/api/scenarios/{sc.id}"' in r.text
# Verify the full confirmation text in the page (brief requirement)
assert 'Szenario „Wegwerf“ mit allen Einträgen wirklich löschen?' in r.text
# Parse HTML to verify the hx-confirm attribute has correct value with proper quotes
class ButtonParser(HTMLParser):
def __init__(self):
super().__init__()
self.hx_confirm_value = None
self.found_delete_button = False
def handle_starttag(self, tag, attrs):
if tag == "button":
attrs_dict = dict(attrs)
if attrs_dict.get("hx-delete") == f"/api/scenarios/{sc.id}":
self.found_delete_button = True
self.hx_confirm_value = attrs_dict.get("hx-confirm")
parser = ButtonParser()
parser.feed(r.text)
assert parser.found_delete_button, "Delete button not found"
# Verify the attribute value uses correct typographic quotes
expected_confirm = 'Szenario „Wegwerf“ mit allen Einträgen wirklich löschen?'
assert parser.hx_confirm_value == expected_confirm, \
f"hx-confirm attribute mismatch: got {repr(parser.hx_confirm_value)}, expected {repr(expected_confirm)}"
def test_szenario_loeschen_roundtrip_gui(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="Wegwerf-Roundtrip", description="", include_recurring=True,
include_planned=True)
db.add(sc)
db.commit()
assert client.delete(f"/api/scenarios/{sc.id}", headers=H).status_code == 204
assert "Wegwerf-Roundtrip" not in client.get("/szenarien").text
def test_szenarien_seite_rechnet_veraltete_projektion_neu(client, db):
from sqlalchemy import func, select
from app.models.tables import ProjectionPoint
from app.services.projection_service import run_projection
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="Veraltet", description="", include_recurring=False,
include_planned=False)
db.add(sc)
db.commit()
run_projection(db, sc, 10, date.today() - timedelta(days=3))
assert client.get("/szenarien").status_code == 200
first = db.execute(select(func.min(ProjectionPoint.day))
.where(ProjectionPoint.scenario_id == sc.id)).scalar()
assert first == date.today() + timedelta(days=1)
def test_refresh_fehler_bricht_seiten_nicht(client, monkeypatch):
def kaputt(session, today):
raise RuntimeError("absichtlich kaputt")
monkeypatch.setattr("app.routers.gui.refresh_stale_projections", kaputt)
client.post("/login", data={"username": "admin", "password": "geheim"})
for path in ("/", "/szenarien"):
assert client.get(path).status_code == 200, path
def test_v_projection_zeigt_ist_serie_und_keine_alten_punkte():
from app.models.views import RESERVED_SCENARIO_NAME, VIEWS
body = VIEWS["v_projection"]
assert f"'{RESERVED_SCENARIO_NAME}'" in body
assert "p.day > CURRENT_DATE" in body
names = list(VIEWS)
assert names.index("v_balance_total") < names.index("v_projection")
def test_planung_seite_befuellt_planungs_diagramm(client, db):
from app.models.tables import PlanningMonthly
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(PlannedItem(name="Kauf", amount=Decimal("-10.00"),
due=date.today() + timedelta(days=3)))
db.commit()
assert client.get("/planung").status_code == 200
assert db.query(PlanningMonthly).count() == 1
def test_planungs_refresh_fehler_bricht_seiten_nicht(client, monkeypatch):
def kaputt(session, today):
raise RuntimeError("absichtlich kaputt")
monkeypatch.setattr("app.routers.gui.refresh_planning_chart", kaputt)
client.post("/login", data={"username": "admin", "password": "geheim"})
for path in ("/", "/planung"):
assert client.get(path).status_code == 200, path
def test_base_hat_doppelzaehlungs_dialog(client):
client.post("/login", data={"username": "admin", "password": "geheim"})
html = client.get("/").text
from app.version import get_version
assert f'<script src="/static/doppelzaehlung.js?v={get_version()}"></script>' in html
assert '<dialog id="doppelzaehlung-dialog">' in html
assert 'id="dz-abbrechen" autofocus' in html
assert ">Trotzdem speichern</button>" in html
assert "FBDoppel.handle(e)" in html
assert "doppelzaehlung_bestaetigt = true" in html
def test_planung_zeigt_doppelzaehlungs_hinweis(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add_all([
Loan(name="Auto", principal=Decimal("1200.00"), annual_rate_pct=Decimal("0"),
term_months=12, payout_date=date(2026, 1, 15), repayment_type="annuity"),
RecurringItem(name="Rate Auto", amount=Decimal("-100.00"), rhythm="monthly", due_day=15),
RecurringItem(name="Miete", amount=Decimal("-600.00"), rhythm="monthly", due_day=1),
])
db.commit()
html = client.get("/planung").text
assert "⚠ zählt evtl. doppelt mit Kredit „Auto“" in html
assert "⚠ zählt evtl. doppelt mit Posten „Rate Auto“" in html
assert html.count("⚠ zählt evtl. doppelt") == 2
assert '<td data-sort="Rate Auto">' in html
assert '<td data-sort="Auto">' in html
def test_planung_ohne_konflikt_kein_hinweis(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(RecurringItem(name="Miete", amount=Decimal("-600.00"), rhythm="monthly", due_day=1))
db.commit()
assert "zählt evtl. doppelt" not in client.get("/planung").text
def test_vorschlag_formular_nutzt_json_form_post(client):
# Review Focus 5: „Vorschlag übernehmen" läuft über denselben Warnweg.
import re
from pathlib import Path
src = Path("app/templates/planning.html").read_text(encoding="utf-8")
assert re.search(r'class="inline-form" hx-ext="json-form" hx-post="/api/recurring"', src)
def test_statische_dateien_mit_versionskennung(client):
# Cache-Busting: jede neue VERSION erzwingt frische CSS/JS im Browser,
# sonst zeigen gecachte Altstände z. B. schwarze Tortensegmente.
from app.version import get_version
v = get_version()
login = client.get("/login").text
assert f'href="/static/style.css?v={v}"' in login
assert re.search(r'/static/[^"?]+"', login) is None
client.post("/login", data={"username": "admin", "password": "geheim"})
html = client.get("/").text
for datei in ("style.css", "htmx.min.js", "sort.js", "doppelzaehlung.js", "szenarien.js"):
assert f'/static/{datei}?v={v}"' in html
assert re.search(r'/static/[^"?]+"', html) is None
def test_szenario_zwei_formulare(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
sc = Scenario(name="Formulare-A14", description="")
db.add(sc)
db.commit()
r = client.get("/szenarien").text
assert "Neue Änderung an Bestehendem" in r and "Neue Zahlung nur in diesem Szenario" in r
assert 'class="entry-form aenderung-form"' in r and 'class="entry-form zahlung-form"' in r
assert f'hx-post="/api/scenarios/{sc.id}/modifiers"' in r
assert f'data-planned-url="/api/scenarios/{sc.id}/planned"' in r
assert f'data-recurring-url="/api/scenarios/{sc.id}/recurring"' in r
assert 'value="set">Neuer Betrag<' in r and 'value="absolute">Kürzung (€)<' in r
for feld in ('name="start_date"', 'name="end_date"', 'name="rhythm"',
'name="due_day"', 'name="category_id"', 'name="due"'):
assert feld in r, feld
assert 'class="zahlung-art"' in r and '<option value="">(keine)</option>' in r
assert 'class="bis-label"' in r
def test_szenario_eintraege_zeitraum_und_wiederkehrend(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
cat = Category(name="Kat-Anzeige")
sc = Scenario(name="Anzeige-A14", description="")
db.add_all([cat, sc])
db.flush()
db.add_all([
ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1,
kind="percent", value=Decimal("50"), start_date=date(2026, 10, 16)),
ScenarioModifier(scenario_id=sc.id, target_type="recurring", target_id=1,
kind="set", value=Decimal("-80.00"),
start_date=date(2026, 11, 1), end_date=date(2027, 3, 31)),
ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=cat.id,
kind="remove", value=Decimal("0"), end_date=date(2027, 6, 30)),
ScenarioRecurringItem(scenario_id=sc.id, name="Zahlung-W", amount=Decimal("-137.50"),
rhythm="monthly", due_day=14, start_date=date(2026, 10, 16),
category_id=cat.id),
ScenarioPlannedItem(scenario_id=sc.id, name="Zahlung-E", amount=Decimal("-5.00"),
due=date(2026, 12, 1), category_id=cat.id),
])
db.commit()
r = client.get("/szenarien").text
assert "<th>Zeitraum</th>" in r
assert "ab 16.10.2026" in r and "01.11.2026 – 31.03.2027" in r and "bis 30.06.2027" in r
assert ">Neuer Betrag<" in r and "-80,00" in r
assert "Zahlung-W" in r and "Wiederkehrend (monatlich, am 14.)" in r and "-137,50" in r
assert f'hx-patch="/api/scenarios/{sc.id}/recurring/' in r
assert f'hx-delete="/api/scenarios/{sc.id}/recurring/' in r
assert "Kat-Anzeige" in r and "Zahlung-E" in r
assert 'id="sri-row-' in r and 'id="sri-edit-' in r
def test_planung_laufzeit_felder_haben_max_1200(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Loan(name="MaxL", 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('max="1200"') >= 2