diff --git a/.github/workflows/portfolio-phase3-integration.yml b/.github/workflows/portfolio-phase3-integration.yml index c2ef0dc..93e156d 100644 --- a/.github/workflows/portfolio-phase3-integration.yml +++ b/.github/workflows/portfolio-phase3-integration.yml @@ -63,111 +63,126 @@ jobs: run: npm test - name: TypeScript typecheck working-directory: frontend run: npm run typecheck - name: Production build working-directory: frontend run: npm run build controls: name: Migration, contracts, quality and repository safety runs-on: ubuntu-24.04 timeout-minutes: 25 env: PYTHONPATH: src JARVIS_FINANCE_ENV: test JARVIS_FINANCE_DB_PATH: /tmp/financemanager-phase3-controls/finance.sqlite3 JARVIS_FINANCE_RUNTIME_DIR: /tmp/financemanager-phase3-controls/runtime JARVIS_FINANCE_CI_ROOT: /tmp/financemanager-phase3-migration JARVIS_FINANCE_WRITE_MODE: disabled steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip cache-dependency-path: requirements-ci.txt - name: Install pinned CI dependencies run: python -m pip install -e . -r requirements-ci.txt - name: Git diff check run: git diff --check "origin/${GITHUB_BASE_REF:-main}...HEAD" - name: Repository safety and secret scan run: python -m jarvis_finance.cli.main git-safety-scan . - name: Ruff – Phase 3 and integration surfaces run: | python -m ruff check \ scripts/ci_portfolio_phase3_gate.py \ src/jarvis_finance/api/main.py \ src/jarvis_finance/api/routers/overview.py \ + src/jarvis_finance/api/routers/market.py \ + src/jarvis_finance/api/routers/system.py \ + src/jarvis_finance/api/schemas/manual_snapshot.py \ + src/jarvis_finance/api/schemas/market.py \ src/jarvis_finance/api/schemas/portfolio_data.py \ src/jarvis_finance/api/schemas/portfolio_performance.py \ src/jarvis_finance/api/schemas/portfolio_policy.py \ src/jarvis_finance/api/schemas/performance_activation.py \ src/jarvis_finance/api/schemas/reconciliation.py \ src/jarvis_finance/api/schemas/wealth_cockpit.py \ src/jarvis_finance/api/security.py \ src/jarvis_finance/cli/main.py \ src/jarvis_finance/config/settings.py \ src/jarvis_finance/ledger/cost_basis.py \ src/jarvis_finance/ledger/performance.py \ src/jarvis_finance/market/providers.py \ src/jarvis_finance/quality/freshness.py \ src/jarvis_finance/quality/git_safety.py \ src/jarvis_finance/services/crypto_market_recovery.py \ src/jarvis_finance/services/crypto_reconciliation.py \ src/jarvis_finance/services/crypto_service.py \ src/jarvis_finance/crypto/current_balances.py \ src/jarvis_finance/api/routers/crypto.py \ src/jarvis_finance/api/schemas/crypto_reconciliation.py \ src/jarvis_finance/services/performance_activation.py \ src/jarvis_finance/services/cash_service.py \ src/jarvis_finance/services/household_import.py \ src/jarvis_finance/services/modelled_wealth.py \ + src/jarvis_finance/services/asset_price_refresh.py \ + src/jarvis_finance/services/market_service.py \ + src/jarvis_finance/services/portfolio_analysis_v1.py \ + src/jarvis_finance/services/raiffeisen_manual_snapshot.py \ + src/jarvis_finance/services/system_ops.py \ src/jarvis_finance/services/wealth_cockpit.py \ src/jarvis_finance/services/portfolio_data.py \ src/jarvis_finance/services/portfolio_performance.py \ src/jarvis_finance/services/portfolio_policy.py \ src/jarvis_finance/services/reconciliation_snapshot.py \ src/jarvis_finance/services/truewealth_productization.py \ src/jarvis_finance/services/truewealth_service.py \ src/jarvis_finance/services/truewealth_valuation.py \ src/jarvis_finance/services/transfer_pairing.py \ src/jarvis_finance/storage/database.py \ src/jarvis_finance/storage/migrations.py \ tests/conftest.py \ tests/unit/test_api_write_security.py \ tests/unit/test_git_safety.py \ tests/unit/test_portfolio_data_ingestion_reconciliation.py \ tests/unit/test_portfolio_performance_foundation.py \ tests/unit/test_portfolio_policy_foundation.py \ tests/unit/test_reconciliation_snapshot_foundation.py \ tests/unit/test_modelled_wealth.py \ + tests/unit/test_asset_price_refresh.py \ + tests/unit/test_portfolio_analysis_v1.py \ + tests/unit/test_raiffeisen_manual_snapshot.py \ tests/unit/test_portfolio_market_analytics_v1.py \ tests/unit/test_wealth_cockpit_v1.py \ tests/unit/test_cash_truewealth_management.py \ tests/unit/test_settings.py \ tests/unit/test_sprint20d_truewealth_productization.py \ tests/unit/test_sprint20d_truewealth_valuation.py \ tests/unit/test_sprint20e_crypto_market_recovery.py \ tests/unit/test_sprint20g1_crypto_reconciliation.py \ tests/unit/test_transfer_pairing_v2.py - name: Python compileall run: python -m compileall -q src tests scripts/ci_portfolio_phase3_gate.py - name: Empty and Sprint-5-to-41 migration gates run: python scripts/ci_portfolio_phase3_gate.py - name: OpenAPI, auth, write, idempotency, reconciliation and performance controls run: | mkdir -p /tmp/financemanager-phase3-controls/runtime python -m pytest -q \ tests/unit/test_api_write_security.py \ tests/unit/test_settings.py \ tests/unit/test_portfolio_data_ingestion_reconciliation.py \ tests/unit/test_portfolio_performance_foundation.py \ tests/unit/test_portfolio_policy_foundation.py \ tests/unit/test_reconciliation_snapshot_foundation.py \ tests/unit/test_modelled_wealth.py \ + tests/unit/test_asset_price_refresh.py \ + tests/unit/test_portfolio_analysis_v1.py \ + tests/unit/test_raiffeisen_manual_snapshot.py \ tests/unit/test_portfolio_market_analytics_v1.py \ tests/unit/test_wealth_cockpit_v1.py \ tests/unit/test_cash_truewealth_management.py \ tests/unit/test_transfer_pairing_v2.py diff --git a/scripts/ci_portfolio_phase3_gate.py b/scripts/ci_portfolio_phase3_gate.py index 4b59014..4fadc28 100644 --- a/scripts/ci_portfolio_phase3_gate.py +++ b/scripts/ci_portfolio_phase3_gate.py @@ -1,58 +1,58 @@ from __future__ import annotations import hashlib import io import json import os import shutil import sqlite3 import subprocess import sys import tarfile from pathlib import Path from jarvis_finance.config.settings import load_settings from jarvis_finance.storage.migrations import apply_migrations, get_schema_version SPRINT5_COMMIT = "480d7a1b72a950e864c4bb021d3f58af8f8c15f9" -EXPECTED_SCHEMA = 51 +EXPECTED_SCHEMA = 52 def _assert_tmp_path(path: Path) -> Path: resolved = path.resolve() if resolved == Path("/tmp") or Path("/tmp") not in resolved.parents: raise RuntimeError(f"CI path must be isolated below /tmp: {resolved}") return resolved def _connect(path: Path) -> sqlite3.Connection: connection = sqlite3.connect(path) connection.row_factory = sqlite3.Row return connection def _integrity(connection: sqlite3.Connection) -> str: return str(connection.execute("PRAGMA integrity_check").fetchone()[0]) def _seed_digest(connection: sqlite3.Connection, *, include_performance_metadata: bool = False) -> str: payload: dict[str, list[list[object]]] = {} for table in ("platforms", "accounts"): columns = [row[1] for row in connection.execute(f'PRAGMA table_info("{table}")')] selected_columns = columns if table == "accounts" and not include_performance_metadata: selected_columns = [column for column in columns if column != "performance_included"] selected = ", ".join(f'"{column}"' for column in selected_columns) rows = connection.execute(f'SELECT {selected} FROM "{table}" ORDER BY {selected}').fetchall() payload[table] = [[row[column] for column in selected_columns] for row in rows] encoded = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str).encode() return hashlib.sha256(encoded).hexdigest() def _database_digest(connection: sqlite3.Connection) -> str: payload: dict[str, list[list[object]]] = {} tables = [ str(row[0]) for row in connection.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" ) diff --git a/src/jarvis_finance/storage/migrations.py b/src/jarvis_finance/storage/migrations.py index 90c34e3..8e6e86f 100644 --- a/src/jarvis_finance/storage/migrations.py +++ b/src/jarvis_finance/storage/migrations.py @@ -1,52 +1,52 @@ from __future__ import annotations import hashlib import json from datetime import datetime, timezone from sqlite3 import Connection from .schema import INITIAL_SCHEMA_SQL from .postfinance_schema import create_postfinance_ledger_import_v1 -MIGRATION_VERSION = 51 -MIGRATION_NAME = "051_current_source_coverage_and_truewealth_activity_v1" +MIGRATION_VERSION = 52 +MIGRATION_NAME = "052_professional_portfolio_cockpit_v1" INSTRUMENT_OPTIONAL_COLUMNS = { "position_category": "TEXT", "ter": "TEXT", "distribution_policy": "TEXT", "index_name": "TEXT", "fund_domicile": "TEXT", "benchmark": "TEXT", "is_currency_hedged": "INTEGER NOT NULL DEFAULT 0", "hedged_to_currency": "TEXT", "hedge_status": "TEXT NOT NULL DEFAULT 'unknown'", "base_exposure_currency": "TEXT", "trading_currency": "TEXT", "instrument_status": "TEXT NOT NULL DEFAULT 'unknown'", "valuation_policy": "TEXT NOT NULL DEFAULT 'live_price'", "corporate_action_status": "TEXT NOT NULL DEFAULT 'not_checked'", "split_or_corporate_action_review_required": "INTEGER NOT NULL DEFAULT 0", } CATALOG_OPTIONAL_COLUMNS = { "last_price": "TEXT", "price_currency": "TEXT", "price_date": "TEXT", "price_source": "TEXT", "exchange_name": "TEXT", "mic": "TEXT", "security_type": "TEXT", } TEXT_AFFINITY_COLUMNS = { "transactions": {"quantity", "price_original", "gross_amount_original", "fee_original", "tax_original", "net_amount_original", "fx_rate_to_chf", "gross_amount_chf", "fee_chf", "tax_chf", "net_amount_chf"}, "crypto_holdings": {"quantity", "legacy_snapshot_value_original", "legacy_snapshot_value_chf"}, "crypto_transactions": {"quantity", "price_original", "gross_amount_original", "fee_quantity", "fee_original", "fx_rate_to_chf", "amount_chf"}, "crypto_prices": {"price", "market_cap", "volume_24h", "change_24h_pct"}, "fx_rates": {"rate"}, "market_prices": {"open", "high", "low", "close", "adjusted_close"}, "equity_price_points": {"price"}, "equity_intraday_candles": {"open", "close", "low", "high", "volume"}, "crypto_price_points": {"price"}, } @@ -2800,99 +2800,194 @@ def _create_crypto_reconciliation_cockpit_v1(conn: Connection) -> None: asset_id TEXT NOT NULL REFERENCES crypto_assets(asset_id), evidence_reference TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'confirmed' CHECK(status='confirmed'), audit_id TEXT NOT NULL UNIQUE REFERENCES audit_log(audit_id), created_by TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL, CHECK(withdrawal_transaction_id<>deposit_transaction_id) ); CREATE INDEX IF NOT EXISTS idx_crypto_balance_snapshots_latest ON crypto_balance_snapshots(status, observed_at DESC, created_at DESC); CREATE INDEX IF NOT EXISTS idx_crypto_snapshot_items_wallet_asset ON crypto_balance_snapshot_items(wallet_id, asset_id); CREATE TRIGGER IF NOT EXISTS crypto_balance_snapshots_no_update BEFORE UPDATE ON crypto_balance_snapshots BEGIN SELECT RAISE(ABORT, 'crypto balance snapshots are immutable'); END; CREATE TRIGGER IF NOT EXISTS crypto_balance_snapshots_no_delete BEFORE DELETE ON crypto_balance_snapshots BEGIN SELECT RAISE(ABORT, 'crypto balance snapshots cannot be deleted'); END; CREATE TRIGGER IF NOT EXISTS crypto_balance_snapshot_wallets_no_update BEFORE UPDATE ON crypto_balance_snapshot_wallets BEGIN SELECT RAISE(ABORT, 'crypto snapshot wallets are immutable'); END; CREATE TRIGGER IF NOT EXISTS crypto_balance_snapshot_wallets_no_delete BEFORE DELETE ON crypto_balance_snapshot_wallets BEGIN SELECT RAISE(ABORT, 'crypto snapshot wallets cannot be deleted'); END; CREATE TRIGGER IF NOT EXISTS crypto_balance_snapshot_items_no_update BEFORE UPDATE ON crypto_balance_snapshot_items BEGIN SELECT RAISE(ABORT, 'crypto snapshot items are immutable'); END; CREATE TRIGGER IF NOT EXISTS crypto_balance_snapshot_items_no_delete BEFORE DELETE ON crypto_balance_snapshot_items BEGIN SELECT RAISE(ABORT, 'crypto snapshot items cannot be deleted'); END; CREATE TRIGGER IF NOT EXISTS crypto_internal_transfer_pairs_no_update BEFORE UPDATE ON crypto_internal_transfer_pairs BEGIN SELECT RAISE(ABORT, 'crypto transfer pairs are immutable'); END; CREATE TRIGGER IF NOT EXISTS crypto_internal_transfer_pairs_no_delete BEFORE DELETE ON crypto_internal_transfer_pairs BEGIN SELECT RAISE(ABORT, 'crypto transfer pairs cannot be deleted'); END; """ ) +def _create_professional_portfolio_cockpit_v1(conn: Connection) -> None: + """Add bounded manual-snapshot and controlled refresh job lineage.""" + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS manual_snapshot_confirmations ( + confirmation_id TEXT PRIMARY KEY, + preview_id TEXT NOT NULL UNIQUE, + input_fingerprint TEXT NOT NULL, + payload_hash TEXT NOT NULL, + snapshot_date TEXT NOT NULL, + source_kind TEXT NOT NULL, + known_wealth_after_chf TEXT NOT NULL, + bank_cash_after_chf TEXT NOT NULL, + separate_membership_asset_after_chf TEXT NOT NULL, + created_snapshot_count INTEGER NOT NULL CHECK(created_snapshot_count=3), + created_at TEXT NOT NULL, + audit_id TEXT NOT NULL UNIQUE REFERENCES audit_log(audit_id), + CHECK(source_kind='dated_manual_screenshot') + ); + CREATE TRIGGER IF NOT EXISTS manual_snapshot_confirmations_no_update + BEFORE UPDATE ON manual_snapshot_confirmations + BEGIN SELECT RAISE(ABORT, 'manual snapshot confirmations are immutable'); END; + CREATE TRIGGER IF NOT EXISTS manual_snapshot_confirmations_no_delete + BEFORE DELETE ON manual_snapshot_confirmations + BEGIN SELECT RAISE(ABORT, 'manual snapshot confirmations cannot be deleted'); END; + CREATE TRIGGER IF NOT EXISTS manual_cash_snapshots_no_update + BEFORE UPDATE ON cash_account_snapshots + WHEN OLD.source='manual_screenshot_snapshot' + BEGIN SELECT RAISE(ABORT, 'manual cash snapshots are immutable'); END; + CREATE TRIGGER IF NOT EXISTS manual_cash_snapshots_no_delete + BEFORE DELETE ON cash_account_snapshots + WHEN OLD.source='manual_screenshot_snapshot' + BEGIN SELECT RAISE(ABORT, 'manual cash snapshots cannot be deleted'); END; + CREATE TRIGGER IF NOT EXISTS manual_asset_snapshots_no_update + BEFORE UPDATE ON account_value_snapshots + WHEN OLD.source_type='manual_screenshot_snapshot' + BEGIN SELECT RAISE(ABORT, 'manual asset snapshots are immutable'); END; + CREATE TRIGGER IF NOT EXISTS manual_asset_snapshots_no_delete + BEFORE DELETE ON account_value_snapshots + WHEN OLD.source_type='manual_screenshot_snapshot' + BEGIN SELECT RAISE(ABORT, 'manual asset snapshots cannot be deleted'); END; + CREATE TABLE IF NOT EXISTS asset_price_refresh_jobs ( + job_id TEXT PRIMARY KEY, + status TEXT NOT NULL CHECK(status IN ('queued','running','complete','partial','failed')), + requested_at TEXT NOT NULL, + completed_at TEXT, + stale_before TEXT NOT NULL, + progress_total INTEGER NOT NULL DEFAULT 3, + progress_completed INTEGER NOT NULL DEFAULT 0, + wealth_snapshot_id TEXT, + audit_id TEXT REFERENCES audit_log(audit_id) + ); + CREATE UNIQUE INDEX IF NOT EXISTS uq_asset_price_refresh_single_active + ON asset_price_refresh_jobs((1)) + WHERE status IN ('queued','running'); + CREATE TABLE IF NOT EXISTS asset_price_refresh_sources ( + job_id TEXT NOT NULL REFERENCES asset_price_refresh_jobs(job_id), + source TEXT NOT NULL CHECK(source IN ('equity','crypto','fx')), + status TEXT NOT NULL CHECK(status IN ('pending','running','complete','failed','skipped')), + stale_candidates INTEGER NOT NULL DEFAULT 0, + updated_count INTEGER NOT NULL DEFAULT 0, + error_code TEXT, + started_at TEXT, + completed_at TEXT, + PRIMARY KEY(job_id,source) + ); + CREATE TABLE IF NOT EXISTS aggregated_wealth_refresh_snapshots ( + wealth_snapshot_id TEXT PRIMARY KEY, + job_id TEXT NOT NULL UNIQUE REFERENCES asset_price_refresh_jobs(job_id), + captured_at TEXT NOT NULL, + known_wealth_chf TEXT, + quality_status TEXT NOT NULL CHECK(quality_status IN ('complete','partial')), + source_status_json TEXT NOT NULL CHECK(json_valid(source_status_json)) + ); + CREATE INDEX IF NOT EXISTS idx_asset_price_refresh_jobs_requested + ON asset_price_refresh_jobs(requested_at DESC); + CREATE TRIGGER IF NOT EXISTS aggregated_wealth_refresh_snapshots_no_update + BEFORE UPDATE ON aggregated_wealth_refresh_snapshots + BEGIN SELECT RAISE(ABORT, 'wealth refresh snapshots are immutable'); END; + CREATE TRIGGER IF NOT EXISTS aggregated_wealth_refresh_snapshots_no_delete + BEFORE DELETE ON aggregated_wealth_refresh_snapshots + BEGIN SELECT RAISE(ABORT, 'wealth refresh snapshots cannot be deleted'); END; + CREATE TRIGGER IF NOT EXISTS sprint23_audit_no_update + BEFORE UPDATE ON audit_log + WHEN OLD.entity_type IN ('manual_source_snapshot','asset_price_refresh_job') + BEGIN SELECT RAISE(ABORT, 'sprint23 audit is immutable'); END; + CREATE TRIGGER IF NOT EXISTS sprint23_audit_no_delete + BEFORE DELETE ON audit_log + WHEN OLD.entity_type IN ('manual_source_snapshot','asset_price_refresh_job') + BEGIN SELECT RAISE(ABORT, 'sprint23 audit cannot be deleted'); END; + """ + ) + + def _apply_compat_migrations(conn: Connection) -> None: _add_missing_instrument_columns(conn) for table, text_columns in TEXT_AFFINITY_COLUMNS.items(): _rebuild_table_with_text_columns(conn, table, text_columns) _create_broker_bank_mapping_tables(conn) _create_broker_import_review_items(conn) _create_broker_import_execution_plans(conn) _add_transaction_void_columns(conn) _create_fx_market_data_tables(conn) _create_market_quote_chart_tables(conn) _create_account_value_snapshot_tables(conn) _create_cash_account_snapshot_tables(conn) _create_instrument_import_candidates(conn) _create_budget_phase1_tables(conn) _create_budget_phase11_tables(conn) _create_budget_phase14_tables(conn) _create_budget_phase15_tables(conn) _create_budget_phase18_tables(conn) _create_budget_phase19_tables(conn) _create_budget_import_production_v1_tables(conn) _create_budget_categories_ux_fix_tables(conn) _create_budget_planning_forecast_v1_tables(conn) _create_budget_fixed_costs_subscriptions_v1_tables(conn) _create_budget_monthly_import_rule_learning_v1_tables(conn) _create_transfer_pairing_v2_tables(conn) _create_portfolio_policy_tables(conn) _create_portfolio_performance_tables(conn) _create_portfolio_ingestion_reconciliation_tables(conn) _create_daily_market_analytics_tables(conn) _create_postfinance_baseline_mapping_audit_v1(conn) _create_truewealth_verified_snapshot_v1(conn) create_postfinance_ledger_import_v1(conn) _create_investment_performance_scope_v1(conn) _create_grocery_optimizer_v1_tables(conn) _add_grocery_price_provider_v1_columns(conn) _create_grocery_matching_learning_v2_tables(conn) _create_household_import_v1_tables(conn) _create_household_review_corrections_v1(conn) _create_annual_budget_recurring_semantics_v1(conn) _create_current_source_coverage_and_truewealth_activity_v1(conn) _create_crypto_reconciliation_cockpit_v1(conn) + _create_professional_portfolio_cockpit_v1(conn) def apply_migrations(conn: Connection) -> None: conn.executescript(INITIAL_SCHEMA_SQL) existing_initial = conn.execute("SELECT 1 FROM schema_migrations WHERE version = 1").fetchone() if not existing_initial: conn.execute( "INSERT INTO schema_migrations(version, name, applied_at, checksum) VALUES (?, ?, ?, ?)", (1, "001_initial_schema", utc_now(), checksum_sql(INITIAL_SCHEMA_SQL)), ) _apply_compat_migrations(conn) existing = conn.execute("SELECT 1 FROM schema_migrations WHERE version = ?", (MIGRATION_VERSION,)).fetchone() if not existing: conn.execute( "INSERT INTO schema_migrations(version, name, applied_at, checksum) VALUES (?, ?, ?, ?)", (MIGRATION_VERSION, MIGRATION_NAME, utc_now(), checksum_sql(MIGRATION_NAME)), ) conn.commit() diff --git a/tests/unit/test_schema.py b/tests/unit/test_schema.py index 7e3a951..f576932 100644 --- a/tests/unit/test_schema.py +++ b/tests/unit/test_schema.py @@ -1,61 +1,61 @@ from __future__ import annotations import pytest from jarvis_finance.storage import migrations from jarvis_finance.storage.database import connect_memory from jarvis_finance.storage.migrations import apply_migrations, get_schema_version from jarvis_finance.storage.schema import REQUIRED_TABLES def test_db_schema_can_be_created() -> None: conn = connect_memory() apply_migrations(conn) tables = {row["name"] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} assert set(REQUIRED_TABLES).issubset(tables) def test_schema_version_recorded() -> None: conn = connect_memory() apply_migrations(conn) - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 def test_decimal_sensitive_columns_use_text_affinity() -> None: conn = connect_memory() apply_migrations(conn) tx_cols = {row["name"]: row["type"] for row in conn.execute("PRAGMA table_info(transactions)").fetchall()} crypto_cols = {row["name"]: row["type"] for row in conn.execute("PRAGMA table_info(crypto_holdings)").fetchall()} instrument_cols = {row["name"] for row in conn.execute("PRAGMA table_info(instruments)").fetchall()} assert tx_cols["quantity"].upper() == "TEXT" assert tx_cols["fx_rate_to_chf"].upper() == "TEXT" assert crypto_cols["quantity"].upper() == "TEXT" assert {"position_category", "ter", "distribution_policy", "index_name", "fund_domicile", "benchmark"}.issubset(instrument_cols) def test_broker_bank_mapping_tables_exist_with_required_columns() -> None: conn = connect_memory() apply_migrations(conn) instrument_mapping_cols = {row["name"] for row in conn.execute("PRAGMA table_info(instrument_mappings)").fetchall()} account_mapping_cols = {row["name"] for row in conn.execute("PRAGMA table_info(platform_account_mappings)").fetchall()} dry_run_cols = {row["name"] for row in conn.execute("PRAGMA table_info(broker_import_dry_runs)").fetchall()} assert {"source_name", "source_platform", "source_label", "normalized_name", "isin", "ticker", "exchange", "currency", "asset_class", "instrument_id", "mapping_status", "confidence", "quality_flags_json"}.issubset(instrument_mapping_cols) assert {"source_platform", "source_account_label", "normalized_platform", "normalized_account_name", "internal_platform_id", "internal_account_id", "account_type", "currency", "mapping_status"}.issubset(account_mapping_cols) assert {"source_platform", "source_file_type", "source_filename_hash", "detected_snapshot_date", "snapshot_date_status", "candidate_positions", "candidate_cash_rows", "mapped_positions", "blocked_positions", "quality_flags_json", "summary_json", "session_status", "is_current"}.issubset(dry_run_cols) review_cols = {row["name"] for row in conn.execute("PRAGMA table_info(broker_import_review_items)").fetchall()} assert {"review_item_id", "dry_run_id", "source_platform", "source_row_ref", "row_hash", "source_label", "normalized_name", "detected_asset_class", "detected_currency", "quality_flags_json", "review_status", "import_readiness_status", "reviewer_confirmed", "snapshot_date_confirmed", "ticker_exchange_confirmed", "account_mapping_status"}.issubset(review_cols) execution_cols = {row["name"] for row in conn.execute("PRAGMA table_info(broker_import_execution_plans)").fetchall()} assert {"execution_plan_id", "dry_run_id", "review_item_id", "source_platform", "target_account_id", "target_instrument_id", "transaction_type", "snapshot_date", "payload_status", "payload_quality_flags_json", "source_row_hash", "planned_write_summary_json", "execution_status", "transaction_id"}.issubset(execution_cols) transaction_cols = {row["name"] for row in conn.execute("PRAGMA table_info(transactions)").fetchall()} assert {"is_voided", "voided_at", "void_reason", "voided_by", "correction_of_transaction_id", "correction_reason"}.issubset(transaction_cols) def _schema_50_connection(monkeypatch: pytest.MonkeyPatch): conn = connect_memory() current_migration = migrations._create_current_source_coverage_and_truewealth_activity_v1 monkeypatch.setattr(migrations, "_create_current_source_coverage_and_truewealth_activity_v1", lambda _conn: None) monkeypatch.setattr(migrations, "MIGRATION_VERSION", 50) monkeypatch.setattr(migrations, "MIGRATION_NAME", "050_test_baseline") migrations.apply_migrations(conn) monkeypatch.setattr(migrations, "_create_current_source_coverage_and_truewealth_activity_v1", current_migration) diff --git a/tests/unit/test_schema49_fk_safe_phase18.py b/tests/unit/test_schema49_fk_safe_phase18.py index 914da69..647b2db 100644 --- a/tests/unit/test_schema49_fk_safe_phase18.py +++ b/tests/unit/test_schema49_fk_safe_phase18.py @@ -39,50 +39,50 @@ def test_phase18_compatibility_is_idempotent_with_confirmed_transfer_child() -> currency_original, created_at ) VALUES (?, 'from-fixture', 'to-fixture', ?, ?, '1.00', 'CHF', '2026-08-01T00:00:00Z')""", (transfer_id, account_id, account_id), ) conn.execute( """INSERT INTO budget_transfer_pairs( transfer_pair_id, source_candidate_id, source_account_id, source_signed_amount, currency, status, quality_status, confirmed_transfer_id, created_at, updated_at ) VALUES ( 'pair_fk_phase18', ?, ?, '-1.00', 'CHF', 'confirmed', 'exact', ?, '2026-08-01T00:00:00Z', '2026-08-01T00:00:00Z' )""", (candidate_id, account_id, transfer_id), ) transfer_sql = conn.execute( "SELECT sql FROM sqlite_master WHERE type='table' AND name='budget_transfers'" ).fetchone()["sql"] conn.execute(transfer_sql.replace("budget_transfers", "budget_transfers__phase18_fixed", 1)) conn.execute( "INSERT INTO budget_transfers__phase18_fixed SELECT * FROM budget_transfers" ) conn.commit() before_counts = { table: conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0] for table in ("budget_transfers", "budget_transfer_pairs") } _create_budget_phase18_tables(conn) conn.commit() after_counts = { table: conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0] for table in before_counts } assert after_counts == before_counts assert conn.execute("PRAGMA foreign_keys").fetchone()[0] == 1 assert list(conn.execute("PRAGMA foreign_key_check")) == [] assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 def test_fresh_database_reaches_schema_49_with_foreign_keys_enabled() -> None: conn = connect_memory() apply_migrations(conn) - assert get_schema_version(conn) == 51 + assert get_schema_version(conn) == 52 assert conn.execute("PRAGMA foreign_keys").fetchone()[0] == 1 assert list(conn.execute("PRAGMA foreign_key_check")) == [] assert conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" __HERMES_CWD_8d46a20096ed__/home/agent/.hermes/worktrees/FinanceManager-sprint23__HERMES_CWD_8d46a20096ed__