diff --git a/backend/migrations/env.py b/backend/migrations/env.py index 1b972c2..588403a 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -34,14 +34,37 @@ def run_migrations_offline() -> None: def run_migrations_online() -> None: with engine.connect() as connection: - context.configure( - connection=connection, - target_metadata=target_metadata, - render_as_batch=True, - compare_type=True, - ) - with context.begin_transaction(): - context.run_migrations() + # Fremdschlüssel für die Dauer der Migration abschalten. + # + # ``render_as_batch`` baut eine Tabelle um, indem es eine Kopie anlegt und + # das Original löscht. SQLite führt bei DROP TABLE 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. + # + # Der Befehl muss VOR der Transaktion laufen, sonst ignoriert SQLite ihn. + roh = connection.connection + roh.rollback() + roh.execute("PRAGMA foreign_keys=OFF") + aktiv = roh.execute("PRAGMA foreign_keys").fetchone()[0] + if aktiv: + raise RuntimeError( + "Fremdschlüssel liessen sich nicht abschalten – Migration abgebrochen, " + "sonst droht Datenverlust beim Tabellen-Umbau." + ) + try: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + finally: + roh.rollback() + roh.execute("PRAGMA foreign_keys=ON") if context.is_offline_mode(): diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py new file mode 100644 index 0000000..bd07992 --- /dev/null +++ b/backend/tests/test_migrations.py @@ -0,0 +1,68 @@ +"""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()