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, )