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