Regeln greifen beim Import. Alles, was vorher hereinkam – oder hereinkam, bevor es die Regel gab –, bleibt liegen. Bei einer Bankanbindung passiert genau das dauernd: Die Umsaetze kommen von selbst, die Regel entsteht spaeter. Die Revolut-Aufrundungen aufs Sparen-Pocket sind der Fall, an dem es aufgefallen ist. Der Knopf steht bei der Transfer-Erkennung und zeigt erst, was passieren wuerde. Uebertrags-Regeln laufen durch denselben Dienst wie jeder andere Uebertrag – er lehnt ab, wo die Gegenseite schon gebucht ist, und solche Buchungen bleiben unangetastet und werden mit Grund gemeldet. Ohne festes Zielkonto wird es aus dem Buchungstext erraten, aber nur bei GENAU einem Treffer und nur an Wortgrenzen: "Euro" darf nicht "PayPal Europe" treffen. Zwei Moeglichkeiten heisst raten, und dann passiert nichts. Angefasst werden nur Buchungen OHNE Kategorie – sonst ueberschriebe der Knopf stillschweigend, was von Hand zugeordnet wurde. Ein Uebertrag ist kein Kandidat mehr, ein zweiter Klick verschiebt das Geld also nicht erneut. Zwei Dinge fielen beim Testen auf und sind mit behoben: - Ein fehlgeschlagener Uebertrag verwarf per rollback() auch alles, was im selben Durchlauf schon gelungen war. Jetzt haelt ein Rettungspunkt je Uebertrag den Schaden bei dem einen. - test_neutral_category nahm VORHANDENE Kategorien und stellte eine davon auf "nicht einrechnen". Benutzten andere Buchungen sie, fielen die mit heraus und die Rechnung ging um deren Summe daneben – ohne Bezug zum Gepruefften. Der Test legt sich seine Kategorien jetzt selbst an. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
422 lines
19 KiB
Python
422 lines
19 KiB
Python
"""Schlagwörter je Kategorie: Anlegen/Konflikt, Treffer (Empfänger ODER Zweck),
|
||
längstes gewinnt, Kind-Filter, Korrektur-Erkennung/Flagging, keine Rückwirkung."""
|
||
|
||
import io
|
||
import json
|
||
import secrets
|
||
|
||
|
||
def _account(admin, **kw):
|
||
body = {"name": "Giro", "type": "girokonto", "currency": "EUR", "opening_balance_cents": 0}
|
||
body.update(kw)
|
||
r = admin.post("/api/accounts", json=body)
|
||
assert r.status_code == 201, r.text
|
||
return r.json()
|
||
|
||
|
||
def _expense_cats(admin, n=2):
|
||
out = []
|
||
for g in admin.get("/api/categories").json():
|
||
if g["kind"] == "expense":
|
||
out += g["categories"]
|
||
assert len(out) >= n, "zu wenige Ausgabe-Kategorien im Seed"
|
||
return out[:n]
|
||
|
||
|
||
def _income_cat(admin):
|
||
for g in admin.get("/api/categories").json():
|
||
if g["kind"] == "income" and g["categories"]:
|
||
return g["categories"][0]
|
||
raise AssertionError("keine Einnahme-Kategorie im Seed")
|
||
|
||
|
||
def _preview_csv(admin, account_id, csv, colmap):
|
||
r = admin.post(
|
||
"/api/imports/preview",
|
||
files={"file": ("k.csv", io.BytesIO(csv.encode("utf-8")), "text/csv")},
|
||
data={
|
||
"account_id": str(account_id), "delimiter": ";", "decimal_sep": ",",
|
||
"date_format": "%d.%m.%Y", "encoding": "utf-8", "has_header": "true",
|
||
"skip_rows": "0", "amount_mode": "signed", "column_map": colmap,
|
||
},
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
return r.json()["rows"]
|
||
|
||
|
||
COLMAP = json.dumps({"date": 0, "amount": 1, "payee": 2, "purpose": 3})
|
||
|
||
|
||
def test_keyword_crud_and_conflict(admin):
|
||
a, b = _expense_cats(admin, 2)
|
||
r = admin.post(f"/api/categories/{a['id']}/keywords", json={"word": " REWE "})
|
||
assert r.status_code == 201, r.text
|
||
assert r.json()["word"] == "rewe" # getrimmt + kleingeschrieben
|
||
kid = r.json()["id"]
|
||
|
||
assert [k["word"] for k in admin.get(f"/api/categories/{a['id']}/keywords").json()] == ["rewe"]
|
||
|
||
# Gleiches Wort, gleiche Kategorie -> idempotent, keine Dublette.
|
||
assert admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "rewe"}).status_code == 201
|
||
assert len(admin.get(f"/api/categories/{a['id']}/keywords").json()) == 1
|
||
|
||
# Gleiches Wort, andere Kategorie -> Konflikt.
|
||
assert admin.post(f"/api/categories/{b['id']}/keywords", json={"word": "rewe"}).status_code == 409
|
||
|
||
assert admin.delete(f"/api/categories/keywords/{kid}").status_code == 204
|
||
assert admin.get(f"/api/categories/{a['id']}/keywords").json() == []
|
||
|
||
|
||
def test_matching_payee_purpose_and_kind_filter(admin):
|
||
giro = _account(admin, name="Giro KW")
|
||
a, _ = _expense_cats(admin, 2)
|
||
admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "netflix"})
|
||
|
||
csv = (
|
||
"Datum;Betrag;Empfaenger;Zweck\n"
|
||
"01.03.2026;-9,99;NETFLIX;Abo\n" # Empfänger-Treffer
|
||
"02.03.2026;-9,99;Kartenzahlung;NETFLIX Monatsabo\n" # Zweck-Treffer (any)
|
||
"03.03.2026;9,99;NETFLIX;Erstattung\n" # Einnahme -> darf NICHT greifen
|
||
)
|
||
rows = _preview_csv(admin, giro["id"], csv, COLMAP)
|
||
assert rows[0]["category_id"] == a["id"]
|
||
assert rows[0]["matched_rule_id"] is not None
|
||
assert rows[1]["category_id"] == a["id"] # über den Zweck getroffen
|
||
assert rows[2]["category_id"] is None # Ausgabe-Schlagwort, Einnahme -> gefiltert
|
||
|
||
|
||
def test_longest_keyword_wins(admin):
|
||
giro = _account(admin, name="Giro LW")
|
||
a, b = _expense_cats(admin, 2)
|
||
admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "rewe"})
|
||
admin.post(f"/api/categories/{b['id']}/keywords", json={"word": "rewe getraenke"})
|
||
|
||
csv = "Datum;Betrag;Empfaenger;Zweck\n01.03.2026;-20,00;REWE Getraenke Markt;Wasser\n"
|
||
rows = _preview_csv(admin, giro["id"], csv, COLMAP)
|
||
assert rows[0]["category_id"] == b["id"] # spezifischeres (längeres) Schlagwort gewinnt
|
||
|
||
|
||
def test_correction_flagging_confirm(admin):
|
||
giro = _account(admin, name="Giro Flag")
|
||
a, b = _expense_cats(admin, 2)
|
||
kw = admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "spotify"}).json()
|
||
|
||
lines = "".join(f"0{i}.03.2026;-9,99;SPOTIFY;Abo\n" for i in range(1, 7)) # 6 Zeilen
|
||
rows = _preview_csv(admin, giro["id"], "Datum;Betrag;Empfaenger;Zweck\n" + lines, COLMAP)
|
||
assert len(rows) == 6 and all(r["category_id"] == a["id"] for r in rows)
|
||
|
||
for r in rows[:2]: # zwei wegkorrigieren
|
||
r["category_id"] = b["id"]
|
||
res = admin.post("/api/imports/commit", json={"account_id": giro["id"], "filename": "s.csv", "rows": rows})
|
||
assert res.status_code == 200, res.text
|
||
|
||
flagged = admin.get("/api/categories/keywords/flagged").json()
|
||
hit = next((f for f in flagged if f["id"] == kw["id"]), None)
|
||
assert hit is not None, flagged
|
||
assert hit["applied_count"] == 6 and hit["overridden_count"] == 2
|
||
|
||
# „Behalten" unterdrückt die Meldung künftig.
|
||
assert admin.post(f"/api/categories/keywords/{kw['id']}/confirm").status_code == 204
|
||
assert kw["id"] not in [f["id"] for f in admin.get("/api/categories/keywords/flagged").json()]
|
||
|
||
|
||
def test_keyword_update_and_conflict(admin):
|
||
a, b = _expense_cats(admin, 2)
|
||
kw = admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "lidil"}).json()
|
||
|
||
r = admin.patch(f"/api/categories/keywords/{kw['id']}", json={"word": " LIDL "})
|
||
assert r.status_code == 200, r.text
|
||
assert r.json()["word"] == "lidl" # getrimmt + kleingeschrieben
|
||
words = admin.get(f"/api/categories/{a['id']}/keywords").json()
|
||
assert next(k for k in words if k["id"] == kw["id"])["word"] == "lidl"
|
||
assert "lidil" not in [k["word"] for k in words] # altes Wort ist weg
|
||
|
||
# Umbenennen auf ein Wort, das schon einer anderen Kategorie gehört -> Konflikt.
|
||
admin.post(f"/api/categories/{b['id']}/keywords", json={"word": "coop"})
|
||
assert admin.patch(f"/api/categories/keywords/{kw['id']}", json={"word": "coop"}).status_code == 409
|
||
|
||
|
||
def test_keyword_count_in_category_list(admin):
|
||
"""Die Übersicht markiert Kategorien mit Schlagwörtern – dafür die Anzahl.
|
||
|
||
Gemessen wird relativ: die Testdatenbank lebt über den ganzen Lauf, in den
|
||
Kategorien liegen also schon Wörter aus vorherigen Tests."""
|
||
a, b = _expense_cats(admin, 2)
|
||
|
||
def anzahl(cat_id):
|
||
for g in admin.get("/api/categories").json():
|
||
for c in g["categories"]:
|
||
if c["id"] == cat_id:
|
||
return c["keyword_count"]
|
||
raise AssertionError("Kategorie nicht gefunden")
|
||
|
||
vorher_a, vorher_b = anzahl(a["id"]), anzahl(b["id"])
|
||
# Gegenprobe: die Zahl entspricht dem, was der Einzelabruf liefert.
|
||
assert vorher_a == len(admin.get(f"/api/categories/{a['id']}/keywords").json())
|
||
|
||
kw = admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "zaehltest-eins"}).json()
|
||
admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "zaehltest-zwei"})
|
||
assert anzahl(a["id"]) == vorher_a + 2
|
||
assert anzahl(b["id"]) == vorher_b # andere Kategorie bleibt unberührt
|
||
|
||
admin.delete(f"/api/categories/keywords/{kw['id']}")
|
||
assert anzahl(a["id"]) == vorher_a + 1
|
||
|
||
|
||
def test_keyword_update_amount(admin):
|
||
"""Wort und Betrag gemeinsam ändern – das Popup lädt beides in die Zeile."""
|
||
(a,) = _expense_cats(admin, 1)
|
||
kw = admin.post(
|
||
f"/api/categories/{a['id']}/keywords", json={"word": "sbb", "amount_cents": 500}
|
||
).json()
|
||
assert kw["amount_cents"] == 500
|
||
|
||
r = admin.patch(
|
||
f"/api/categories/keywords/{kw['id']}", json={"word": "sbb", "amount_cents": 750}
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
assert r.json()["amount_cents"] == 750
|
||
|
||
# Nur das Wort schicken lässt den Betrag stehen.
|
||
r = admin.patch(f"/api/categories/keywords/{kw['id']}", json={"word": "sbb cff"})
|
||
assert r.status_code == 200, r.text
|
||
assert r.json()["amount_cents"] == 750
|
||
|
||
# Ausdrücklich null löscht ihn (geleertes Betragsfeld im Popup).
|
||
r = admin.patch(
|
||
f"/api/categories/keywords/{kw['id']}", json={"word": "sbb cff", "amount_cents": None}
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
assert r.json()["amount_cents"] is None
|
||
gespeichert = admin.get(f"/api/categories/{a['id']}/keywords").json()
|
||
assert next(k for k in gespeichert if k["id"] == kw["id"])["amount_cents"] is None
|
||
|
||
# Null ist erlaubt, null-als-Betrag nicht.
|
||
r = admin.patch(
|
||
f"/api/categories/keywords/{kw['id']}", json={"word": "sbb cff", "amount_cents": 0}
|
||
)
|
||
assert r.status_code == 422
|
||
|
||
|
||
def test_below_threshold_not_flagged(admin):
|
||
giro = _account(admin, name="Giro NoFlag")
|
||
a, b = _expense_cats(admin, 2)
|
||
kw = admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "denner"}).json()
|
||
|
||
lines = "".join(f"0{i}.03.2026;-4,00;DENNER;Einkauf\n" for i in range(1, 7)) # 6 Zeilen
|
||
rows = _preview_csv(admin, giro["id"], "Datum;Betrag;Empfaenger;Zweck\n" + lines, COLMAP)
|
||
rows[0]["category_id"] = b["id"] # nur 1 von 6 korrigiert (< 1/3)
|
||
admin.post("/api/imports/commit", json={"account_id": giro["id"], "filename": "d.csv", "rows": rows})
|
||
|
||
assert kw["id"] not in [f["id"] for f in admin.get("/api/categories/keywords/flagged").json()]
|
||
|
||
|
||
def test_amount_condition_rule(admin):
|
||
giro = _account(admin, name="Giro Amt")
|
||
a, _ = _expense_cats(admin, 2)
|
||
# „sv" nur bei genau 5.00 -> Kategorie a
|
||
r = admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "sv", "amount_cents": 500})
|
||
assert r.status_code == 201, r.text
|
||
assert r.json()["amount_cents"] == 500
|
||
|
||
csv = (
|
||
"Datum;Betrag;Empfaenger;Zweck\n"
|
||
"01.03.2026;-5,00;SV Restaurant;Mittag\n" # 5.00 -> a
|
||
"02.03.2026;-8,00;SV Restaurant;Produkt\n" # anderer Betrag -> keine Regel
|
||
)
|
||
rows = _preview_csv(admin, giro["id"], csv, COLMAP)
|
||
assert rows[0]["category_id"] == a["id"]
|
||
assert rows[1]["category_id"] is None
|
||
|
||
|
||
def test_import_verbindet_keine_konten_mehr(admin):
|
||
"""Der Import erzeugt keine Überträge – weder über Schlagwort noch über
|
||
Erkennungs-Text, weder ausgehend noch eingehend.
|
||
|
||
Das Raten des Gegenkontos hat mehr kaputt gemacht als geholfen: falsch
|
||
geratene Ziele, verdrehte Richtungen und doppelt gebuchte Bewegungen,
|
||
sobald beide Auszüge eingelesen wurden. Jede Zeile ist jetzt schlicht das,
|
||
was im Auszug steht.
|
||
"""
|
||
giro = _account(admin, name="Giro TR")
|
||
spar = _account(admin, name="Sparkonto TR", type="sparkonto",
|
||
identifiers=[{"kind": "text", "value": "Sparen"}])
|
||
admin.post("/api/categories/transfer-keywords", json={"word": "uebertrag"})
|
||
admin.post("/api/categories/transfer-keywords",
|
||
json={"word": "revolut", "target_account_id": spar["id"]})
|
||
|
||
csv = (
|
||
"Datum;Betrag;Empfaenger;Zweck\n"
|
||
"01.03.2026;-100,00;Max;Uebertrag Sparen\n" # Schlagwort + Erkennungs-Text
|
||
"02.03.2026;-25,00;Einkauf REVOLUT;Karte\n" # Schlagwort mit festem Ziel
|
||
"03.03.2026;120,00;Gutschrift;von Sparen\n" # Eingang mit Erkennungs-Text
|
||
)
|
||
rows = _preview_csv(admin, giro["id"], csv, COLMAP)
|
||
assert [r["type"] for r in rows] == ["expense", "expense", "income"]
|
||
assert all(r["to_account_id"] is None for r in rows)
|
||
assert all(r["type"] != "transfer" for r in rows)
|
||
|
||
|
||
def test_beide_auszuege_werden_beide_gebucht(admin):
|
||
"""Lies man beide Seiten einer Bewegung ein, zählt jede auf ihrem Konto.
|
||
|
||
Früher galt die zweite Zeile als Gegenbuchung und blieb ungebucht – was
|
||
schiefging, sobald die Erkennung danebenlag. Jetzt bucht jedes Konto das,
|
||
was in seinem eigenen Auszug steht, und beide Salden stimmen.
|
||
"""
|
||
a = _account(admin, name="Konto A CP", opening_balance_cents=100000,
|
||
identifiers=[{"kind": "text", "value": "KontoA"}])
|
||
b = _account(admin, name="Konto B CP", identifiers=[{"kind": "text", "value": "KontoB"}])
|
||
|
||
rowsA = _preview_csv(admin, a["id"],
|
||
"Datum;Betrag;Empfaenger;Zweck\n05.03.2026;-100,00;Uebertrag;an KontoB\n", COLMAP)
|
||
assert rowsA[0]["type"] == "expense"
|
||
r = admin.post("/api/imports/commit", json={"account_id": a["id"], "filename": "a.csv", "rows": rowsA})
|
||
assert r.json()["imported_count"] == 1
|
||
|
||
rowsB = _preview_csv(admin, b["id"],
|
||
"Datum;Betrag;Empfaenger;Zweck\n07.03.2026;100,00;Gutschrift;von KontoA\n", COLMAP)
|
||
assert rowsB[0]["type"] == "income"
|
||
assert rowsB[0]["type"] != "transfer"
|
||
assert rowsB[0]["include"] is True
|
||
assert rowsB[0]["include"] is True
|
||
r = admin.post("/api/imports/commit", json={"account_id": b["id"], "filename": "b.csv", "rows": rowsB})
|
||
assert r.json()["imported_count"] == 1
|
||
|
||
assert admin.get(f"/api/accounts/{a['id']}").json()["balance_cents"] == 90000
|
||
assert admin.get(f"/api/accounts/{b['id']}").json()["balance_cents"] == 10000
|
||
|
||
|
||
def test_same_word_across_kinds_allowed(admin):
|
||
a, b = _expense_cats(admin, 2)
|
||
inc = _income_cat(admin)
|
||
# Dasselbe Wort für eine AUSGABE- und eine EINNAHME-Kategorie -> beides erlaubt.
|
||
r1 = admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "querart"})
|
||
assert r1.status_code == 201, r1.text
|
||
r2 = admin.post(f"/api/categories/{inc['id']}/keywords", json={"word": "querart"})
|
||
assert r2.status_code == 201, r2.text
|
||
assert r1.json()["id"] != r2.json()["id"]
|
||
# ... und auch als Transfer-Regel (dritte Art) erlaubt.
|
||
assert admin.post("/api/categories/transfer-keywords", json={"word": "querart"}).status_code == 201
|
||
# Aber ein zweites Ausgabe-Wort gleicher Art bleibt ein Konflikt.
|
||
assert admin.post(f"/api/categories/{b['id']}/keywords", json={"word": "querart"}).status_code == 409
|
||
|
||
|
||
def test_no_retroactive_apply(admin):
|
||
giro = _account(admin, name="Giro Retro")
|
||
a, _ = _expense_cats(admin, 2)
|
||
rows = _preview_csv(admin, giro["id"], "Datum;Betrag;Empfaenger;Zweck\n01.03.2026;-5,00;ALDI;Einkauf\n", COLMAP)
|
||
assert rows[0]["category_id"] is None
|
||
admin.post("/api/imports/commit", json={"account_id": giro["id"], "filename": "aldi.csv", "rows": rows})
|
||
|
||
admin.post(f"/api/categories/{a['id']}/keywords", json={"word": "aldi"}) # erst danach angelegt
|
||
|
||
txns = admin.get("/api/transactions").json()
|
||
aldi = [t for t in txns if (t.get("payee") or "").upper() == "ALDI"]
|
||
assert aldi and aldi[0]["category_id"] is None # bestehende Buchung bleibt unkategorisiert
|
||
|
||
|
||
def test_same_word_multiple_amounts(admin):
|
||
"""Ein Wort darf mehrere Preise haben – im Personalrestaurant zahlt man mal
|
||
5.00, mal 7.50. Ohne Betrag bleibt es der Auffang für alle übrigen."""
|
||
giro = _account(admin, name="Giro Preise")
|
||
mensa, buero = _expense_cats(admin, 2)
|
||
|
||
# Zwei Preise fürs Restaurant …
|
||
for cents in (500, 750):
|
||
r = admin.post(f"/api/categories/{mensa['id']}/keywords",
|
||
json={"word": "sv gruppe", "amount_cents": cents})
|
||
assert r.status_code == 201, r.text
|
||
# … und derselbe Text ohne Betrag als Auffang in einer anderen Kategorie.
|
||
r = admin.post(f"/api/categories/{buero['id']}/keywords", json={"word": "sv gruppe"})
|
||
assert r.status_code == 201, r.text
|
||
|
||
worte = admin.get(f"/api/categories/{mensa['id']}/keywords").json()
|
||
betraege = sorted(w["amount_cents"] for w in worte if w["word"] == "sv gruppe")
|
||
assert betraege == [500, 750]
|
||
|
||
csv = (
|
||
"Datum;Betrag;Empfaenger;Zweck\n"
|
||
"01.03.2026;-5,00;SV GRUPPE;Mittag\n"
|
||
"02.03.2026;-7,50;SV GRUPPE;Mittag gross\n"
|
||
"03.03.2026;-24,00;SV GRUPPE;Material\n"
|
||
)
|
||
rows = _preview_csv(admin, giro["id"], csv, COLMAP)
|
||
assert rows[0]["category_id"] == mensa["id"], "5.00 -> Restaurant"
|
||
assert rows[1]["category_id"] == mensa["id"], "7.50 -> Restaurant"
|
||
assert rows[2]["category_id"] == buero["id"], "anderer Betrag -> Auffang"
|
||
|
||
# Derselbe Preis zweimal bleibt ein Konflikt bzw. liefert dieselbe Regel.
|
||
doppelt = admin.post(f"/api/categories/{mensa['id']}/keywords",
|
||
json={"word": "sv gruppe", "amount_cents": 500})
|
||
assert doppelt.status_code == 201
|
||
assert len(admin.get(f"/api/categories/{mensa['id']}/keywords").json()) == len(worte)
|
||
|
||
|
||
def _kto(admin, **kw):
|
||
body = {"name": f"K {secrets.token_hex(3)}", "type": "girokonto",
|
||
"currency": "CHF", "opening_balance_cents": 0}
|
||
body.update(kw)
|
||
return admin.post("/api/accounts", json=body).json()
|
||
|
||
|
||
def test_regeln_lassen_sich_nachtraeglich_anwenden(admin):
|
||
"""Regeln greifen sonst nur beim Import. Bei einer Bankanbindung kommen die
|
||
Umsaetze von selbst und die Regel entsteht spaeter – dann bleibt alles
|
||
liegen, was vorher schon da war."""
|
||
quelle = _kto(admin, name=f"Revolut {secrets.token_hex(3)}")
|
||
pocket = _kto(admin, name=f"Sparen Pocket {secrets.token_hex(3)}")
|
||
kennung = f"to chf mb:{secrets.token_hex(6)}"
|
||
|
||
# Drei Aufrundungen, wie Revolut sie liefert – ohne Kategorie.
|
||
ids = []
|
||
for cent in (100, 50, 34):
|
||
ids.append(admin.post("/api/transactions", json={
|
||
"type": "expense", "date": "2026-08-25", "account_id": quelle["id"],
|
||
"amount_cents": cent, "payee": kennung.upper()}).json()["id"])
|
||
|
||
r = admin.post("/api/categories/transfer-keywords", json={
|
||
"word": kennung, "target_account_id": pocket["id"]})
|
||
assert r.status_code == 201, r.text
|
||
|
||
# Erst zeigen, nichts tun. Die Zahlen gelten fuer ALLE offenen Buchungen
|
||
# des Nutzers, deshalb wird hier nur geprueft, dass nichts passiert ist.
|
||
vor = admin.post("/api/categories/keywords/anwenden", params={"vorschau": True}).json()
|
||
assert vor["uebertraege"] >= 3
|
||
assert admin.get(f"/api/accounts/{pocket['id']}").json()["balance_cents"] == 0
|
||
for txn_id in ids:
|
||
assert admin.get(f"/api/transactions/{txn_id}").json()["type"] == "expense"
|
||
|
||
# Dann wirklich.
|
||
admin.post("/api/categories/keywords/anwenden", params={"vorschau": False})
|
||
assert admin.get(f"/api/accounts/{pocket['id']}").json()["balance_cents"] == 184
|
||
assert admin.get(f"/api/accounts/{quelle['id']}").json()["balance_cents"] == -184
|
||
for txn_id in ids:
|
||
assert admin.get(f"/api/transactions/{txn_id}").json()["type"] == "transfer"
|
||
|
||
# Ein zweiter Lauf ruehrt sie nicht noch einmal an: Ein Uebertrag ist kein
|
||
# Kandidat mehr, sonst verschoebe jeder Klick das Geld erneut.
|
||
admin.post("/api/categories/keywords/anwenden", params={"vorschau": False})
|
||
assert admin.get(f"/api/accounts/{pocket['id']}").json()["balance_cents"] == 184
|
||
|
||
|
||
def test_nachtraegliches_anwenden_ruehrt_kategorisiertes_nicht_an(admin):
|
||
"""Nur Buchungen OHNE Kategorie – sonst überschriebe der Knopf stillschweigend
|
||
das, was man von Hand zugeordnet hat."""
|
||
konto = _kto(admin)
|
||
gruppen = admin.get("/api/categories").json()
|
||
kat = next(c["id"] for g in gruppen if g["kind"] == "expense"
|
||
for c in g["categories"])
|
||
wort = f"marke{secrets.token_hex(4)}"
|
||
txn = admin.post("/api/transactions", json={
|
||
"type": "expense", "date": "2026-08-25", "account_id": konto["id"],
|
||
"amount_cents": 999, "payee": wort, "category_id": kat}).json()
|
||
|
||
andere = next(c["id"] for g in gruppen if g["kind"] == "expense"
|
||
for c in g["categories"] if c["id"] != kat)
|
||
admin.post(f"/api/categories/{andere}/keywords",
|
||
json={"pattern": wort, "match_field": "any"})
|
||
|
||
admin.post("/api/categories/keywords/anwenden", params={"vorschau": False})
|
||
assert admin.get(f"/api/transactions/{txn['id']}").json()["category_id"] == kat
|