feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
339
server/tests/financial_connector_migration_assertions.py
Normal file
339
server/tests/financial_connector_migration_assertions.py
Normal file
@@ -0,0 +1,339 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user