Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
443 lines
15 KiB
Python
443 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import threading
|
|
import uuid
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from datetime import UTC, datetime
|
|
from decimal import Decimal
|
|
|
|
from savings_postgres_testkit import ( # noqa: F401 - 注册 pg_factory fixture
|
|
_pg_factory_fixture,
|
|
)
|
|
from sqlalchemy import func, select, text
|
|
from sqlalchemy.exc import DBAPIError
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from app.models.expense_case import BusinessEvent
|
|
from app.models.financial_connector import (
|
|
FinancialConnectorConfig,
|
|
FinancialConnectorConfigEvent,
|
|
FinancialConnectorEvent,
|
|
FinancialConnectorOperationalEvent,
|
|
)
|
|
from app.models.financial_record import ExpenseClaim
|
|
from app.models.tenant import Tenant
|
|
from app.schemas.financial_connector import (
|
|
FinancialConnectorConfigLifecycleAction,
|
|
FinancialEventEnvelope,
|
|
)
|
|
from app.services.expense_cases import ExpenseCaseService
|
|
from app.services.financial_connector_auth import (
|
|
FinancialConnectorSecretResolver,
|
|
sign_financial_event,
|
|
)
|
|
from app.services.financial_connector_config_lifecycle import (
|
|
FinancialConnectorConfigConflictError,
|
|
FinancialConnectorConfigLifecycleService,
|
|
)
|
|
from app.services.financial_connector_ingestion import (
|
|
FinancialConnectorConflictError,
|
|
FinancialConnectorIngestionService,
|
|
)
|
|
from app.services.financial_connector_operational_events import (
|
|
FinancialConnectorOperationalContext,
|
|
FinancialConnectorOperationalEventService,
|
|
operational_event_candidate,
|
|
)
|
|
|
|
|
|
def test_concurrent_identical_settlement_has_one_fact_and_one_payment(
|
|
pg_factory: sessionmaker[Session],
|
|
) -> None:
|
|
seeded = _seed(pg_factory)
|
|
timestamp = 1_800_000_000
|
|
envelope = _envelope(seeded, event_id=f"settled-{uuid.uuid4().hex}")
|
|
ready = threading.Barrier(2)
|
|
|
|
def ingest_once() -> bool:
|
|
with pg_factory() as db:
|
|
ready.wait(timeout=5)
|
|
result = _ingest(db, envelope, timestamp)
|
|
db.commit()
|
|
return result.replayed
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
outcomes = [
|
|
future.result(timeout=10)
|
|
for future in (pool.submit(ingest_once), pool.submit(ingest_once))
|
|
]
|
|
assert sorted(outcomes) == [False, True]
|
|
with pg_factory() as db:
|
|
claim = db.get(ExpenseClaim, seeded[1])
|
|
assert claim is not None and claim.status == "paid"
|
|
assert (
|
|
db.scalar(
|
|
select(func.count(FinancialConnectorEvent.id)).where(
|
|
FinancialConnectorEvent.tenant_id == seeded[0],
|
|
FinancialConnectorEvent.external_event_id == envelope.external_event_id,
|
|
)
|
|
)
|
|
== 1
|
|
)
|
|
assert (
|
|
db.scalar(
|
|
select(func.count(BusinessEvent.id)).where(
|
|
BusinessEvent.tenant_id == seeded[0],
|
|
BusinessEvent.aggregate_id == seeded[1],
|
|
BusinessEvent.event_type == "payment_completed",
|
|
)
|
|
)
|
|
== 1
|
|
)
|
|
|
|
refund = _envelope(
|
|
seeded,
|
|
event_id=f"refund-{uuid.uuid4().hex}",
|
|
event_type="payment_refunded",
|
|
origin_external_event_id=envelope.external_event_id,
|
|
)
|
|
with pg_factory() as db:
|
|
_ingest(db, refund, timestamp + 1)
|
|
db.commit()
|
|
claim = db.get(ExpenseClaim, seeded[1])
|
|
assert claim is not None and claim.status == "pending_payment"
|
|
second_settlement = _envelope(
|
|
seeded,
|
|
event_id=f"settled-{uuid.uuid4().hex}",
|
|
)
|
|
with pg_factory() as db:
|
|
_ingest(db, second_settlement, timestamp + 2)
|
|
db.commit()
|
|
claim = db.get(ExpenseClaim, seeded[1])
|
|
assert claim is not None and claim.status == "paid"
|
|
|
|
|
|
def test_concurrent_conflicting_payload_has_one_winner_and_facts_are_append_only(
|
|
pg_factory: sessionmaker[Session],
|
|
) -> None:
|
|
seeded = _seed(pg_factory)
|
|
timestamp = 1_800_000_000
|
|
event_id = f"failed-{uuid.uuid4().hex}"
|
|
first = _envelope(seeded, event_id=event_id, event_type="payment_failed")
|
|
second = first.model_copy(
|
|
update={"payload": {**first.payload, "failure_code": "different_failure"}}
|
|
)
|
|
ready = threading.Barrier(2)
|
|
|
|
def ingest_once(envelope: FinancialEventEnvelope) -> str:
|
|
with pg_factory() as db:
|
|
ready.wait(timeout=5)
|
|
try:
|
|
_ingest(db, envelope, timestamp)
|
|
db.commit()
|
|
return "created"
|
|
except FinancialConnectorConflictError as error:
|
|
db.rollback()
|
|
FinancialConnectorOperationalEventService(db).record(error.operational_event)
|
|
db.commit()
|
|
return "conflict"
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
outcomes = [
|
|
future.result(timeout=10)
|
|
for future in (pool.submit(ingest_once, first), pool.submit(ingest_once, second))
|
|
]
|
|
assert sorted(outcomes) == ["conflict", "created"]
|
|
with pg_factory() as db:
|
|
event = db.scalar(
|
|
select(FinancialConnectorEvent).where(
|
|
FinancialConnectorEvent.tenant_id == seeded[0],
|
|
FinancialConnectorEvent.external_event_id == event_id,
|
|
)
|
|
)
|
|
assert event is not None
|
|
assert (
|
|
db.scalar(
|
|
select(func.count(FinancialConnectorOperationalEvent.id)).where(
|
|
FinancialConnectorOperationalEvent.tenant_id == seeded[0],
|
|
FinancialConnectorOperationalEvent.event_type == "payload_conflict",
|
|
)
|
|
)
|
|
== 1
|
|
)
|
|
savepoint = db.begin_nested()
|
|
try:
|
|
try:
|
|
db.execute(
|
|
text(
|
|
"UPDATE financial_connector_events SET error_code = 'tampered' "
|
|
"WHERE id = :event_id"
|
|
),
|
|
{"event_id": event.id},
|
|
)
|
|
except DBAPIError:
|
|
pass
|
|
else: # pragma: no cover - PostgreSQL trigger must reject
|
|
raise AssertionError("append-only event unexpectedly accepted UPDATE")
|
|
finally:
|
|
savepoint.rollback()
|
|
|
|
|
|
def test_concurrent_operational_candidate_has_one_append_only_fact(
|
|
pg_factory: sessionmaker[Session],
|
|
) -> None:
|
|
tenant_id = f"tenant-operational-{uuid.uuid4().hex}"
|
|
config_id = str(uuid.uuid4())
|
|
with pg_factory() as db:
|
|
db.add(
|
|
FinancialConnectorConfig(
|
|
id=config_id,
|
|
tenant_id=tenant_id,
|
|
provider="operational-bank",
|
|
environment="production",
|
|
key_version="v1",
|
|
secret_ref=f"connector/{tenant_id}",
|
|
allowed_event_types_json=["payment_settled"],
|
|
clock_skew_seconds=300,
|
|
status="active",
|
|
created_by="postgres-test",
|
|
)
|
|
)
|
|
db.commit()
|
|
context = FinancialConnectorOperationalContext(
|
|
tenant_id=tenant_id,
|
|
config_id=config_id,
|
|
provider="operational-bank",
|
|
environment="production",
|
|
request_fingerprint="hmac-sha256:" + "a" * 64,
|
|
external_event_fingerprint="hmac-sha256:" + "b" * 64,
|
|
)
|
|
occurred_at = datetime.now(UTC)
|
|
candidate = operational_event_candidate(
|
|
context,
|
|
event_type="replay",
|
|
reason_code="duplicate_external_event",
|
|
occurred_at=occurred_at,
|
|
)
|
|
ready = threading.Barrier(2)
|
|
|
|
def record_once() -> None:
|
|
with pg_factory() as db:
|
|
ready.wait(timeout=5)
|
|
FinancialConnectorOperationalEventService(db).record(candidate)
|
|
db.commit()
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
for future in (pool.submit(record_once), pool.submit(record_once)):
|
|
future.result(timeout=10)
|
|
|
|
with pg_factory() as db:
|
|
rows = list(
|
|
db.scalars(
|
|
select(FinancialConnectorOperationalEvent).where(
|
|
FinancialConnectorOperationalEvent.tenant_id == tenant_id
|
|
)
|
|
).all()
|
|
)
|
|
assert len(rows) == 1
|
|
savepoint = db.begin_nested()
|
|
try:
|
|
try:
|
|
db.execute(
|
|
text("DELETE FROM financial_connector_operational_events WHERE id = :event_id"),
|
|
{"event_id": rows[0].id},
|
|
)
|
|
except DBAPIError:
|
|
pass
|
|
else: # pragma: no cover - PostgreSQL trigger must reject
|
|
raise AssertionError("append-only operational event accepted DELETE")
|
|
finally:
|
|
savepoint.rollback()
|
|
|
|
|
|
def test_concurrent_config_activation_has_one_version_winner(
|
|
pg_factory: sessionmaker[Session],
|
|
) -> None:
|
|
tenant_id = f"tenant-config-{uuid.uuid4().hex}"
|
|
config_id = str(uuid.uuid4())
|
|
secret_ref = f"connector/{tenant_id}"
|
|
secret = f"strong-secret-{tenant_id}"
|
|
with pg_factory() as db:
|
|
db.add(
|
|
FinancialConnectorConfig(
|
|
id=config_id,
|
|
tenant_id=tenant_id,
|
|
provider="versioned-bank",
|
|
environment="production",
|
|
key_version="v1",
|
|
secret_ref=secret_ref,
|
|
allowed_event_types_json=["payment_settled"],
|
|
clock_skew_seconds=300,
|
|
status="disabled",
|
|
version=1,
|
|
created_by="postgres-test",
|
|
)
|
|
)
|
|
db.commit()
|
|
ready = threading.Barrier(2)
|
|
|
|
def activate_once(index: int) -> str:
|
|
with pg_factory() as db:
|
|
ready.wait(timeout=5)
|
|
try:
|
|
FinancialConnectorConfigLifecycleService(
|
|
db,
|
|
secrets=FinancialConnectorSecretResolver({secret_ref: secret}),
|
|
).activate(
|
|
tenant_id=tenant_id,
|
|
config_id=config_id,
|
|
payload=FinancialConnectorConfigLifecycleAction(
|
|
expected_version=1,
|
|
request_id=f"config-activate-{index:03d}",
|
|
reason="并发激活只能有一个版本胜者。",
|
|
),
|
|
actor_id=f"postgres-operator-{index}",
|
|
)
|
|
db.commit()
|
|
return "activated"
|
|
except FinancialConnectorConfigConflictError:
|
|
db.rollback()
|
|
return "conflict"
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
outcomes = [
|
|
future.result(timeout=10)
|
|
for future in (pool.submit(activate_once, 1), pool.submit(activate_once, 2))
|
|
]
|
|
assert sorted(outcomes) == ["activated", "conflict"]
|
|
with pg_factory() as db:
|
|
config = db.get(FinancialConnectorConfig, config_id)
|
|
assert config is not None
|
|
assert config.status == "active" and config.version == 2
|
|
assert (
|
|
db.scalar(
|
|
select(func.count(FinancialConnectorConfigEvent.id)).where(
|
|
FinancialConnectorConfigEvent.tenant_id == tenant_id,
|
|
FinancialConnectorConfigEvent.config_id == config_id,
|
|
FinancialConnectorConfigEvent.action == "activated",
|
|
)
|
|
)
|
|
== 1
|
|
)
|
|
|
|
|
|
def _seed(factory: sessionmaker[Session]) -> tuple[str, str, str, str]:
|
|
tenant_id = f"tenant-connector-{uuid.uuid4().hex}"
|
|
claim_id = str(uuid.uuid4())
|
|
config_id = str(uuid.uuid4())
|
|
with factory() as db:
|
|
db.add(
|
|
Tenant(
|
|
tenant_id=tenant_id,
|
|
tenant_code=tenant_id,
|
|
name="连接器并发探针租户",
|
|
status="active",
|
|
)
|
|
)
|
|
db.flush()
|
|
claim = ExpenseClaim(
|
|
id=claim_id,
|
|
tenant_id=tenant_id,
|
|
claim_no=f"BX-PG-{uuid.uuid4().hex[:12]}",
|
|
employee_name="PostgreSQL 测试员工",
|
|
department_name="测试部",
|
|
expense_type="travel",
|
|
reason="连接器并发验证",
|
|
location="上海",
|
|
amount=Decimal("66.00"),
|
|
currency="CNY",
|
|
invoice_count=1,
|
|
occurred_at=datetime.now(UTC),
|
|
submitted_at=datetime.now(UTC),
|
|
status="pending_payment",
|
|
approval_stage="待付款",
|
|
risk_flags_json=[],
|
|
)
|
|
db.add(claim)
|
|
db.flush()
|
|
ExpenseCaseService(db).ensure_case_for_claim(claim, tenant_id=tenant_id)
|
|
db.add(
|
|
FinancialConnectorConfig(
|
|
id=config_id,
|
|
tenant_id=tenant_id,
|
|
provider="postgres-bank",
|
|
environment="production",
|
|
key_version="v1",
|
|
secret_ref=f"connector/{tenant_id}",
|
|
allowed_event_types_json=[
|
|
"payment_settled",
|
|
"payment_failed",
|
|
"payment_refunded",
|
|
],
|
|
clock_skew_seconds=300,
|
|
status="active",
|
|
created_by="postgres-test",
|
|
)
|
|
)
|
|
db.commit()
|
|
return tenant_id, claim_id, config_id, claim.claim_no
|
|
|
|
|
|
def _envelope(
|
|
seeded: tuple[str, str, str, str],
|
|
*,
|
|
event_id: str,
|
|
event_type: str = "payment_settled",
|
|
origin_external_event_id: str | None = None,
|
|
) -> FinancialEventEnvelope:
|
|
tenant_id, claim_id, _, claim_no = seeded
|
|
return FinancialEventEnvelope(
|
|
tenant_id=tenant_id,
|
|
external_event_id=event_id,
|
|
event_type=event_type,
|
|
occurred_at=datetime.now(UTC),
|
|
correlation_id=f"correlation-{event_id}"[:64],
|
|
payload={
|
|
"claim_id": claim_id,
|
|
"claim_reference": claim_no,
|
|
"amount": "66.00",
|
|
"currency": "CNY",
|
|
"external_payment_reference": f"PAY-{event_id}",
|
|
"failure_code": "provider_rejected",
|
|
**(
|
|
{"origin_external_event_id": origin_external_event_id}
|
|
if origin_external_event_id
|
|
else {}
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
def _ingest(
|
|
db: Session,
|
|
envelope: FinancialEventEnvelope,
|
|
timestamp: int,
|
|
):
|
|
claim = db.get(ExpenseClaim, str(envelope.payload["claim_id"]))
|
|
assert claim is not None
|
|
envelope = envelope.model_copy(
|
|
update={"payload": {**envelope.payload, "claim_reference": claim.claim_no}}
|
|
)
|
|
secret_ref = f"connector/{envelope.tenant_id}"
|
|
secret = f"secret-{envelope.tenant_id}"
|
|
signature = sign_financial_event(
|
|
envelope,
|
|
timestamp=timestamp,
|
|
secret=secret,
|
|
tenant_id=envelope.tenant_id,
|
|
provider="postgres-bank",
|
|
key_version="v1",
|
|
)
|
|
return FinancialConnectorIngestionService(
|
|
db,
|
|
secrets=FinancialConnectorSecretResolver({secret_ref: secret}),
|
|
now_epoch=timestamp,
|
|
).ingest(
|
|
envelope,
|
|
tenant_header=envelope.tenant_id,
|
|
provider_header="postgres-bank",
|
|
key_version_header="v1",
|
|
timestamp_header=str(timestamp),
|
|
signature_header=signature,
|
|
)
|