Files
X-Financial/server/tests/financial_connector_migration_assertions.py

340 lines
13 KiB
Python
Raw Normal View History

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()