from __future__ import annotations from typing import Any import pytest from sqlalchemy import inspect, text from sqlalchemy.engine import Engine from sqlalchemy.exc import DBAPIError, IntegrityError from app.models.financial_connector import ( FinancialConnectorConfig, FinancialConnectorConfigEvent, FinancialConnectorEvent, FinancialConnectorOperationalEvent, PaymentReconciliationCase, PaymentReconciliationEvent, ) FINANCIAL_CONNECTOR_MODELS = ( FinancialConnectorConfig, FinancialConnectorConfigEvent, FinancialConnectorEvent, FinancialConnectorOperationalEvent, PaymentReconciliationCase, PaymentReconciliationEvent, ) def _assert_financial_connector_head_schema(engine: Engine) -> None: inspector = inspect(engine) for model in FINANCIAL_CONNECTOR_MODELS: table = model.__table__ live_columns = { str(column["name"]): bool(column["nullable"]) for column in inspector.get_columns(table.name, schema="public") } declared_columns = {column.name: bool(column.nullable) for column in table.columns} assert live_columns == declared_columns live_constraint_names = { str(item["name"]) for loader in ( inspector.get_unique_constraints, inspector.get_check_constraints, inspector.get_foreign_keys, ) for item in loader(table.name, schema="public") if item.get("name") } declared_constraint_names = { str(constraint.name) for constraint in table.constraints if constraint.name is not None } assert live_constraint_names == declared_constraint_names live_index_names = { str(item["name"]) for item in inspector.get_indexes(table.name, schema="public") if not item.get("duplicates_constraint") } declared_index_names = {str(index.name) for index in table.indexes} assert live_index_names == declared_index_names for table_name in ( "financial_connector_config_events", "financial_connector_events", "financial_connector_operational_events", "payment_reconciliation_events", ): with engine.connect() as connection: trigger_count = int( connection.scalar( text( "SELECT COUNT(*) FROM pg_trigger trigger " "JOIN pg_class relation ON relation.oid = trigger.tgrelid " "WHERE relation.relname = :table_name " "AND trigger.tgname = :trigger_name " "AND NOT trigger.tgisinternal" ), { "table_name": table_name, "trigger_name": f"trg_{table_name}_append_only", }, ) or 0 ) assert trigger_count == 1 def _assert_financial_connector_runtime_invariants(engine: Engine) -> None: def rejected( connection: Any, statement: Any, parameters: dict[str, Any], error_type: type[DBAPIError] = IntegrityError, ) -> None: savepoint = connection.begin_nested() try: with pytest.raises(error_type): connection.execute(statement, parameters) finally: if savepoint.is_active: savepoint.rollback() config_insert = text( """ INSERT INTO financial_connector_configs ( id, tenant_id, provider, environment, key_version, secret_ref, allowed_event_types_json, clock_skew_seconds, status, version, created_by ) VALUES ( :id, :tenant_id, 'probe-bank', 'production', 'v1', 'server/probe', '["payment_settled"]', 300, 'active', 1, 'migration-probe' ) """ ) event_insert = text( """ INSERT INTO financial_connector_events ( id, tenant_id, config_id, provider, environment, direction, external_event_id, event_type, occurred_at, key_version, verification_level, request_fingerprint, content_hash, processing_status, claim_id, expense_case_id, correlation_id, normalized_payload_json, response_json ) VALUES ( :id, :tenant_id, :config_id, 'probe-bank', 'production', 'inbound', :external_event_id, 'payment_settled', now(), 'v1', 'production_verified', :fingerprint, :content_hash, 'processed', 'claim-soft-ref', :expense_case_id, 'probe-correlation', '{}', '{}' ) """ ) with engine.connect() as connection: transaction = connection.begin() try: connection.execute( text( """ INSERT INTO expense_cases ( id, tenant_id, case_no, scene_code, title, current_stage, status ) VALUES ( 'connector-case-a', 'tenant-a', 'CASE-CONNECTOR-A', 'travel', '连接器迁移探针', 'paying', 'active' ) """ ) ) connection.execute( config_insert, {"id": "connector-config-a", "tenant_id": "tenant-a"}, ) connection.execute( text( """ INSERT INTO financial_connector_operational_events ( id, tenant_id, config_id, provider, environment, event_type, reason_code, request_fingerprint, external_event_fingerprint, idempotency_key ) VALUES ( 'connector-operational-event-a', 'tenant-a', 'connector-config-a', 'probe-bank', 'production', 'replay', 'duplicate_external_event', :request_fingerprint, :external_event_fingerprint, :idempotency_key ) """ ), { "request_fingerprint": "hmac-sha256:" + "a" * 64, "external_event_fingerprint": "hmac-sha256:" + "b" * 64, "idempotency_key": "sha256:" + "c" * 64, }, ) rejected( connection, text( """ INSERT INTO financial_connector_operational_events ( id, tenant_id, config_id, provider, environment, event_type, reason_code, request_fingerprint, external_event_fingerprint, idempotency_key ) VALUES ( 'connector-operational-event-cross', 'tenant-b', 'connector-config-a', 'probe-bank', 'production', 'replay', 'duplicate_external_event', :request_fingerprint, :external_event_fingerprint, :idempotency_key ) """ ), { "request_fingerprint": "hmac-sha256:" + "d" * 64, "external_event_fingerprint": "hmac-sha256:" + "e" * 64, "idempotency_key": "sha256:" + "f" * 64, }, ) rejected( connection, text( """ INSERT INTO financial_connector_operational_events ( id, tenant_id, config_id, provider, environment, event_type, reason_code, request_fingerprint, external_event_fingerprint, idempotency_key ) VALUES ( 'connector-operational-weak', 'tenant-a', 'connector-config-a', 'probe-bank', 'production', 'auth_failure', 'signature_invalid', :request_fingerprint, :external_event_fingerprint, :idempotency_key ) """ ), { "request_fingerprint": "sha256:" + "a" * 64, "external_event_fingerprint": "hmac-sha256:" + "b" * 64, "idempotency_key": "sha256:" + "d" * 64, }, ) connection.execute( text( """ INSERT INTO financial_connector_config_events ( id, tenant_id, config_id, action, actor_id, request_id, reason, expected_version, before_json, after_json ) VALUES ( 'connector-config-event-a', 'tenant-a', 'connector-config-a', 'activated', 'migration-probe', 'migration-request-001', '迁移运行时约束探针', 1, '{}', '{"status": "active", "version": 2}' ) """ ) ) rejected( connection, text( "UPDATE financial_connector_configs SET version = 0 " "WHERE id = 'connector-config-a'" ), {}, ) connection.execute( event_insert, { "id": "connector-event-a", "tenant_id": "tenant-a", "config_id": "connector-config-a", "external_event_id": "external-a", "fingerprint": "sha256:" + "a" * 64, "content_hash": "sha256:" + "b" * 64, "expense_case_id": "connector-case-a", }, ) rejected( connection, event_insert, { "id": "connector-event-cross", "tenant_id": "tenant-b", "config_id": "connector-config-a", "external_event_id": "external-cross", "fingerprint": "sha256:" + "c" * 64, "content_hash": "sha256:" + "d" * 64, "expense_case_id": "connector-case-a", }, ) connection.execute( text( """ INSERT INTO payment_reconciliation_cases ( id, tenant_id, provider, claim_id, expense_case_id, expected_amount, actual_amount, amount_difference, expected_currency, actual_currency, expected_reference, status, erp_status, last_connector_event_id, version ) VALUES ( 'reconciliation-a', 'tenant-a', 'probe-bank', 'claim-soft-ref', 'connector-case-a', 10, 10, 0, 'CNY', 'CNY', 'BX-PROBE', 'matched', 'pending_posting', 'connector-event-a', 1 ) """ ) ) connection.execute( text( """ INSERT INTO payment_reconciliation_events ( id, tenant_id, reconciliation_case_id, connector_event_id, action, actor_type, actor_id, request_fingerprint, before_json, after_json, response_json, correlation_id ) VALUES ( 'reconciliation-event-a', 'tenant-a', 'reconciliation-a', 'connector-event-a', 'auto_matched', 'connector', 'probe-bank', :fingerprint, '{}', '{}', '{}', 'probe-correlation' ) """ ), {"fingerprint": "sha256:" + "e" * 64}, ) rejected( connection, text( "UPDATE financial_connector_config_events SET reason = 'tampered' " "WHERE id = 'connector-config-event-a'" ), {}, DBAPIError, ) rejected( connection, text( "UPDATE financial_connector_events SET error_code = 'tampered' " "WHERE id = 'connector-event-a'" ), {}, DBAPIError, ) rejected( connection, text( "UPDATE financial_connector_operational_events " "SET reason_code = 'tampered' " "WHERE id = 'connector-operational-event-a'" ), {}, DBAPIError, ) rejected( connection, text( "DELETE FROM payment_reconciliation_events " "WHERE id = 'reconciliation-event-a'" ), {}, DBAPIError, ) finally: transaction.rollback()