Files
X-Financial/server/tests/test_approval_risk_concurrency_postgres.py
2026-07-16 15:34:58 +08:00

226 lines
7.9 KiB
Python

from __future__ import annotations
import os
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from sqlalchemy import create_engine, func, select
from sqlalchemy.engine import make_url
from sqlalchemy.orm import Session, sessionmaker
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.approval_action import ApprovalActionLedger
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.schemas.risk_disposition import RiskDispositionActionCreate
from app.services.expense_claim_risk_gate import ExpenseClaimRiskBlockedError
from app.services.expense_claims import ExpenseClaimService
from app.services.risk_dispositions import RiskDispositionService
DATABASE_URL = os.environ.get("MIGRATION_TEST_DATABASE_URL", "").strip()
def test_disposition_reopen_and_approval_share_claim_lock(
monkeypatch: pytest.MonkeyPatch,
) -> None:
database_url = _require_disposable_database_url()
monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
engine = create_engine(database_url, pool_pre_ping=True)
Base.metadata.create_all(bind=engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
suffix = uuid.uuid4().hex[:12]
claim_id = f"claim-risk-lock-{suffix}"
observation_id = f"risk-lock-{suffix}"
disposition_id = f"disposition-lock-{suffix}"
manager_email = f"manager-{suffix}@example.com"
manager_user = CurrentUserContext(
username=manager_email,
name="并发审批经理",
role_codes=["manager"],
is_admin=False,
)
with factory() as db:
_seed_locked_risk_case(
db,
claim_id=claim_id,
observation_id=observation_id,
disposition_id=disposition_id,
manager_email=manager_email,
suffix=suffix,
)
claim_locked = threading.Event()
release_disposition = threading.Event()
approval_started = threading.Event()
from app.services import risk_dispositions as risk_disposition_module
original_apply_action = risk_disposition_module._apply_action
def pause_after_claim_lock(*args, **kwargs):
claim_locked.set()
if not release_disposition.wait(timeout=5):
raise TimeoutError("test did not release risk disposition")
return original_apply_action(*args, **kwargs)
monkeypatch.setattr(risk_disposition_module, "_apply_action", pause_after_claim_lock)
def reopen_risk() -> str:
with factory() as db:
result = RiskDispositionService(db).execute_action(
observation_id,
RiskDispositionActionCreate(
action="confirm",
expected_version=1,
request_id=f"request-risk-reopen-{suffix}",
comment="复核后确认风险成立",
),
tenant_id="default",
actor_id=manager_email,
actor_name="并发审批经理",
current_user=manager_user,
)
return result.disposition.adjudication
def approve_claim() -> str:
approval_started.set()
with factory() as db:
try:
ExpenseClaimService(db).approve_claim(
claim_id,
manager_user,
opinion="同意",
request_id=f"request-approve-after-risk-{suffix}",
expected_status="submitted",
expected_approval_stage="直属领导审批",
)
except ExpenseClaimRiskBlockedError:
return "blocked"
return "approved"
try:
with ThreadPoolExecutor(max_workers=2) as pool:
risk_future = pool.submit(reopen_risk)
assert claim_locked.wait(timeout=5)
approval_future = pool.submit(approve_claim)
assert approval_started.wait(timeout=5)
time.sleep(0.2)
assert not approval_future.done()
release_disposition.set()
assert risk_future.result(timeout=5) == "confirmed"
assert approval_future.result(timeout=5) == "blocked"
with factory() as db:
claim = db.get(ExpenseClaim, claim_id)
disposition = db.get(RiskDisposition, disposition_id)
assert claim is not None and claim.approval_stage == "直属领导审批"
assert disposition is not None and disposition.adjudication == "confirmed"
assert (
db.scalar(
select(func.count())
.select_from(ApprovalActionLedger)
.where(ApprovalActionLedger.claim_id == claim_id)
)
== 0
)
finally:
release_disposition.set()
engine.dispose()
def _seed_locked_risk_case(
db: Session,
*,
claim_id: str,
observation_id: str,
disposition_id: str,
manager_email: str,
suffix: str,
) -> None:
manager = Employee(
id=f"manager-risk-lock-{suffix}",
employee_no=f"M-RISK-LOCK-{suffix}",
name="并发审批经理",
email=manager_email,
)
employee = Employee(
id=f"employee-risk-lock-{suffix}",
employee_no=f"E-RISK-LOCK-{suffix}",
name="并发风险员工",
email=f"employee-{suffix}@example.com",
manager=manager,
)
now = datetime.now(UTC)
claim = ExpenseClaim(
id=claim_id,
claim_no=f"EXP-RISK-LOCK-{suffix}",
employee=employee,
employee_name=employee.name,
department_name="风控部",
expense_type="travel",
reason="客户拜访",
location="上海",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=1,
occurred_at=now,
submitted_at=now,
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
observation = RiskObservation(
id=observation_id,
tenant_id="default",
observation_key=f"risk:claim-lock:{suffix}",
subject_type="expense_claim",
subject_key=f"claim:{claim_id}",
subject_label=claim.claim_no,
claim_id=claim_id,
claim_no=claim.claim_no,
risk_type="duplicate_invoice",
risk_signal="duplicate_invoice",
title="重复票据风险",
description="此前被标记为误报,现重新确认。",
risk_score=92,
risk_level="high",
confidence_score=0.95,
control_stage="reimbursement",
control_mode="risk_observation",
automation_mode="semi_auto_review",
source="financial_risk_graph",
algorithm_version="financial_risk_graph.v1",
status="false_positive",
feedback_status="false_positive",
)
disposition = RiskDisposition(
id=disposition_id,
tenant_id="default",
observation_id=observation_id,
adjudication="false_positive",
lifecycle_status="open",
version=1,
)
db.add_all([manager, employee, claim, observation, disposition])
db.commit()
def _require_disposable_database_url() -> str:
if not DATABASE_URL:
pytest.skip("仅在显式配置 MIGRATION_TEST_DATABASE_URL 时运行 PostgreSQL 并发测试")
parsed = make_url(DATABASE_URL)
host = str(parsed.host or "").replace("_", "-").lower()
database = str(parsed.database or "").replace("_", "-").lower()
if not host.startswith(("migration-probe", "disposable-probe")):
raise RuntimeError("并发测试数据库主机必须使用 disposable 前缀")
if not database.startswith(("migration-probe", "disposable-probe")):
raise RuntimeError("并发测试数据库名必须使用 disposable 前缀")
return DATABASE_URL