feat: Die Buchungsliste laesst sich im Server durchsuchen und filtern

Drei neue Abfrageparameter an GET /api/transactions: q sucht in Empfaenger,
Verwendungszweck und Notiz, tag_id filtert auf ein Etikett, subscription_id
auf einen Vertrag. Bisher gab es das nur im Browser - die Weboberflaeche laedt
alle Buchungen und siebt sie dort. Am Telefon ist das der Unterschied
zwischen "laedt" und "benutzbar"; das Limit steht nicht umsonst auf 5000.

Zwei Dinge, die die Tests festhalten: Prozent- und Unterstrich-Zeichen im
Suchtext sind Zeichen, keine Platzhalter - sonst faende die Eingabe "50%"
alles. Und ein fremder tag_id liefert nichts statt alles, weil der Filter auf
TransactionTag.owner_id anschlaegt.

Der Tag-Filter nimmt eine Unterabfrage wie der uncategorized-Zweig daneben,
keinen Join: Ein Join gaebe jede Buchung so oft zurueck, wie sie Tags traegt.
Die lokale Abfragevariable hiess q und heisst jetzt abfrage, damit der
Parameter den Namen bekommen konnte.

Bekannte Grenze: SQLite gleicht bei LIKE nur ASCII gross/klein ab, "Mueller"
mit Umlaut findet also nicht jede Schreibweise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-10-21 20:10:21 +02:00
co-authored by Claude Opus 5
parent e2f4e755f1
commit 1649c84f6a
2 changed files with 166 additions and 11 deletions
+126
View File
@@ -0,0 +1,126 @@
"""Suche und Filter auf der Buchungsliste: ``q``, ``tag_id``, ``subscription_id``.
Bisher siebte die Weboberflaeche im Browser und lud dafuer alles, was es gab -
das Limit steht nicht umsonst auf 5000. Am Telefon ist das der Unterschied
zwischen "laedt" und "benutzbar", deshalb sitzen die drei Filter jetzt im
Server.
Die Datenbank lebt ueber den ganzen Testlauf. Texte deshalb eindeutig
wuerfeln, sonst findet die Suche die Daten anderer Tests mit.
"""
import secrets
from conftest import login_ready, new_user
def _konto(admin):
return admin.post("/api/accounts", json={
"name": f"Konto {secrets.token_hex(3)}", "type": "girokonto",
"currency": "EUR", "opening_balance_cents": 0}).json()
def _buchung(admin, konto, **felder):
r = admin.post("/api/transactions", json={
"type": "expense", "date": "2026-09-15", "account_id": konto["id"],
"amount_cents": 100 + secrets.randbelow(9000), **felder})
assert r.status_code == 201, r.text
return r.json()
def _suche(admin, **filter_):
r = admin.get("/api/transactions", params=filter_)
assert r.status_code == 200, r.text
return [t["id"] for t in r.json()]
def test_suche_findet_empfaenger_verwendungszweck_und_notiz(admin):
konto = _konto(admin)
wort = f"Zebra{secrets.token_hex(4)}"
ueber_empfaenger = _buchung(admin, konto, payee=f"Laden {wort}")
ueber_zweck = _buchung(admin, konto, payee="Egal", description=f"Rechnung {wort}")
ueber_notiz = _buchung(admin, konto, payee="Egal", notes=f"wegen {wort}")
daneben = _buchung(admin, konto, payee="Etwas voellig anderes")
gefunden = _suche(admin, q=wort)
assert set(gefunden) == {ueber_empfaenger["id"], ueber_zweck["id"], ueber_notiz["id"]}
assert daneben["id"] not in gefunden
def test_suche_achtet_nicht_auf_gross_und_kleinschreibung(admin):
konto = _konto(admin)
wort = f"Kiosk{secrets.token_hex(4)}"
txn = _buchung(admin, konto, payee=wort.upper())
assert txn["id"] in _suche(admin, q=wort.lower())
def test_prozentzeichen_im_suchtext_bleibt_ein_zeichen(admin):
"""Sonst waere jede Eingabe mit % ein Platzhalter und faende alles."""
konto = _konto(admin)
marke = secrets.token_hex(4)
mit_prozent = _buchung(admin, konto, payee=f"Rabatt%{marke}")
ohne = _buchung(admin, konto, payee=f"Rabatt {marke}")
gefunden = _suche(admin, q=f"Rabatt%{marke}")
assert gefunden == [mit_prozent["id"]]
assert ohne["id"] not in gefunden
def test_tag_filter_zeigt_nur_buchungen_mit_diesem_tag(admin):
konto = _konto(admin)
tag = admin.post("/api/tags", json={
"name": f"Tag {secrets.token_hex(4)}", "color": "#3f51b5"}).json()
mit = _buchung(admin, konto, payee="Mit Etikett")
ohne = _buchung(admin, konto, payee="Ohne Etikett")
admin.patch(f"/api/transactions/{mit['id']}", json={"tag_ids": [tag["id"]]})
gefunden = _suche(admin, tag_id=tag["id"])
assert gefunden == [mit["id"]]
assert ohne["id"] not in gefunden
def test_tag_eines_fremden_nutzers_zeigt_keine_buchungen(admin):
"""Mandanten-Isolation: Eine geratene fremde ID darf nichts aufschliessen."""
konto = _konto(admin)
meine = _buchung(admin, konto, payee="Meine Buchung")
name, passwort = new_user(admin)
fremd, _ = login_ready(name, passwort)
fremder_tag = fremd.post("/api/tags", json={
"name": f"Fremd {secrets.token_hex(4)}", "color": "#eb6834"}).json()
gefunden = _suche(admin, tag_id=fremder_tag["id"])
assert gefunden == []
assert meine["id"] not in gefunden
def test_abo_filter_zeigt_nur_buchungen_dieses_vertrags(admin):
konto = _konto(admin)
abo = admin.post("/api/subscriptions", json={
"name": f"Dienst {secrets.token_hex(4)}", "amount_cents": 999,
"currency": "EUR", "interval_months": 1}).json()
zugeordnet = _buchung(admin, konto, payee="Monatsbeitrag")
frei = _buchung(admin, konto, payee="Einmalkauf")
r = admin.post(f"/api/subscriptions/{abo['id']}/buchungen/{zugeordnet['id']}")
assert r.status_code == 200, r.text
gefunden = _suche(admin, subscription_id=abo["id"])
assert gefunden == [zugeordnet["id"]]
assert frei["id"] not in gefunden
def test_suche_und_tag_filter_greifen_zusammen(admin):
konto = _konto(admin)
wort = f"Baumarkt{secrets.token_hex(4)}"
tag = admin.post("/api/tags", json={
"name": f"Tag {secrets.token_hex(4)}", "color": "#2a78d6"}).json()
beides = _buchung(admin, konto, payee=f"{wort} Farbe")
nur_text = _buchung(admin, konto, payee=f"{wort} Schrauben")
nur_tag = _buchung(admin, konto, payee="Etwas anderes")
for txn in (beides, nur_tag):
admin.patch(f"/api/transactions/{txn['id']}", json={"tag_ids": [tag["id"]]})
gefunden = _suche(admin, q=wort, tag_id=tag["id"])
assert gefunden == [beides["id"]]
assert nur_text["id"] not in gefunden and nur_tag["id"] not in gefunden