Files
X-Financial/server/tests/test_financial_connector_mock_observability.py
caoxiaozhu 787bc3a481 feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
2026-07-17 14:14:08 +08:00

450 lines
16 KiB
Python

from __future__ import annotations
import json
import uuid
from collections.abc import Generator
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
import app.models # noqa: F401 - 注册完整 metadata
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.api.v1.endpoints.financial_connectors import router
from app.core.config import get_settings
from app.db.base_class import Base
from app.models.expense_case import BusinessEvent
from app.models.financial_connector import (
FinancialConnectorConfig,
FinancialConnectorEvent,
PaymentReconciliationCase,
)
from app.models.financial_record import ExpenseClaim
from app.models.savings import SavingsRealization
from app.schemas.financial_connector import FinancialConnectorSimulationCreate
from app.services.expense_cases import ExpenseCaseService
from app.services.financial_connector_auth import FinancialConnectorSecretResolver
from app.services.financial_connector_mock_adapter import (
FinancialConnectorMockAdapter,
FinancialConnectorMockAdapterError,
)
from app.services.financial_connector_observability import (
FinancialConnectorObservabilityPermissionError,
FinancialConnectorObservabilityService,
)
from app.services.financial_connector_payment_evidence import (
FinancialConnectorPaymentEvidenceService,
)
@pytest.fixture()
def db() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
with factory() as session:
yield session
Base.metadata.drop_all(engine)
engine.dispose()
def test_mock_adapter_covers_deterministic_scenarios_without_core_side_effects(
db: Session,
) -> None:
config = _config(db)
_config(db, provider=config.provider, key_version="v0", status="rotating")
claim = _claim(db)
db.commit()
adapter = FinancialConnectorMockAdapter(
db,
secrets=FinancialConnectorSecretResolver(
{"connector/runtime-test": "runtime-server-only-secret"}
),
now=datetime(2026, 7, 16, 12, 0, tzinfo=UTC),
)
expected_outcomes = {
"success": ["accepted"],
"failure": ["expected_exception"],
"out_of_order": ["expected_exception"],
"duplicate": ["accepted", "replayed"],
"conflict": ["accepted", "conflict"],
"refund": ["accepted", "accepted"],
"erp_receipt": ["accepted", "accepted"],
}
results = {}
for scenario, outcomes in expected_outcomes.items():
result = adapter.run(
tenant_id="default",
config_id=config.id,
payload=FinancialConnectorSimulationCreate(
claim_id=claim.id,
scenario=scenario,
request_id=f"runtime-{scenario}-request-001",
),
)
db.commit()
results[scenario] = result
assert [step.outcome for step in result.steps] == outcomes
assert result.projection_scope == "simulation_only"
assert result.core_side_effects_allowed is False
serialized = result.model_dump_json().lower()
assert '"payload":' not in serialized
assert '"signature":' not in serialized
repeated = adapter.run(
tenant_id="default",
config_id=config.id,
payload=FinancialConnectorSimulationCreate(
claim_id=claim.id,
scenario="success",
request_id="runtime-success-request-001",
),
)
db.commit()
assert repeated.request_fingerprint == results["success"].request_fingerprint
assert repeated.steps[0].outcome == "replayed"
assert db.scalar(select(func.count(FinancialConnectorEvent.id))) == 9
assert db.scalar(select(func.count(PaymentReconciliationCase.id))) == 0
assert db.scalar(select(func.count(BusinessEvent.id))) == 0
assert db.scalar(select(func.count(SavingsRealization.id))) == 0
db.refresh(claim)
assert claim.status == "pending_payment"
assert claim.approval_stage == "待付款"
assert claim.risk_flags_json == []
assert all(
event.response_json["projection_scope"] == "simulation_only"
for event in db.scalars(select(FinancialConnectorEvent)).all()
)
def test_mock_adapter_rejects_cross_tenant_production_and_inactive_configs(
db: Session,
) -> None:
tenant_a = _config(db, tenant_id="tenant-a", provider="tenant-a-bank")
tenant_b_claim = _claim(db, tenant_id="tenant-b")
production = _config(db, provider="production-bank", environment="production")
inactive = _config(db, provider="disabled-bank", status="disabled")
db.commit()
adapter = FinancialConnectorMockAdapter(
db,
secrets=FinancialConnectorSecretResolver(
{"connector/runtime-test": "runtime-server-only-secret"}
),
)
payload = FinancialConnectorSimulationCreate(
claim_id=tenant_b_claim.id,
scenario="success",
request_id="runtime-boundary-request-001",
)
with pytest.raises(LookupError, match="报销单不存在"):
adapter.run(tenant_id="tenant-a", config_id=tenant_a.id, payload=payload)
with pytest.raises(FinancialConnectorMockAdapterError, match="生产"):
adapter.run(tenant_id="default", config_id=production.id, payload=payload)
with pytest.raises(FinancialConnectorMockAdapterError, match="激活"):
adapter.run(tenant_id="default", config_id=inactive.id, payload=payload)
def test_observability_aggregates_real_facts_and_durable_zero_metrics(
db: Session,
) -> None:
config = _config(db)
claim = _claim(db)
adapter = FinancialConnectorMockAdapter(
db,
secrets=FinancialConnectorSecretResolver(
{"connector/runtime-test": "runtime-server-only-secret"}
),
now=datetime(2026, 7, 16, 12, 0, tzinfo=UTC),
)
for scenario in ("success", "failure", "out_of_order"):
adapter.run(
tenant_id="default",
config_id=config.id,
payload=FinancialConnectorSimulationCreate(
claim_id=claim.id,
scenario=scenario,
request_id=f"runtime-observe-{scenario}-001",
),
)
db.add(
PaymentReconciliationCase(
id=str(uuid.uuid4()),
tenant_id="default",
provider=config.provider,
claim_id=claim.id,
expected_amount=claim.amount,
actual_amount=claim.amount,
amount_difference=Decimal("0.00"),
expected_currency="CNY",
actual_currency="CNY",
expected_reference=claim.claim_no,
status="exception",
exception_code="test_anomaly",
erp_status="pending_posting",
last_connector_event_id="test-observability-event",
version=1,
created_at=datetime(2026, 7, 16, 12, 0, tzinfo=UTC),
updated_at=datetime(2026, 7, 16, 12, 0, tzinfo=UTC),
)
)
db.commit()
service = FinancialConnectorObservabilityService(
db,
now=datetime(2026, 7, 16, 12, 5, tzinfo=UTC),
)
result = service.read_for_current_user(_user("finance", roles=["finance"]), window_hours=24)
other = service.read_for_tenant("tenant-other", window_hours=24)
assert result.summary.event_count == 3
assert result.summary.processed_event_count == 1
assert result.summary.failed_event_count == 2
assert result.summary.failure_rate == pytest.approx(0.6667)
assert result.summary.backlog_count == 0
assert result.summary.reconciliation_anomaly_count == 1
assert result.summary.retry_count == 0
assert result.summary.auth_failure_count == 0
assert result.summary.signature_failure_count == 0
assert result.summary.payload_conflict_count == 0
assert result.retry_metric.status == "available"
assert result.auth_failure_metric.status == "available"
assert result.signature_failure_metric.status == "available"
assert result.payload_conflict_metric.status == "available"
assert result.source_revision == "20260716_0022"
assert result.as_of == datetime(2026, 7, 16, 12, 5, tzinfo=UTC)
assert all(item.evidence_classification == "simulated_connector" for item in result.items)
assert sum(item.reconciliation_anomaly_count for item in result.items) == 1
assert other.summary.event_count == 0
assert other.items == []
with pytest.raises(FinancialConnectorObservabilityPermissionError):
service.read_for_current_user(_user("ordinary"), window_hours=24)
def test_payment_evidence_distinguishes_manual_external_and_unpaid() -> None:
manual = _claim_object(status="paid")
manual.risk_flags_json = [
{
"source": "payment",
"event_type": "expense_claim_payment_completed",
"created_at": "2026-07-16T10:00:00+00:00",
}
]
external = _claim_object(status="paid")
external.risk_flags_json = [
{
"source": "external_payment",
"event_type": "expense_claim_external_payment_settled",
"provider": "verified-bank",
"verification_level": "production_verified",
"evidence_classification": "external_cash",
"external_reference_tail": "12345678",
"created_at": "2026-07-16T11:00:00+00:00",
}
]
unpaid = _claim_object(status="pending_payment")
manual_read = FinancialConnectorPaymentEvidenceService.read(manual)
external_read = FinancialConnectorPaymentEvidenceService.read(external)
unpaid_read = FinancialConnectorPaymentEvidenceService.read(unpaid)
assert manual_read.evidence_classification == "internal_manual_payment"
assert manual_read.trust_level == "low"
assert external_read.evidence_classification == "external_cash"
assert external_read.trust_level == "high"
assert external_read.external_reference_tail == "12345678"
assert unpaid_read.evidence_classification == "none"
@pytest.fixture()
def http_context(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv(
"FINANCIAL_CONNECTOR_HMAC_KEYS_JSON",
json.dumps({"connector/runtime-test": "runtime-server-only-secret"}),
)
get_settings.cache_clear()
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
with factory() as session:
config = _config(session)
claim = _claim(session)
claim.status = "paid"
claim.approval_stage = "已付款"
claim.risk_flags_json = [
{
"source": "payment",
"event_type": "expense_claim_payment_completed",
"created_at": "2026-07-16T10:00:00+00:00",
}
]
session.commit()
app = FastAPI()
app.include_router(router, prefix="/api/v1")
user_box = {"current": _user("platform-admin", is_admin=True)}
def override_db() -> Generator[Session, None, None]:
with factory() as session:
yield session
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[get_current_user] = lambda: user_box["current"]
client = TestClient(app)
try:
yield client, user_box, config.id, claim.id
finally:
client.close()
app.dependency_overrides.clear()
Base.metadata.drop_all(engine)
engine.dispose()
get_settings.cache_clear()
def test_runtime_http_permissions_tenant_scope_and_replay(http_context) -> None:
client, user_box, config_id, claim_id = http_context
simulation = client.post(
f"/api/v1/financial-connectors/admin/tenants/default/configs/{config_id}/simulate",
json={
"claim_id": claim_id,
"scenario": "success",
"request_id": "runtime-http-simulation-001",
},
)
replay = client.post(
f"/api/v1/financial-connectors/admin/tenants/default/configs/{config_id}/simulate",
json={
"claim_id": claim_id,
"scenario": "success",
"request_id": "runtime-http-simulation-001",
},
)
assert simulation.status_code == 200
assert simulation.json()["core_side_effects_allowed"] is False
assert replay.json()["steps"][0]["outcome"] == "replayed"
user_box["current"] = _user("finance", roles=["finance"])
observed = client.get("/api/v1/financial-connectors/observability?window_hours=24")
evidence = client.get(f"/api/v1/financial-connectors/payment-evidence/{claim_id}")
assert observed.status_code == 200
assert observed.json()["summary"]["event_count"] == 1
assert observed.json()["summary"]["retry_count"] == 1
assert observed.json()["retry_metric"]["status"] == "available"
assert observed.json()["source_revision"] == "20260716_0022"
assert evidence.status_code == 200
assert evidence.json()["evidence_classification"] == "internal_manual_payment"
forbidden_simulation = client.post(
f"/api/v1/financial-connectors/admin/tenants/default/configs/{config_id}/simulate",
json={
"claim_id": claim_id,
"scenario": "success",
"request_id": "runtime-http-forbidden-001",
},
)
assert forbidden_simulation.status_code == 403
user_box["current"] = _user("other-finance", tenant_id="tenant-other", roles=["finance"])
assert (
client.get(f"/api/v1/financial-connectors/payment-evidence/{claim_id}").status_code == 404
)
assert (
client.get("/api/v1/financial-connectors/observability").json()["tenant_id"]
== "tenant-other"
)
user_box["current"] = _user("ordinary")
assert client.get("/api/v1/financial-connectors/observability").status_code == 403
def _config(
db: Session,
*,
tenant_id: str = "default",
provider: str = "runtime-bank",
environment: str = "mock",
status: str = "active",
key_version: str = "v1",
) -> FinancialConnectorConfig:
row = FinancialConnectorConfig(
id=str(uuid.uuid4()),
tenant_id=tenant_id,
provider=provider,
environment=environment,
key_version=key_version,
secret_ref="connector/runtime-test",
allowed_event_types_json=[
"payment_settled",
"payment_failed",
"erp_posted",
"erp_posting_failed",
"payment_refunded",
"payment_reversed",
],
clock_skew_seconds=300,
status=status,
created_by="platform-admin",
)
db.add(row)
db.flush()
return row
def _claim(db: Session, *, tenant_id: str = "default") -> ExpenseClaim:
row = _claim_object()
db.add(row)
db.flush()
if tenant_id != "default":
ExpenseCaseService(db).ensure_case_for_claim(row, tenant_id=tenant_id)
return row
def _claim_object(*, status: str = "pending_payment") -> ExpenseClaim:
return ExpenseClaim(
id=str(uuid.uuid4()),
claim_no=f"BX-RUNTIME-{uuid.uuid4().hex[:10].upper()}",
employee_name="连接器运行测试员工",
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=status,
approval_stage="待付款" if status == "pending_payment" else "已付款",
risk_flags_json=[],
)
def _user(
username: str,
*,
tenant_id: str = "default",
roles: list[str] | None = None,
is_admin: bool = False,
) -> CurrentUserContext:
return CurrentUserContext(
username=username,
name=username,
role_codes=list(roles or []),
is_admin=is_admin,
tenant_id=tenant_id,
)