340 lines
13 KiB
Python
340 lines
13 KiB
Python
|
|
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()
|