Das feste Zielkonto eines Transfer-Schlagworts behielt beim Wiederherstellen die alte Konto-Nummer. Die Nummern werden dabei aber neu vergeben, also lief es ins Leere – und zwar so, dass die GANZE Wiederherstellung mit einem Fremdschluessel-Fehler abbrach, nicht nur diese eine Zeile. Wer je ein Zielkonto gewaehlt hatte, kam an seine Sicherung nicht mehr heran. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
131 lines
5.0 KiB
Python
131 lines
5.0 KiB
Python
import json
|
||
from datetime import date
|
||
|
||
from conftest import login_ready, new_user
|
||
|
||
|
||
def _setup(admin):
|
||
acc = admin.post("/api/accounts", json={"name": "Exp", "currency": "EUR"}).json()
|
||
admin.post("/api/transactions", json={
|
||
"type": "expense", "date": str(date.today()), "account_id": acc["id"],
|
||
"amount_cents": 4200, "payee": "Testladen", "description": "Etwas",
|
||
})
|
||
return acc
|
||
|
||
|
||
def test_export_transactions_csv(admin):
|
||
_setup(admin)
|
||
r = admin.get("/api/export/transactions.csv")
|
||
assert r.status_code == 200
|
||
assert "text/csv" in r.headers["content-type"]
|
||
body = r.content.decode("utf-8-sig")
|
||
assert "Datum;Typ;Konto" in body.splitlines()[0]
|
||
assert "Testladen" in body
|
||
|
||
|
||
def test_export_transactions_json(admin):
|
||
_setup(admin)
|
||
r = admin.get("/api/export/transactions.json")
|
||
assert r.status_code == 200
|
||
data = json.loads(r.content)
|
||
assert any(t["payee"] == "Testladen" for t in data)
|
||
|
||
|
||
def test_export_all_json(admin):
|
||
_setup(admin)
|
||
r = admin.get("/api/export/all.json")
|
||
assert r.status_code == 200
|
||
data = json.loads(r.content)
|
||
assert data["app"] == "Certain Finance"
|
||
assert len(data["accounts"]) >= 1
|
||
assert len(data["transactions"]) >= 1
|
||
|
||
|
||
def test_db_backup_admin_only(admin):
|
||
# Admin darf.
|
||
r = admin.get("/api/backup/db")
|
||
assert r.status_code == 200
|
||
assert r.content[:16].startswith(b"SQLite format 3")
|
||
|
||
# Normaler Nutzer darf nicht.
|
||
other, _ = login_ready(*new_user(admin))
|
||
assert other.get("/api/backup/db").status_code == 403
|
||
# ...aber seinen eigenen Export schon (leer).
|
||
assert other.get("/api/export/all.json").status_code == 200
|
||
|
||
|
||
def test_jede_nutzertabelle_ist_im_backup_beruecksichtigt():
|
||
"""Neue Tabellen dürfen nicht stillschweigend aus dem Backup fallen.
|
||
|
||
Sieben Tabellen sind genau so entstanden: angelegt, migriert, benutzt – und
|
||
in ``backup_data`` nie eingetragen. Aufgefallen wäre das erst beim
|
||
Wiedereinspielen, also im schlechtesten Moment. Dieser Test zwingt jede
|
||
künftige Tabelle mit ``owner_id`` zu einer bewussten Entscheidung: entweder
|
||
sichern oder hier begründet ausnehmen.
|
||
"""
|
||
from app.models import Base
|
||
from app.services import backup_data
|
||
|
||
gesichert = {
|
||
spec.model.__tablename__
|
||
for liste in (backup_data.USER_TABLES, backup_data.SYSTEM_USER_TABLES,
|
||
backup_data.GLOBAL_TABLES)
|
||
for spec in liste
|
||
}
|
||
# Bewusst ausgenommen – die Begründung steht in backup_data.py.
|
||
ausgenommen = {"mail_sources", "mail_senders", "pending_uploads",
|
||
"bank_apps", "bank_connections", "bank_account_links"}
|
||
|
||
fehlend = sorted(
|
||
m.class_.__tablename__
|
||
for m in Base.registry.mappers
|
||
if "owner_id" in m.class_.__table__.columns
|
||
and m.class_.__tablename__ not in gesichert
|
||
and m.class_.__tablename__ not in ausgenommen
|
||
)
|
||
assert not fehlend, (
|
||
f"Diese Tabellen gehören dem Nutzer, stehen aber in keiner Backup-Liste: "
|
||
f"{fehlend}. Entweder in USER_TABLES aufnehmen oder oben begründet ausnehmen."
|
||
)
|
||
|
||
|
||
def test_transfer_schlagwort_behaelt_sein_zielkonto(admin):
|
||
"""Ein Transfer-Schlagwort mit festem Zielkonto trug beim Wiedereinspielen
|
||
die ALTE Konto-Nummer in die neue Datenbank. Beim Einspielen brach dann die
|
||
ganze Sicherung mit einem Fremdschluessel-Fehler ab - nicht nur diese eine
|
||
Zeile. Wer je ein Zielkonto gewaehlt hatte, kam an seine Sicherung also gar
|
||
nicht mehr heran."""
|
||
import secrets
|
||
|
||
def _konto(name):
|
||
return admin.post("/api/accounts", json={
|
||
"name": name, "type": "sparkonto", "currency": "CHF",
|
||
"opening_balance_cents": 0}).json()
|
||
|
||
# Erst Luecken in die Konto-Nummern reissen, dann das Zielkonto anlegen.
|
||
# Ohne Luecken vergibt SQLite beim Einspielen zufaellig dieselben Nummern
|
||
# und der Fehler bliebe unsichtbar - genau deshalb ist er so lange
|
||
# unbemerkt geblieben.
|
||
weg = [_konto(f"Weg {secrets.token_hex(3)}") for _ in range(5)]
|
||
ziel = _konto(f"Sparen {secrets.token_hex(3)}")
|
||
for k in weg:
|
||
assert admin.delete(f"/api/accounts/{k['id']}").status_code in (200, 204)
|
||
|
||
wort = f"um chf sparen {secrets.token_hex(5)}"
|
||
r = admin.post("/api/categories/transfer-keywords",
|
||
json={"word": wort, "target_account_id": ziel["id"]})
|
||
assert r.status_code == 201, r.text
|
||
|
||
daten = json.loads(admin.get("/api/export/backup.json").content)
|
||
r = admin.post("/api/backup/restore",
|
||
files={"file": ("backup.json", json.dumps(daten).encode("utf-8"),
|
||
"application/json")},
|
||
data={"mode": "replace"})
|
||
assert r.status_code == 200, r.text
|
||
|
||
konten = {a["name"]: a["id"] for a in admin.get("/api/accounts").json()}
|
||
regel = next(w for w in admin.get("/api/categories/transfer-keywords").json()
|
||
if w["word"] == wort)
|
||
assert regel["target_account_id"] == konten[ziel["name"]], \
|
||
"das Zielkonto muss auf die NEUE Nummer zeigen"
|