Files
X-Financial/server/tests/test_savings_concurrency_postgres.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

407 lines
15 KiB
Python

from __future__ import annotations
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor
import pytest
from savings_postgres_testkit import (
_pg_factory_fixture, # noqa: F401 - 注册名为 pg_factory 的 pytest fixture
_seed_opportunity,
_seed_payment_case,
_seed_pending_realization,
_SeededRealization,
_user,
)
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_record import ExpenseClaim
from app.models.savings import (
SavingsEvent,
SavingsEvidenceLink,
SavingsOpportunity,
SavingsRealization,
)
from app.schemas.savings import (
SavingsOpportunityActionCreate,
SavingsRealizationActionCreate,
)
from app.services.savings_access_policy import SavingsPermissionError
from app.services.savings_actions import SavingsActionService
from app.services.savings_realization import (
SavingsRealizationError,
SavingsRealizationService,
)
def test_concurrent_identical_request_replays_one_immutable_event(
pg_factory: sessionmaker[Session],
) -> None:
seed = _seed_opportunity(pg_factory, status="identified", owner_id="owner-idempotent")
actor = _user("owner-idempotent", seed.tenant_id, employee_id="owner-idempotent")
payload = SavingsOpportunityActionCreate(
action="accept",
request_id=f"accept-concurrent-{seed.suffix}",
expected_version=1,
comment="并发接受同一个节省机会",
)
ready = threading.Barrier(2)
def accept_once():
with pg_factory() as db:
ready.wait(timeout=5)
return SavingsActionService(db).execute(seed.opportunity_id, payload, actor).response
with ThreadPoolExecutor(max_workers=2) as pool:
results = [
future.result(timeout=10)
for future in (pool.submit(accept_once), pool.submit(accept_once))
]
assert sorted(item.replayed for item in results) == [False, True]
assert results[0].opportunity.model_dump(mode="json") == results[1].opportunity.model_dump(
mode="json"
)
with pg_factory() as db:
opportunity = db.get(SavingsOpportunity, seed.opportunity_id)
assert opportunity is not None and opportunity.status == "accepted"
assert opportunity.version == 2
assert (
db.scalar(
select(func.count())
.select_from(SavingsEvent)
.where(
SavingsEvent.tenant_id == seed.tenant_id,
SavingsEvent.actor_id == "owner-idempotent",
SavingsEvent.request_id == payload.request_id,
)
)
== 1
)
def test_same_benefit_concurrent_confirmation_has_one_canonical_winner(
pg_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
benefit_key = f"benefit-shared-{uuid.uuid4().hex}"
first = _seed_pending_realization(
pg_factory,
benefit_key=benefit_key,
owner_id="owner-canonical-a",
recorder_id="payer-canonical-a",
)
second = _seed_pending_realization(
pg_factory,
benefit_key=benefit_key,
owner_id="owner-canonical-b",
recorder_id="payer-canonical-b",
)
ready = threading.Barrier(2)
original_confirm = SavingsRealizationService._confirm
def confirm_after_duplicate_probe(self, *args, **kwargs):
original_confirm(self, *args, **kwargs)
ready.wait(timeout=5)
monkeypatch.setattr(SavingsRealizationService, "_confirm", confirm_after_duplicate_probe)
def confirm_once(seed: _SeededRealization, actor_id: str):
payload = SavingsRealizationActionCreate(
action="confirm",
request_id=f"confirm-race-{seed.suffix}",
expected_version=1,
comment="并发确认相同经济收益",
)
with pg_factory() as db:
try:
return (
SavingsRealizationService(db)
.execute_action(
seed.realization_id,
payload,
_user(actor_id, seed.tenant_id, employee_id=actor_id, roles=["finance"]),
)
.response.realization.id
)
except SavingsRealizationError as error:
return error
with ThreadPoolExecutor(max_workers=2) as pool:
outcomes = [
future.result(timeout=10)
for future in (
pool.submit(confirm_once, first, "finance-canonical-a"),
pool.submit(confirm_once, second, "finance-canonical-b"),
)
]
assert sum(isinstance(item, str) for item in outcomes) == 1
assert sum(isinstance(item, SavingsRealizationError) for item in outcomes) == 1
with pg_factory() as db:
realizations = list(
db.scalars(
select(SavingsRealization).where(
SavingsRealization.tenant_id == first.tenant_id,
SavingsRealization.benefit_key == benefit_key,
)
)
)
assert sorted((row.status, row.dedupe_status) for row in realizations) == [
("finance_confirmed", "canonical"),
("pending_confirmation", "pending_review"),
]
winner = next(row for row in realizations if row.status == "finance_confirmed")
loser = next(row for row in realizations if row.status == "pending_confirmation")
loser_evidence = list(
db.scalars(
select(SavingsEvidenceLink).where(
SavingsEvidenceLink.realization_id == loser.id,
)
)
)
assert loser_evidence and {item.verification_status for item in loser_evidence} == {
"unverified"
}
assert (
db.scalar(
select(func.count())
.select_from(SavingsEvent)
.where(
SavingsEvent.realization_id.in_([first.realization_id, second.realization_id]),
SavingsEvent.action == "confirm",
)
)
== 1
)
assert (
db.scalar(
select(func.count())
.select_from(BusinessEvent)
.where(
BusinessEvent.aggregate_type == "savings_realization",
BusinessEvent.aggregate_id == winner.id,
BusinessEvent.event_type == "saving_confirmed",
)
)
== 1
)
assert (
db.scalar(
select(func.count())
.select_from(SavingsRealization)
.where(
SavingsRealization.tenant_id == first.tenant_id,
SavingsRealization.benefit_key == benefit_key,
SavingsRealization.realization_type == "actual",
SavingsRealization.dedupe_status == "canonical",
SavingsRealization.status == "finance_confirmed",
)
)
== 1
)
def test_cross_tenant_action_is_rejected_without_side_effect(
pg_factory: sessionmaker[Session],
) -> None:
seed = _seed_opportunity(
pg_factory, tenant_id="tenant-savings-private", owner_id="owner-private"
)
realization_seed = _seed_pending_realization(
pg_factory,
tenant_id="tenant-savings-private",
owner_id="owner-private-realization",
recorder_id="payer-private-realization",
)
request_id = f"cross-tenant-{seed.suffix}"
realization_request_id = f"cross-tenant-confirm-{realization_seed.suffix}"
with pg_factory() as db:
with pytest.raises(LookupError, match="不存在"):
SavingsActionService(db).execute(
seed.opportunity_id,
SavingsOpportunityActionCreate(
action="accept",
request_id=request_id,
expected_version=1,
comment="尝试跨租户改变机会",
),
_user("finance-outsider", "tenant-savings-outsider", roles=["finance"]),
)
with pytest.raises(LookupError, match="不存在"):
SavingsRealizationService(db).execute_action(
realization_seed.realization_id,
SavingsRealizationActionCreate(
action="confirm",
request_id=realization_request_id,
expected_version=1,
comment="尝试跨租户确认实际节省",
),
_user("finance-outsider", "tenant-savings-outsider", roles=["finance"]),
)
with pg_factory() as db:
opportunity = db.get(SavingsOpportunity, seed.opportunity_id)
assert opportunity is not None and opportunity.status == "identified"
assert opportunity.version == 1
realization = db.get(SavingsRealization, realization_seed.realization_id)
assert realization is not None and realization.status == "pending_confirmation"
assert realization.version == 1
assert (
db.scalar(
select(func.count())
.select_from(SavingsEvent)
.where(SavingsEvent.request_id.in_([request_id, realization_request_id]))
)
== 0
)
def test_confirmation_requires_actor_independent_from_owner_and_recorder(
pg_factory: sessionmaker[Session],
) -> None:
seed = _seed_pending_realization(
pg_factory,
owner_id="finance-owner-independent",
recorder_id="finance-recorder-independent",
)
for actor_id in ("finance-owner-independent", "finance-recorder-independent"):
with pg_factory() as db:
with pytest.raises(SavingsPermissionError, match="不能确认自己"):
SavingsRealizationService(db).execute_action(
seed.realization_id,
SavingsRealizationActionCreate(
action="confirm",
request_id=f"self-confirm-{actor_id}-{seed.suffix}",
expected_version=1,
comment="不应允许自我确认",
),
_user(actor_id, seed.tenant_id, employee_id=actor_id, roles=["finance"]),
)
confirmer_id = "finance-independent-reviewer"
with pg_factory() as db:
result = SavingsRealizationService(db).execute_action(
seed.realization_id,
SavingsRealizationActionCreate(
action="confirm",
request_id=f"independent-confirm-{seed.suffix}",
expected_version=1,
comment="独立复核付款与政策证据",
),
_user(confirmer_id, seed.tenant_id, employee_id=confirmer_id, roles=["finance"]),
)
assert result.response.realization.status == "finance_confirmed"
assert result.response.realization.finance_confirmer_id == confirmer_id
assert result.response.realization.recorded_by_id != confirmer_id
evidence_statuses = set(
db.scalars(
select(SavingsEvidenceLink.verification_status).where(
SavingsEvidenceLink.realization_id == seed.realization_id,
)
)
)
assert evidence_statuses == {"verified"}
def test_concurrent_same_payment_event_persists_one_realization(
pg_factory: sessionmaker[Session],
) -> None:
seed = _seed_payment_case(pg_factory)
ready = threading.Barrier(2)
def realize_once():
with pg_factory() as db:
claim = db.get(ExpenseClaim, seed.claim_id)
event = db.get(BusinessEvent, seed.payment_event_id)
assert claim is not None and event is not None
ready.wait(timeout=5)
created = SavingsRealizationService(db).realize_paid_claim(
claim,
event,
_user(
"payment-actor", seed.tenant_id, employee_id="payment-actor", roles=["finance"]
),
)
db.commit()
return len(created)
with ThreadPoolExecutor(max_workers=2) as pool:
outcomes = [
future.result(timeout=10)
for future in (pool.submit(realize_once), pool.submit(realize_once))
]
assert sorted(outcomes) == [0, 1]
with pg_factory() as db:
rows = list(
db.scalars(
select(SavingsRealization).where(
SavingsRealization.tenant_id == seed.tenant_id,
SavingsRealization.opportunity_id == seed.opportunity_id,
SavingsRealization.business_event_id == seed.payment_event_id,
)
)
)
assert len(rows) == 1
assert rows[0].realization_key == (f"payment:{seed.payment_event_id}:{seed.opportunity_id}")
opportunity = db.get(SavingsOpportunity, seed.opportunity_id)
assert opportunity is not None and opportunity.status == "realized"
assert opportunity.version == 2
assert (
db.scalar(
select(func.count())
.select_from(BusinessEvent)
.where(
BusinessEvent.tenant_id == seed.tenant_id,
BusinessEvent.aggregate_type == "savings_realization",
BusinessEvent.aggregate_id == rows[0].id,
BusinessEvent.event_type == "saving_action_completed",
BusinessEvent.causation_id == seed.payment_event_id,
)
)
== 1
)
def test_savings_event_is_append_only_at_database_boundary(
pg_factory: sessionmaker[Session],
) -> None:
seed = _seed_opportunity(pg_factory, owner_id="owner-append-only")
request_id = f"append-only-{seed.suffix}"
with pg_factory() as db:
response = (
SavingsActionService(db)
.execute(
seed.opportunity_id,
SavingsOpportunityActionCreate(
action="accept",
request_id=request_id,
expected_version=1,
comment="创建不可变审计事件",
),
_user("owner-append-only", seed.tenant_id, employee_id="owner-append-only"),
)
.response
)
event_id = response.event.id
for statement in (
"UPDATE savings_events SET action = 'tampered' WHERE id = :event_id",
"DELETE FROM savings_events WHERE id = :event_id",
):
with pg_factory() as db:
with pytest.raises(DBAPIError, match="append-only"):
db.execute(text(statement), {"event_id": event_id})
db.commit()
db.rollback()
with pg_factory() as db:
event = db.get(SavingsEvent, event_id)
assert event is not None and event.action == "accept"
assert event.response_json["event"]["id"] == event_id