Files
ScarriffleandClaude Opus 5 a9ea0432d8 fix: Eine Sicherung mit Transfer-Schlagwort laesst sich wieder einspielen
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>
2026-10-19 07:16:03 +02:00

131 lines
5.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"