Die Testdatenbank lebt ueber den ganzen Lauf und Schlagwoerter sind je Nutzer eindeutig; "coop" legt bereits ein anderer Test an. Der Test lief allein durch und fiel im Verbund um. Jetzt mit einem zufaelligen Wort. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
250 lines
9.9 KiB
Python
250 lines
9.9 KiB
Python
"""Schnellerfassung per Gerätemarke (iOS-Kurzbefehl)."""
|
||
|
||
import secrets
|
||
|
||
from fastapi.testclient import TestClient
|
||
|
||
|
||
def _fremd():
|
||
"""Client OHNE Sitzung. Die Fixture ``client`` ist derselbe Client wie
|
||
``admin`` und traegt dessen Anmeldung – damit liesse sich nicht pruefen,
|
||
ob eine Marke fuer sich allein etwas darf."""
|
||
from app.main import app
|
||
|
||
return TestClient(app)
|
||
|
||
|
||
def _konto(admin, **kw):
|
||
body = {"name": "Girokonto", "type": "girokonto", "currency": "CHF",
|
||
"opening_balance_cents": 100000}
|
||
body.update(kw)
|
||
r = admin.post("/api/accounts", json=body)
|
||
assert r.status_code == 201, r.text
|
||
return r.json()
|
||
|
||
|
||
def _marke(admin, konto_id=None, name="iPhone"):
|
||
r = admin.post("/api/inbox/tokens", json={"name": name, "default_account_id": konto_id})
|
||
assert r.status_code == 201, r.text
|
||
return r.json()
|
||
|
||
|
||
def _kategorie(admin, name):
|
||
for g in admin.get("/api/categories").json():
|
||
for c in g["categories"]:
|
||
if c["name"] == name:
|
||
return c["id"]
|
||
raise AssertionError(f"{name} fehlt")
|
||
|
||
|
||
def test_marke_bucht_und_taucht_im_eingang_auf(admin):
|
||
client = _fremd()
|
||
konto = _konto(admin)
|
||
marke = _marke(admin, konto["id"])
|
||
|
||
r = client.post("/api/quick/booking", json={"amount": "12.50", "payee": "Kantine SV"},
|
||
headers={"X-Certain-Token": marke["token"]})
|
||
assert r.status_code == 201, r.text
|
||
assert "12.50 CHF" in r.json()["message"]
|
||
|
||
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000 - 1250
|
||
meine = [e for e in admin.get("/api/inbox").json() if e["payee"] == "Kantine SV"]
|
||
assert len(meine) == 1
|
||
assert meine[0]["status"] == "booked"
|
||
|
||
|
||
def test_marke_darf_nur_erfassen_nichts_lesen(admin):
|
||
"""Die Marke liegt auf dem Telefon. Sie darf keine Daten herausgeben."""
|
||
client = _fremd()
|
||
konto = _konto(admin)
|
||
marke = _marke(admin, konto["id"])
|
||
kopf = {"X-Certain-Token": marke["token"]}
|
||
|
||
for pfad in ("/api/transactions", "/api/accounts", "/api/inbox", "/api/export/all.json", "/api/backup/db", "/api/categories"):
|
||
r = client.get(pfad, headers=kopf)
|
||
assert r.status_code == 401, f"{pfad} gab {r.status_code} statt 401"
|
||
|
||
|
||
def test_unbekannte_oder_fehlende_marke_wird_abgewiesen(admin):
|
||
client = _fremd()
|
||
_konto(admin)
|
||
assert client.post("/api/quick/booking", json={"amount": "5"}).status_code == 401
|
||
r = client.post("/api/quick/booking", json={"amount": "5"},
|
||
headers={"X-Certain-Token": "ausgedacht"})
|
||
assert r.status_code == 401
|
||
|
||
|
||
def test_widerrufene_marke_bucht_nicht_mehr(admin):
|
||
client = _fremd()
|
||
konto = _konto(admin)
|
||
marke = _marke(admin, konto["id"])
|
||
kopf = {"X-Certain-Token": marke["token"]}
|
||
assert client.post("/api/quick/booking", json={"amount": "1"}, headers=kopf).status_code == 201
|
||
|
||
assert admin.delete(f"/api/inbox/tokens/{marke['id']}").status_code == 204
|
||
assert client.post("/api/quick/booking", json={"amount": "1"}, headers=kopf).status_code == 401
|
||
|
||
|
||
def test_klartext_der_marke_ist_nur_einmal_zu_sehen(admin):
|
||
konto = _konto(admin)
|
||
marke = _marke(admin, konto["id"])
|
||
assert marke["token"]
|
||
liste = admin.get("/api/inbox/tokens").json()
|
||
meine = [t for t in liste if t["id"] == marke["id"]]
|
||
assert len(meine) == 1
|
||
assert "token" not in meine[0], "Der Klartext darf nie wieder ausgeliefert werden"
|
||
|
||
|
||
def test_doppelt_ausgeloester_kurzbefehl_bucht_nur_einmal(admin):
|
||
"""Schlechtes Netz oder Doppeltipp dürfen nicht zweimal abbuchen."""
|
||
client = _fremd()
|
||
konto = _konto(admin)
|
||
marke = _marke(admin, konto["id"])
|
||
kopf = {"X-Certain-Token": marke["token"]}
|
||
daten = {"amount": "9.90", "payee": "Kiosk", "external_id": "abc-123"}
|
||
|
||
a = client.post("/api/quick/booking", json=daten, headers=kopf)
|
||
b = client.post("/api/quick/booking", json=daten, headers=kopf)
|
||
assert a.status_code == 201 and b.status_code == 201
|
||
assert b.json()["message"] == "Bereits erfasst"
|
||
assert a.json()["item"]["id"] == b.json()["item"]["id"]
|
||
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000 - 990
|
||
|
||
|
||
def test_schlagwort_schlaegt_die_kategorie_vor(admin):
|
||
"""Was der CSV-Import kann, kann der Kurzbefehl auch."""
|
||
client = _fremd()
|
||
konto = _konto(admin)
|
||
leb = _kategorie(admin, "Lebensmittel")
|
||
# Eindeutiges Wort: Die Testdatenbank lebt über den ganzen Lauf, und
|
||
# Schlagwörter sind je Nutzer eindeutig.
|
||
wort = "laden" + secrets.token_hex(3)
|
||
assert admin.post(f"/api/categories/{leb}/keywords", json={"word": wort}).status_code == 201
|
||
|
||
marke = _marke(admin, konto["id"])
|
||
r = client.post("/api/quick/booking",
|
||
json={"amount": "23.40", "payee": f"{wort.upper()} Bahnhof"},
|
||
headers={"X-Certain-Token": marke["token"]})
|
||
assert r.status_code == 201, r.text
|
||
assert r.json()["item"]["category_id"] == leb
|
||
|
||
|
||
def test_einnahme_und_fremdes_konto(admin):
|
||
client = _fremd()
|
||
konto = _konto(admin)
|
||
zweites = _konto(admin, name="Sparkonto", opening_balance_cents=0)
|
||
marke = _marke(admin, konto["id"])
|
||
kopf = {"X-Certain-Token": marke["token"]}
|
||
|
||
r = client.post("/api/quick/booking",
|
||
json={"amount": "50", "kind": "income", "account_id": zweites["id"]},
|
||
headers=kopf)
|
||
assert r.status_code == 201, r.text
|
||
assert admin.get(f"/api/accounts/{zweites['id']}").json()["balance_cents"] == 5000
|
||
# Das Standardkonto bleibt unberührt.
|
||
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000
|
||
|
||
|
||
def test_verwerfen_loescht_die_buchung_behaelt_aber_die_spur(admin):
|
||
client = _fremd()
|
||
konto = _konto(admin)
|
||
marke = _marke(admin, konto["id"])
|
||
client.post("/api/quick/booking", json={"amount": "7.00", "payee": "Irrtum"},
|
||
headers={"X-Certain-Token": marke["token"]})
|
||
eintrag = admin.get("/api/inbox").json()[0]
|
||
|
||
assert admin.delete(f"/api/inbox/{eintrag['id']}").status_code == 204
|
||
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000
|
||
assert not [e for e in admin.get("/api/inbox").json() if e["id"] == eintrag["id"]]
|
||
|
||
|
||
def _import(admin, konto_id, csv_text):
|
||
import io
|
||
import json
|
||
return admin.post(
|
||
"/api/imports/preview",
|
||
files={"file": ("a.csv", io.BytesIO(csv_text.encode("utf-8")), "text/csv")},
|
||
data={"account_id": str(konto_id), "delimiter": ";", "decimal_sep": ",",
|
||
"date_format": "%d.%m.%Y", "encoding": "utf-8", "has_header": "true",
|
||
"skip_rows": "0", "amount_mode": "signed",
|
||
"column_map": json.dumps({"date": 0, "amount": 1, "payee": 2, "purpose": 3})},
|
||
).json()
|
||
|
||
|
||
KOPF = "Datum;Betrag;Empfaenger;Zweck\n"
|
||
|
||
|
||
def test_auszug_erkennt_die_bereits_per_kurzbefehl_erfasste_zahlung(admin):
|
||
"""Der Kern: dieselbe Zahlung darf nicht zweimal in den Saldo laufen.
|
||
|
||
Der Import-Schlüssel greift hier nicht – auf dem Auszug steht ein anderer
|
||
Text als das, was man am Telefon eingetippt hat, und die Bank bucht ein
|
||
bis zwei Tage später.
|
||
"""
|
||
client = _fremd()
|
||
konto = _konto(admin)
|
||
marke = _marke(admin, konto["id"])
|
||
client.post("/api/quick/booking",
|
||
json={"amount": "12.50", "payee": "Kantine", "date": "2026-05-04"},
|
||
headers={"X-Certain-Token": marke["token"]})
|
||
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000 - 1250
|
||
|
||
# Der Auszug nennt sie anders und datiert sie zwei Tage später.
|
||
v = _import(admin, konto["id"], KOPF + "06.05.2026;-12,50;SV GROUP SCHWEIZ AG;Kartenzahlung\n")
|
||
zeile = v["rows"][0]
|
||
assert zeile["is_duplicate"] is True
|
||
assert zeile["duplicate_reason"] == "inbox"
|
||
assert zeile["include"] is False
|
||
|
||
admin.post("/api/imports/commit", json={
|
||
"account_id": konto["id"], "filename": "mai.csv",
|
||
"rows": [r for r in v["rows"] if r["include"]]})
|
||
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000 - 1250
|
||
|
||
|
||
def test_abweichender_betrag_gilt_nicht_als_dieselbe_zahlung(admin):
|
||
"""Bei Geld ist Toleranz gefährlich: nur der exakte Betrag zählt."""
|
||
client = _fremd()
|
||
konto = _konto(admin)
|
||
marke = _marke(admin, konto["id"])
|
||
client.post("/api/quick/booking",
|
||
json={"amount": "12.50", "payee": "Kantine", "date": "2026-06-04"},
|
||
headers={"X-Certain-Token": marke["token"]})
|
||
|
||
v = _import(admin, konto["id"], KOPF + "05.06.2026;-12,60;SV GROUP;Kartenzahlung\n")
|
||
assert v["rows"][0]["is_duplicate"] is False
|
||
|
||
|
||
def test_zu_weit_entferntes_datum_gilt_nicht(admin):
|
||
client = _fremd()
|
||
konto = _konto(admin)
|
||
marke = _marke(admin, konto["id"])
|
||
client.post("/api/quick/booking",
|
||
json={"amount": "31.00", "payee": "Baumarkt", "date": "2026-07-01"},
|
||
headers={"X-Certain-Token": marke["token"]})
|
||
|
||
v = _import(admin, konto["id"], KOPF + "20.07.2026;-31,00;OBI;Einkauf\n")
|
||
assert v["rows"][0]["is_duplicate"] is False
|
||
|
||
|
||
def test_zwei_gleiche_zahlungen_verbrauchen_je_einen_eintrag(admin):
|
||
"""Zweimal derselbe Kaffee am selben Tag: beide Seiten müssen aufgehen."""
|
||
client = _fremd()
|
||
konto = _konto(admin)
|
||
marke = _marke(admin, konto["id"])
|
||
kopf = {"X-Certain-Token": marke["token"]}
|
||
for lauf in ("a", "b"):
|
||
client.post("/api/quick/booking",
|
||
json={"amount": "4.20", "payee": "Kaffee", "date": "2026-09-02",
|
||
"external_id": f"kaffee-{lauf}"}, headers=kopf)
|
||
assert admin.get(f"/api/accounts/{konto['id']}").json()["balance_cents"] == 100000 - 840
|
||
|
||
v = _import(admin, konto["id"], KOPF
|
||
+ "02.09.2026;-4,20;CAFE BAR;Karte\n"
|
||
+ "02.09.2026;-4,20;CAFE BAR;Karte\n"
|
||
+ "02.09.2026;-4,20;CAFE BAR;Karte\n")
|
||
gruende = [r["duplicate_reason"] for r in v["rows"]]
|
||
assert gruende.count("inbox") == 2, gruende
|
||
# Die dritte ist echt und wird gebucht.
|
||
assert sum(1 for r in v["rows"] if r["include"]) == 1
|