Files
GuidoandClaude Opus 4.8 35d89b2b8f fix: Migrationen löschen keine abhängigen Daten mehr
Auf SQLite baut Alembic (render_as_batch) eine Tabelle um, indem es eine Kopie
anlegt und das Original löscht. Bei DROP TABLE führt SQLite ein implizites DELETE
aus – mit aktiven Fremdschlüsseln schlägt das per ON DELETE CASCADE auf abhängige
Tabellen durch.

Migration 0013 fügte `categories` nur ein Feld hinzu und vernichtete dabei alle
Schlagwörter und Budget-Vorlagen und setzte die Kategorie sämtlicher Buchungen
auf NULL. Reproduziert und behoben: Fremdschlüssel werden für die Dauer der
Migration abgeschaltet (vor der Transaktion, sonst ignoriert SQLite den Befehl)
und danach wieder aktiviert. Lässt sich das Abschalten nicht bestätigen, bricht
die Migration ab, statt Daten zu riskieren.

Regressionstest legt Daten auf Stand 0012 an, migriert auf head und prüft, dass
Schlagwörter, Budget-Vorlagen und Kategoriezuordnungen erhalten bleiben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 14:21:03 +02:00

69 lines
3.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.
"""Migrationen dürfen keine Daten vernichten.
Auf SQLite baut Alembic (``render_as_batch``) eine Tabelle um, indem es eine
Kopie anlegt und das Original löscht. Bei DROP TABLE führt SQLite ein implizites
DELETE aus – mit aktiven Fremdschlüsseln schlägt das per ON DELETE CASCADE auf
abhängige Tabellen durch. Ein zusätzliches Feld an ``categories`` löschte so
sämtliche Schlagwörter und Budget-Vorlagen und setzte die Kategorie aller
Buchungen auf NULL. Dieser Test hält das fest.
"""
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
BACKEND = Path(__file__).resolve().parents[1]
# Ein Datensatz je Tabelle, die per Fremdschlüssel an categories hängt.
SETUP = """
INSERT INTO users (username,password_hash,role,is_active,must_change_password)
VALUES ('t','x','user',1,0);
INSERT INTO category_groups (owner_id,name,kind,icon,color,position,is_builtin)
VALUES (1,'G','expense','list','#000',0,0);
INSERT INTO categories (owner_id,group_id,name,type,icon,color,position,is_builtin,is_archived)
VALUES (1,1,'Lebensmittel','expense','x','#000',0,0,0);
INSERT INTO accounts (owner_id,name,type,currency,icon,color,opening_balance_cents,is_archived,position)
VALUES (1,'Giro','girokonto','EUR','x','#000',0,0,0);
INSERT INTO categorization_rules
(owner_id,match_field,pattern,category_id,priority,hits,source,applied_count,overridden_count,confirmed,is_transfer,kind)
VALUES (1,'any','rewe',1,10,0,'manual',0,0,0,0,'expense');
INSERT INTO budget_template_lines (owner_id,category_id,planned_cents) VALUES (1,1,20000);
INSERT INTO transactions (owner_id,type,date,account_id,amount_cents,amount_eur_cents,category_id)
VALUES (1,'expense','2026-03-01',1,5000,5000,1);
"""
def _alembic(data_dir: Path, ziel: str) -> None:
subprocess.run(
[sys.executable, "-c",
f"from alembic import command; from alembic.config import Config;"
f" command.upgrade(Config('alembic.ini'), {ziel!r})"],
cwd=BACKEND, check=True, capture_output=True,
env={"PATH": "/usr/bin:/bin", "APP_DATA_DIR": str(data_dir),
"APP_ECB_FETCH_ON_START": "false", "APP_SESSION_SECRET": "test",
"PYTHONPATH": str(BACKEND)},
)
def test_batch_migration_keeps_dependent_rows():
with tempfile.TemporaryDirectory(prefix="cf-mig-") as tmp:
data_dir = Path(tmp)
# Stand vor dem Umbau von `categories`, dann Testdaten einspielen.
_alembic(data_dir, "0012")
con = sqlite3.connect(data_dir / "app.db")
con.executescript(SETUP)
con.commit()
con.close()
_alembic(data_dir, "head")
con = sqlite3.connect(data_dir / "app.db")
zahl = lambda s: con.execute(s).fetchone()[0] # noqa: E731
assert zahl("SELECT COUNT(*) FROM categorization_rules") == 1, "Schlagwörter verloren"
assert zahl("SELECT COUNT(*) FROM budget_template_lines") == 1, "Budget-Vorlage verloren"
assert zahl("SELECT COUNT(*) FROM transactions WHERE category_id IS NOT NULL") == 1, \
"Kategorie der Buchung verloren"
con.close()