from __future__ import annotations import sqlite3 from pathlib import Path import pytest from jarca_ledger_mcp.infrastructure.sqlite import migrations from jarca_ledger_mcp.infrastructure.sqlite.migrations import init_db FIXTURE = Path(__file__).parent / "fixtures" / "schema_v14.sql" def _v14_db(path: Path) -> None: with sqlite3.connect(path) as db: db.executescript(FIXTURE.read_text()) db.execute("INSERT INTO accounts VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ("a1", "Main", "bank", "Bank", "KRW", 1, "memo", "1234", "User", 100000, "2026-01-31", "", "ok", "cash", "2026-01-01", "2026-01-31")) db.execute("INSERT INTO transactions (id,transaction_date,type,amount,account_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?)", ("t1", "2026-01-15", "expense", -1200, "a1", "2026-01-15", "2026-01-15")) db.execute("INSERT INTO asset_snapshots (id,basis_date,source_type,source_id,name,amount,closed_at,created_at) VALUES (?,?,?,?,?,?,?,?)", ("s1", "2026-01-31", "account", "a1", "Main", 100000, "2026-01-31T10:00:00", "2026-01-31T10:00:00")) db.execute("INSERT INTO asset_snapshot_runs (id,basis_date,close_status,created_at) VALUES (?,?,?,?)", ("run1", "2026-01-31", "closed", "2026-01-31T10:00:00")) db.execute("INSERT OR REPLACE INTO schema_meta VALUES ('schema_version','14')") db.commit() def _rows(path: Path, table: str): with sqlite3.connect(path) as db: return db.execute(f"SELECT * FROM {table} ORDER BY 1").fetchall() def test_actual_v14_fixture_is_tag_ddl_and_migrates_preserving_rows(tmp_path): path = tmp_path / "ledger.sqlite3" _v14_db(path) before_accounts, before_tx = _rows(path, "accounts"), _rows(path, "transactions") init_db(str(path)) with sqlite3.connect(path) as db: assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15" assert db.execute("SELECT amount, closed_at FROM asset_snapshots WHERE id='s1'").fetchone() == (100000, "2026-01-31T10:00:00") assert db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='asset_snapshot_runs'").fetchone() is not None assert db.execute("PRAGMA foreign_key_check").fetchall() == [] assert db.execute("PRAGMA integrity_check").fetchone()[0] == "ok" assert _rows(path, "accounts") == before_accounts assert _rows(path, "transactions") == before_tx def test_actual_v14_migration_is_idempotent_and_snapshot_unchanged(tmp_path): path = tmp_path / "ledger.sqlite3" _v14_db(path) init_db(str(path)) first = _rows(path, "asset_snapshots") init_db(str(path)) assert _rows(path, "asset_snapshots") == first def test_migration_failure_rolls_back_legacy_renames_and_metadata(tmp_path, monkeypatch): path = tmp_path / "ledger.sqlite3" _v14_db(path) original = migrations.migrate_db def fail(conn): raise RuntimeError("injected") monkeypatch.setattr(migrations, "migrate_db", fail) with pytest.raises(RuntimeError, match="injected"): init_db(str(path)) with sqlite3.connect(path) as db: assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "14" assert db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='asset_snapshots'").fetchone() assert not db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='account_snapshot'").fetchone() monkeypatch.setattr(migrations, "migrate_db", original) def test_new_init_has_same_contract(tmp_path): path = tmp_path / "new.sqlite3" init_db(str(path)) with sqlite3.connect(path) as db: assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "15" cols = {r[1] for r in db.execute("PRAGMA table_info(account_snapshot)")} assert {"monthly_settlement_id", "verification_status", "evidence_kind"} <= cols def test_populated_v14_upgrade_preserves_evidence_corrections_and_cashflow(tmp_path): path = tmp_path / "populated.sqlite3" _v14_db(path) with sqlite3.connect(path) as db: db.execute("INSERT INTO manual_assets(id,name,valuation_amount,created_at,updated_at) VALUES('manual','House',500,'t','t')") db.execute("INSERT INTO account_valuations(id,account_id,basis_date,amount,recorded_at) VALUES('v1','a1','2026-01-31',100,'t')") db.execute("INSERT INTO account_valuations(id,account_id,basis_date,amount,recorded_at,supersedes_id,correction_reason) VALUES('v2','a1','2026-01-31',110,'t','v1','correct')") db.execute("UPDATE asset_snapshots SET snapshot_run_id='run1' WHERE id='s1'") # A direct asset close can have no settlement_month. A correction shares # its basis date, but its frozen cashflow must never merge with run1. db.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at,correction_of_run_id,correction_reason) VALUES('run2','2026-01-31','conditional','t','run1','correct')") for run, amount in [("run1", 10), ("run2", 20)]: db.execute("INSERT INTO settlement_cashflow_snapshots VALUES(?,?,'income','','',?,1,'KRW')", ("cf-" + run, run, amount)) db.execute("CREATE INDEX custom_valuation_amount ON account_valuations(amount)") tables = ("accounts", "transactions", "manual_assets", "account_valuations", "asset_snapshots", "asset_snapshot_runs") with sqlite3.connect(path) as db: columns = {table: ",".join(row[1] for row in db.execute(f"PRAGMA table_info({table})")) for table in tables} before = {table: db.execute(f"SELECT {columns[table]} FROM {table} ORDER BY id").fetchall() for table in tables} init_db(str(path)) init_db(str(path)) with sqlite3.connect(path) as db: assert {table: db.execute(f"SELECT {columns[table]} FROM {table} ORDER BY id").fetchall() for table in tables} == before with sqlite3.connect(path) as db: assert db.execute("SELECT settlement_run_id,monthly_settlement_id,amount FROM settlement_cashflow_snapshots ORDER BY id").fetchall() == [("run1", None, 10), ("run2", None, 20)] assert db.execute("SELECT COUNT(*) FROM monthly_settlement").fetchone() == (0,) assert db.execute("SELECT COUNT(*) FROM account_snapshot").fetchone() == (0,) assert db.execute("SELECT name FROM sqlite_master WHERE name='custom_valuation_amount'").fetchone() assert db.execute("PRAGMA integrity_check").fetchone() == ("ok",) assert db.execute("PRAGMA foreign_key_check").fetchall() == [] def test_orphan_cashflow_migration_rolls_back_without_deleting_legacy_data(tmp_path): path = tmp_path / "orphan.sqlite3" _v14_db(path) with sqlite3.connect(path) as db: db.execute("INSERT INTO settlement_cashflow_snapshots VALUES('orphan','missing','income','','',1,1,'KRW')") before = _rows(path, "settlement_cashflow_snapshots") with pytest.raises(sqlite3.IntegrityError): init_db(str(path)) assert _rows(path, "settlement_cashflow_snapshots") == before with sqlite3.connect(path) as db: assert db.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone() == ("14",) assert not db.execute("SELECT name FROM sqlite_master WHERE name='account_snapshot'").fetchone()