Die Liste stand in der Anlegereihenfolge, die selten die gewuenschte ist.
Neuer Endpunkt PUT /accounts/order nimmt die IDs in der gewuenschten Folge
und schreibt die Position; er muss vor /{account_id} stehen, sonst faengt
die ID-Route den Pfad ab. Fremde IDs werden abgewiesen.
In der Kontenliste zwei Pfeile je Zeile, optimistisch sortiert, damit die
Liste beim Klicken nicht springt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
224 lines
10 KiB
Python
224 lines
10 KiB
Python
from datetime import date, timedelta
|
||
from decimal import Decimal
|
||
|
||
|
||
def _seed_recent_rates(rate: str = "0.95") -> None:
|
||
from app.database import SessionLocal
|
||
from app.models import ExchangeRate
|
||
|
||
db = SessionLocal()
|
||
try:
|
||
today = date.today()
|
||
for delta in range(0, 7):
|
||
day = today - timedelta(days=delta)
|
||
if db.get(ExchangeRate, (day, "CHF")) is None:
|
||
db.add(ExchangeRate(date=day, currency="CHF", rate_per_eur=Decimal(rate)))
|
||
db.commit()
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def _mk_account(admin, **kw):
|
||
body = {"name": "A", "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 test_starter_categories_present(admin):
|
||
groups = admin.get("/api/categories").json()
|
||
assert len(groups) >= 6
|
||
names = {c["name"] for g in groups for c in g["categories"]}
|
||
assert {"Gehalt", "Lebensmittel", "Sparen & Investments"} <= names
|
||
|
||
|
||
def test_currency_validation(admin):
|
||
r = admin.post("/api/accounts", json={"name": "X", "currency": "USD"})
|
||
assert r.status_code == 422
|
||
|
||
|
||
def test_identifiers_stored_and_deduped(admin):
|
||
acc = _mk_account(
|
||
admin,
|
||
name="Mit IBAN",
|
||
identifiers=[
|
||
{"kind": "iban", "value": "DE12 3456"},
|
||
{"kind": "iban", "value": "de123456"}, # normiert identisch -> dedupliziert
|
||
{"kind": "card_no", "value": "1234"},
|
||
],
|
||
)
|
||
kinds = sorted((i["kind"], i["value"]) for i in acc["identifiers"])
|
||
assert len(acc["identifiers"]) == 2
|
||
assert ("card_no", "1234") in kinds
|
||
|
||
|
||
def test_update_account_keeps_identifier(admin):
|
||
"""Umbenennen mit unverändertem Identifikator darf nicht an der UNIQUE-
|
||
Bedingung scheitern (Regression: früheres clear()+neu-Anlegen)."""
|
||
acc = _mk_account(admin, name="Revolut CHF",
|
||
identifiers=[{"kind": "text", "value": "Revolut**5064*"}])
|
||
r = admin.patch(f"/api/accounts/{acc['id']}", json={
|
||
"name": "Revolut CHF neu",
|
||
"identifiers": [{"kind": "text", "value": "Revolut**5064*"}],
|
||
})
|
||
assert r.status_code == 200, r.text
|
||
assert r.json()["name"] == "Revolut CHF neu"
|
||
idents = r.json()["identifiers"]
|
||
assert len(idents) == 1 and idents[0]["value"] == "Revolut**5064*"
|
||
|
||
# Identifikator entfernen klappt ebenfalls.
|
||
r2 = admin.patch(f"/api/accounts/{acc['id']}", json={"name": "Ohne", "identifiers": []})
|
||
assert r2.status_code == 200, r2.text
|
||
assert r2.json()["identifiers"] == []
|
||
|
||
|
||
def test_balance_and_cross_currency_transfer(admin):
|
||
_seed_recent_rates("0.95")
|
||
d = date.today()
|
||
eur = _mk_account(admin, name="Giro", currency="EUR", opening_balance_cents=100000)
|
||
chf = _mk_account(admin, name="Spar", type="sparkonto", currency="CHF", opening_balance_cents=0)
|
||
|
||
admin.post("/api/transactions", json={"type": "income", "date": str(d), "account_id": eur["id"], "amount_cents": 50000})
|
||
admin.post("/api/transactions", json={"type": "expense", "date": str(d), "account_id": eur["id"], "amount_cents": 20000})
|
||
assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 130000
|
||
|
||
# Transfer 100 EUR -> CHF (0.95) = 95 CHF gutgeschrieben.
|
||
r = admin.post("/api/transactions", json={
|
||
"type": "transfer", "date": str(d), "account_id": eur["id"],
|
||
"to_account_id": chf["id"], "amount_cents": 10000,
|
||
})
|
||
assert r.status_code == 201, r.text
|
||
assert r.json()["to_amount_cents"] == 9500
|
||
assert admin.get(f"/api/accounts/{eur['id']}").json()["balance_cents"] == 120000
|
||
assert admin.get(f"/api/accounts/{chf['id']}").json()["balance_cents"] == 9500
|
||
|
||
|
||
def test_transfer_requires_other_account(admin):
|
||
eur = _mk_account(admin, name="Solo", currency="EUR")
|
||
d = date.today()
|
||
r = admin.post("/api/transactions", json={
|
||
"type": "transfer", "date": str(d), "account_id": eur["id"],
|
||
"to_account_id": eur["id"], "amount_cents": 100,
|
||
})
|
||
assert r.status_code == 400
|
||
|
||
|
||
def test_stichtag_excludes_earlier_bookings(admin):
|
||
"""Der Anfangssaldo gilt am Ende des Stichtags.
|
||
|
||
Buchungen bis einschliesslich dieses Tages stecken schon darin und dürfen den
|
||
Kontostand nicht ein zweites Mal bewegen; erst spätere zählen. Ohne die
|
||
Datumsprüfung addierte die Berechnung jede Buchung des Kontos – der Stichtag
|
||
war reine Dekoration und Salden liefen um die Vorgeschichte daneben.
|
||
"""
|
||
stichtag = date(2026, 3, 31)
|
||
acc = _mk_account(admin, name="Lohn", currency="EUR",
|
||
opening_balance_cents=20000, opening_date=str(stichtag))
|
||
|
||
def saldo() -> int:
|
||
return admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"]
|
||
|
||
# Vor dem Stichtag: bleibt buchbar, ändert den Saldo aber nicht.
|
||
admin.post("/api/transactions", json={"type": "expense", "date": "2026-02-10",
|
||
"account_id": acc["id"], "amount_cents": 7000})
|
||
assert saldo() == 20000
|
||
|
||
# Am Stichtag selbst: ebenfalls schon im Anfangssaldo enthalten.
|
||
admin.post("/api/transactions", json={"type": "income", "date": str(stichtag),
|
||
"account_id": acc["id"], "amount_cents": 5000})
|
||
assert saldo() == 20000
|
||
|
||
# Erst der Tag danach bewegt den Saldo.
|
||
admin.post("/api/transactions", json={"type": "income", "date": "2026-04-01",
|
||
"account_id": acc["id"], "amount_cents": 1500})
|
||
assert saldo() == 21500
|
||
|
||
# Die alten Buchungen sind weiterhin da – nur der Saldo lässt sie aus.
|
||
liste = admin.get("/api/transactions", params={"account_id": acc["id"]}).json()
|
||
daten = {t["date"] for t in (liste["items"] if isinstance(liste, dict) else liste)}
|
||
assert {"2026-02-10", "2026-03-31", "2026-04-01"} <= daten
|
||
|
||
|
||
def test_stichtag_gilt_je_konto_beim_transfer(admin):
|
||
"""Beim Übertrag wird jede Seite an ihrem eigenen Stichtag gemessen."""
|
||
von = _mk_account(admin, name="Von", currency="EUR",
|
||
opening_balance_cents=50000, opening_date="2026-03-31")
|
||
nach = _mk_account(admin, name="Nach", currency="EUR",
|
||
opening_balance_cents=1000, opening_date="2026-01-31")
|
||
|
||
# Liegt vor dem Stichtag von "Von", aber nach dem von "Nach".
|
||
r = admin.post("/api/transactions", json={
|
||
"type": "transfer", "date": "2026-02-15", "account_id": von["id"],
|
||
"to_account_id": nach["id"], "amount_cents": 30000,
|
||
})
|
||
assert r.status_code == 201, r.text
|
||
assert admin.get(f"/api/accounts/{von['id']}").json()["balance_cents"] == 50000
|
||
assert admin.get(f"/api/accounts/{nach['id']}").json()["balance_cents"] == 31000
|
||
|
||
|
||
def test_ohne_stichtag_zaehlt_alles(admin):
|
||
acc = _mk_account(admin, name="Ohne", currency="EUR", opening_balance_cents=10000)
|
||
admin.post("/api/transactions", json={"type": "expense", "date": "2020-01-01",
|
||
"account_id": acc["id"], "amount_cents": 2500})
|
||
assert admin.get(f"/api/accounts/{acc['id']}").json()["balance_cents"] == 7500
|
||
|
||
|
||
def test_konto_loeschen_laesst_fremden_saldo_unberuehrt(admin):
|
||
"""Ein gelöschtes Konto darf den Saldo eines anderen nicht verändern.
|
||
|
||
``to_account_id`` hängt an ON DELETE CASCADE. Wurde das Zielkonto gelöscht,
|
||
verschwand die ganze Buchung – auch die Abgangsseite, die dem Quellkonto
|
||
gehört. Dessen Saldo stieg dadurch stillschweigend um den Übertrag.
|
||
"""
|
||
quelle = _mk_account(admin, name="Quelle", currency="CHF", opening_balance_cents=100000)
|
||
ziel = _mk_account(admin, name="Depot", currency="CHF")
|
||
r = admin.post("/api/transactions", json={
|
||
"type": "transfer", "date": "2026-03-01", "account_id": quelle["id"],
|
||
"to_account_id": ziel["id"], "amount_cents": 30000,
|
||
})
|
||
assert r.status_code == 201, r.text
|
||
assert admin.get(f"/api/accounts/{quelle['id']}").json()["balance_cents"] == 70000
|
||
|
||
assert admin.delete(f"/api/accounts/{ziel['id']}").status_code == 204
|
||
# Das Geld ist abgeflossen und bleibt abgeflossen.
|
||
assert admin.get(f"/api/accounts/{quelle['id']}").json()["balance_cents"] == 70000
|
||
verbleibend = [t for t in admin.get("/api/transactions").json()
|
||
if t["account_id"] == quelle["id"] and t["date"] == "2026-03-01"]
|
||
assert len(verbleibend) == 1
|
||
assert verbleibend[0]["type"] == "expense"
|
||
|
||
|
||
def test_konto_loeschen_erhaelt_zugang_beim_zielkonto(admin):
|
||
"""Umgekehrt: War das gelöschte Konto die Quelle, bleibt der Zugang beim Ziel."""
|
||
quelle = _mk_account(admin, name="Weg", currency="CHF", opening_balance_cents=50000)
|
||
ziel = _mk_account(admin, name="Bleibt", currency="CHF")
|
||
admin.post("/api/transactions", json={
|
||
"type": "transfer", "date": "2026-03-02", "account_id": quelle["id"],
|
||
"to_account_id": ziel["id"], "amount_cents": 20000,
|
||
})
|
||
assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 20000
|
||
assert admin.delete(f"/api/accounts/{quelle['id']}").status_code == 204
|
||
assert admin.get(f"/api/accounts/{ziel['id']}").json()["balance_cents"] == 20000
|
||
|
||
|
||
def test_kontenreihenfolge_laesst_sich_setzen(admin):
|
||
"""Die Liste stand in der Anlegereihenfolge; die will man selbst bestimmen."""
|
||
a = _mk_account(admin, name="Zuerst angelegt")
|
||
b = _mk_account(admin, name="Danach angelegt")
|
||
c = _mk_account(admin, name="Zuletzt angelegt")
|
||
namen = lambda: [x["name"] for x in admin.get("/api/accounts").json()
|
||
if x["name"].endswith("angelegt")]
|
||
assert namen() == ["Zuerst angelegt", "Danach angelegt", "Zuletzt angelegt"]
|
||
|
||
r = admin.put("/api/accounts/order", json={"ids": [c["id"], a["id"], b["id"]]})
|
||
assert r.status_code == 204, r.text
|
||
assert namen() == ["Zuletzt angelegt", "Zuerst angelegt", "Danach angelegt"]
|
||
|
||
|
||
def test_kontenreihenfolge_nimmt_keine_fremden_konten(admin, client):
|
||
"""Fremde Konto-IDs in der Liste dürfen nichts verschieben."""
|
||
eigen = _mk_account(admin, name="Eigenes Konto")
|
||
r = admin.put("/api/accounts/order", json={"ids": [eigen["id"], 999999]})
|
||
assert r.status_code == 404
|