Files
Aether/tests/services/test_wallet_service_rules.py
T

624 lines
22 KiB
Python
Raw Normal View History

from decimal import Decimal
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy.exc import IntegrityError
from src.services.wallet.service import WalletService
def _build_wallet(
*,
wallet_id: str = "wallet-1",
recharge: str = "0",
gift: str = "0",
limit_mode: str = "finite",
status: str = "active",
api_key_id: str | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
id=wallet_id,
balance=Decimal(recharge),
gift_balance=Decimal(gift),
total_recharged=Decimal("0"),
total_consumed=Decimal("0"),
total_refunded=Decimal("0"),
total_adjusted=Decimal("0"),
limit_mode=limit_mode,
currency="USD",
status=status,
api_key_id=api_key_id,
updated_at=None,
)
def _build_locked_db(wallet: SimpleNamespace) -> MagicMock:
db = MagicMock()
query = MagicMock()
query.filter.return_value = query
query.with_for_update.return_value = query
query.one_or_none.return_value = wallet
db.query.return_value = query
return db
def test_get_or_create_wallet_prefers_user_owner_for_non_standalone_key() -> None:
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
db.begin_nested.return_value.__enter__.return_value = None
db.begin_nested.return_value.__exit__.return_value = None
user = SimpleNamespace(id="user-1")
api_key = SimpleNamespace(id="key-1", is_standalone=False)
wallet = WalletService.get_or_create_wallet(
db, user=cast(Any, user), api_key=cast(Any, api_key)
)
assert wallet is not None
assert wallet.user_id == "user-1"
assert wallet.api_key_id is None
def test_get_or_create_wallet_uses_api_key_owner_for_standalone_key() -> None:
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
db.begin_nested.return_value.__enter__.return_value = None
db.begin_nested.return_value.__exit__.return_value = None
user = SimpleNamespace(id="user-1")
api_key = SimpleNamespace(id="key-1", is_standalone=True)
wallet = WalletService.get_or_create_wallet(
db, user=cast(Any, user), api_key=cast(Any, api_key)
)
assert wallet is not None
assert wallet.user_id is None
assert wallet.api_key_id == "key-1"
def test_get_wallets_by_user_ids_returns_mapping() -> None:
db = MagicMock()
wallet_1 = SimpleNamespace(user_id="user-1")
wallet_2 = SimpleNamespace(user_id="user-2")
db.query.return_value.filter.return_value.all.return_value = [wallet_1, wallet_2]
result = WalletService.get_wallets_by_user_ids(db, ["user-1", "user-2"])
assert result == {"user-1": wallet_1, "user-2": wallet_2}
def test_get_wallets_by_user_ids_skips_query_for_empty_ids() -> None:
db = MagicMock()
result = WalletService.get_wallets_by_user_ids(db, [])
assert result == {}
db.query.assert_not_called()
def test_check_request_allowed_denies_when_recharge_negative_even_total_positive() -> None:
wallet = _build_wallet(recharge="-1", gift="10", limit_mode="finite")
db = MagicMock()
api_key = MagicMock()
with patch.object(WalletService, "get_or_create_wallet", return_value=wallet):
result = WalletService.check_request_allowed(db, user=None, api_key=api_key)
assert result.allowed is False
assert result.message == "钱包欠费,请先充值"
assert result.remaining == Decimal("-1.00000000")
def test_get_balance_snapshot_returns_negative_recharge_for_unlimited_wallet() -> None:
wallet = _build_wallet(recharge="-2", gift="100", limit_mode="unlimited")
db = MagicMock()
with patch.object(WalletService, "get_or_create_wallet", return_value=wallet):
snapshot = WalletService.get_balance_snapshot(db, user=None, api_key=MagicMock())
assert snapshot == Decimal("-2.00000000")
def test_admin_adjust_balance_negative_from_gift_spills_to_recharge() -> None:
wallet = _build_wallet(recharge="2", gift="3")
db = _build_locked_db(wallet)
tx = WalletService.admin_adjust_balance(
db,
wallet=cast(Any, wallet),
amount_usd=Decimal("-10"),
balance_type="gift",
operator_id="admin-1",
description="test",
)
assert wallet.gift_balance == Decimal("0E-8")
assert wallet.balance == Decimal("-5.00000000")
assert wallet.total_adjusted == Decimal("-10.00000000")
assert tx.recharge_balance_after == Decimal("-5.00000000")
assert tx.gift_balance_after == Decimal("0E-8")
assert tx.amount == Decimal("-10.00000000")
assert tx.category == "adjust"
assert tx.reason_code == "adjust_admin"
def test_admin_adjust_balance_negative_from_recharge_then_gift() -> None:
wallet = _build_wallet(recharge="2", gift="3")
db = _build_locked_db(wallet)
tx = WalletService.admin_adjust_balance(
db,
wallet=cast(Any, wallet),
amount_usd=Decimal("-4"),
balance_type="recharge",
operator_id="admin-1",
description="test",
)
assert wallet.balance == Decimal("0E-8")
assert wallet.gift_balance == Decimal("1.00000000")
assert wallet.total_adjusted == Decimal("-4.00000000")
assert tx.recharge_balance_after == Decimal("0E-8")
assert tx.gift_balance_after == Decimal("1.00000000")
assert tx.amount == Decimal("-4.00000000")
def test_admin_adjust_balance_positive_adds_to_selected_bucket_without_offset() -> None:
wallet = _build_wallet(recharge="-2", gift="5")
db = _build_locked_db(wallet)
tx = WalletService.admin_adjust_balance(
db,
wallet=cast(Any, wallet),
amount_usd=Decimal("1"),
balance_type="gift",
operator_id="admin-1",
description="test",
)
assert wallet.balance == Decimal("-2.00000000")
assert wallet.gift_balance == Decimal("6.00000000")
assert wallet.total_adjusted == Decimal("1.00000000")
assert tx.recharge_balance_after == Decimal("-2.00000000")
assert tx.gift_balance_after == Decimal("6.00000000")
assert tx.amount == Decimal("1.00000000")
assert tx.category == "adjust"
assert tx.reason_code == "adjust_admin"
def test_apply_usage_charge_prefers_gift_then_recharge() -> None:
wallet = _build_wallet(recharge="5", gift="3", limit_mode="finite")
usage = SimpleNamespace(
wallet_id=None,
api_key_id=None,
user_id="user-1",
wallet_balance_before=None,
wallet_balance_after=None,
wallet_recharge_balance_before=None,
wallet_recharge_balance_after=None,
wallet_gift_balance_before=None,
wallet_gift_balance_after=None,
)
db = _build_locked_db(wallet)
with patch.object(WalletService, "_resolve_wallet_for_usage", return_value=wallet):
before, after = WalletService.apply_usage_charge(
db, usage=cast(Any, usage), amount_usd=Decimal("6")
)
assert before == Decimal("8.00000000")
assert after == Decimal("2.00000000")
assert wallet.gift_balance == Decimal("0E-8")
assert wallet.balance == Decimal("2.00000000")
assert wallet.total_consumed == Decimal("6.00000000")
assert usage.wallet_balance_before == Decimal("8.00000000")
assert usage.wallet_balance_after == Decimal("2.00000000")
assert usage.wallet_recharge_balance_before == Decimal("5.00000000")
assert usage.wallet_recharge_balance_after == Decimal("2.00000000")
assert usage.wallet_gift_balance_before == Decimal("3.00000000")
assert usage.wallet_gift_balance_after == Decimal("0E-8")
def test_apply_usage_charge_unlimited_wallet_keeps_balances() -> None:
wallet = _build_wallet(recharge="5", gift="3", limit_mode="unlimited")
usage = SimpleNamespace(
wallet_id=None,
api_key_id=None,
user_id="user-1",
wallet_balance_before=None,
wallet_balance_after=None,
wallet_recharge_balance_before=None,
wallet_recharge_balance_after=None,
wallet_gift_balance_before=None,
wallet_gift_balance_after=None,
)
db = _build_locked_db(wallet)
with patch.object(WalletService, "_resolve_wallet_for_usage", return_value=wallet):
before, after = WalletService.apply_usage_charge(
db, usage=cast(Any, usage), amount_usd=Decimal("4")
)
assert before == Decimal("8.00000000")
assert after == Decimal("8.00000000")
assert wallet.balance == Decimal("5")
assert wallet.gift_balance == Decimal("3")
assert wallet.total_consumed == Decimal("4.00000000")
assert usage.wallet_balance_before == Decimal("8.00000000")
assert usage.wallet_balance_after == Decimal("8.00000000")
assert usage.wallet_recharge_balance_before == Decimal("5")
assert usage.wallet_recharge_balance_after == Decimal("5")
assert usage.wallet_gift_balance_before == Decimal("3")
assert usage.wallet_gift_balance_after == Decimal("3")
def test_complete_refund_requires_processing_status() -> None:
refund = SimpleNamespace(id="refund-1", status="failed")
db = MagicMock()
query = MagicMock()
query.filter.return_value = query
query.with_for_update.return_value = query
query.one_or_none.return_value = refund
db.query.return_value = query
with pytest.raises(ValueError, match="processing"):
WalletService.complete_refund(db, refund=cast(Any, refund))
def test_get_or_create_wallet_reuses_existing_after_integrity_error() -> None:
existing_wallet = _build_wallet(wallet_id="wallet-existing", recharge="1", gift="0")
db = MagicMock()
nested = MagicMock()
nested.__enter__.return_value = None
nested.__exit__.return_value = None
db.begin_nested.return_value = nested
db.flush.side_effect = IntegrityError("insert", {}, Exception("duplicate key"))
with patch.object(WalletService, "get_wallet", side_effect=[None, existing_wallet]):
wallet = WalletService.get_or_create_wallet(
db,
user=cast(Any, SimpleNamespace(id="user-1")),
api_key=None,
)
assert wallet is existing_wallet
def test_create_refund_request_rejects_uncredited_payment_order() -> None:
wallet = _build_wallet(wallet_id="wallet-1", recharge="10", gift="0")
payment_order = SimpleNamespace(
id="order-1",
wallet_id="wallet-1",
status="pending",
refundable_amount_usd=Decimal("10"),
)
wallet_query = MagicMock()
wallet_query.filter.return_value = wallet_query
wallet_query.with_for_update.return_value = wallet_query
wallet_query.one_or_none.return_value = wallet
order_query = MagicMock()
order_query.filter.return_value = order_query
order_query.with_for_update.return_value = order_query
order_query.one_or_none.return_value = payment_order
db = MagicMock()
def _query(model: object) -> MagicMock:
name = getattr(model, "__name__", "")
if name == "Wallet":
return wallet_query
if name == "PaymentOrder":
return order_query
raise AssertionError(f"unexpected model query: {name}")
db.query.side_effect = _query
with patch.object(
WalletService, "_get_pending_refund_reserved_amount", return_value=Decimal("0")
):
with pytest.raises(ValueError, match="payment order is not refundable"):
WalletService.create_refund_request(
db,
wallet=cast(Any, wallet),
user_id="user-1",
amount_usd=Decimal("2"),
refund_no="rf-1",
source_type="payment_order",
source_id="order-1",
refund_mode="original_channel",
payment_order=cast(Any, payment_order),
)
def test_create_refund_request_reserves_pending_wallet_amount() -> None:
wallet = _build_wallet(wallet_id="wallet-1", recharge="10", gift="0")
wallet_query = MagicMock()
wallet_query.filter.return_value = wallet_query
wallet_query.with_for_update.return_value = wallet_query
wallet_query.one_or_none.return_value = wallet
db = MagicMock()
def _query(model: object) -> MagicMock:
name = getattr(model, "__name__", "")
if name == "Wallet":
return wallet_query
raise AssertionError(f"unexpected model query: {name}")
db.query.side_effect = _query
with patch.object(
WalletService,
"_get_pending_refund_reserved_amount",
return_value=Decimal("9"),
):
with pytest.raises(ValueError, match="available refundable recharge balance"):
WalletService.create_refund_request(
db,
wallet=cast(Any, wallet),
user_id="user-1",
amount_usd=Decimal("2"),
refund_no="rf-2",
source_type="wallet_balance",
source_id=None,
refund_mode="offline_payout",
)
def test_create_refund_request_reserves_pending_order_amount() -> None:
wallet = _build_wallet(wallet_id="wallet-1", recharge="10", gift="0")
payment_order = SimpleNamespace(
id="order-1",
wallet_id="wallet-1",
status="credited",
refundable_amount_usd=Decimal("5"),
)
wallet_query = MagicMock()
wallet_query.filter.return_value = wallet_query
wallet_query.with_for_update.return_value = wallet_query
wallet_query.one_or_none.return_value = wallet
order_query = MagicMock()
order_query.filter.return_value = order_query
order_query.with_for_update.return_value = order_query
order_query.one_or_none.return_value = payment_order
db = MagicMock()
def _query(model: object) -> MagicMock:
name = getattr(model, "__name__", "")
if name == "Wallet":
return wallet_query
if name == "PaymentOrder":
return order_query
raise AssertionError(f"unexpected model query: {name}")
db.query.side_effect = _query
with patch.object(
WalletService,
"_get_pending_refund_reserved_amount",
side_effect=[Decimal("0"), Decimal("4")],
):
with pytest.raises(ValueError, match="available refundable amount"):
WalletService.create_refund_request(
db,
wallet=cast(Any, wallet),
user_id="user-1",
amount_usd=Decimal("2"),
refund_no="rf-3",
source_type="payment_order",
source_id="order-1",
refund_mode="original_channel",
payment_order=cast(Any, payment_order),
)
def test_move_refund_to_processing_rejects_double_transition() -> None:
refund = SimpleNamespace(
id="refund-1",
wallet_id="wallet-1",
payment_order_id="order-1",
amount_usd=Decimal("2"),
status="pending_approval",
approved_by=None,
processed_by=None,
processed_at=None,
updated_at=None,
)
wallet = _build_wallet(wallet_id="wallet-1", recharge="10", gift="0")
payment_order = SimpleNamespace(
id="order-1",
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=Decimal("10"),
)
refund_query = MagicMock()
refund_query.filter.return_value = refund_query
refund_query.with_for_update.return_value = refund_query
refund_query.one_or_none.return_value = refund
wallet_query = MagicMock()
wallet_query.filter.return_value = wallet_query
wallet_query.with_for_update.return_value = wallet_query
wallet_query.one_or_none.return_value = wallet
order_query = MagicMock()
order_query.filter.return_value = order_query
order_query.with_for_update.return_value = order_query
order_query.one_or_none.return_value = payment_order
db = MagicMock()
def _query(model: object) -> MagicMock:
name = getattr(model, "__name__", "")
if name == "RefundRequest":
return refund_query
if name == "Wallet":
return wallet_query
if name == "PaymentOrder":
return order_query
raise AssertionError(f"unexpected model query: {name}")
db.query.side_effect = _query
tx = SimpleNamespace(id="tx-1")
with patch.object(WalletService, "create_wallet_transaction", return_value=tx) as create_tx:
first_tx = WalletService.move_refund_to_processing(
db, refund=cast(Any, refund), operator_id="admin-1"
)
with pytest.raises(ValueError, match="not approvable"):
WalletService.move_refund_to_processing(
db, refund=cast(Any, refund), operator_id="admin-1"
)
assert first_tx is tx
assert create_tx.call_count == 1
assert refund.status == "processing"
assert payment_order.refunded_amount_usd == Decimal("2.00000000")
assert payment_order.refundable_amount_usd == Decimal("8.00000000")
def test_move_refund_to_processing_rechecks_payment_order_refundable_amount() -> None:
refund = SimpleNamespace(
id="refund-1",
wallet_id="wallet-1",
payment_order_id="order-1",
amount_usd=Decimal("2"),
status="pending_approval",
approved_by=None,
processed_by=None,
processed_at=None,
updated_at=None,
)
wallet = _build_wallet(wallet_id="wallet-1", recharge="10", gift="0")
payment_order = SimpleNamespace(
id="order-1",
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=Decimal("1"),
)
refund_query = MagicMock()
refund_query.filter.return_value = refund_query
refund_query.with_for_update.return_value = refund_query
refund_query.one_or_none.return_value = refund
wallet_query = MagicMock()
wallet_query.filter.return_value = wallet_query
wallet_query.with_for_update.return_value = wallet_query
wallet_query.one_or_none.return_value = wallet
order_query = MagicMock()
order_query.filter.return_value = order_query
order_query.with_for_update.return_value = order_query
order_query.one_or_none.return_value = payment_order
db = MagicMock()
def _query(model: object) -> MagicMock:
name = getattr(model, "__name__", "")
if name == "RefundRequest":
return refund_query
if name == "Wallet":
return wallet_query
if name == "PaymentOrder":
return order_query
raise AssertionError(f"unexpected model query: {name}")
db.query.side_effect = _query
with patch.object(WalletService, "create_wallet_transaction") as create_tx:
with pytest.raises(ValueError, match="refund amount exceeds refundable amount"):
WalletService.move_refund_to_processing(
db, refund=cast(Any, refund), operator_id="admin-1"
)
create_tx.assert_not_called()
assert refund.status == "pending_approval"
assert payment_order.refunded_amount_usd == Decimal("0")
assert payment_order.refundable_amount_usd == Decimal("1")
def test_fail_refund_rejects_invalid_status_after_first_failure() -> None:
refund = SimpleNamespace(
id="refund-2",
wallet_id="wallet-1",
payment_order_id=None,
amount_usd=Decimal("1"),
status="processing",
failure_reason=None,
updated_at=None,
)
wallet = _build_wallet(wallet_id="wallet-1", recharge="10", gift="0")
refund_query = MagicMock()
refund_query.filter.return_value = refund_query
refund_query.with_for_update.return_value = refund_query
refund_query.one_or_none.return_value = refund
wallet_query = MagicMock()
wallet_query.filter.return_value = wallet_query
wallet_query.first.return_value = wallet
db = MagicMock()
def _query(model: object) -> MagicMock:
name = getattr(model, "__name__", "")
if name == "RefundRequest":
return refund_query
if name == "Wallet":
return wallet_query
raise AssertionError(f"unexpected model query: {name}")
db.query.side_effect = _query
tx = SimpleNamespace(id="tx-revert")
with patch.object(WalletService, "create_wallet_transaction", return_value=tx) as create_tx:
first_tx = WalletService.fail_refund(
db,
refund=cast(Any, refund),
reason="first-failure",
operator_id="admin-1",
)
with pytest.raises(ValueError, match="cannot fail refund in status: failed"):
WalletService.fail_refund(
db,
refund=cast(Any, refund),
reason="retry-failure",
operator_id="admin-1",
)
assert first_tx is tx
assert create_tx.call_count == 1
assert refund.status == "failed"
assert refund.failure_reason == "first-failure"
def test_fail_refund_rejects_succeeded_status() -> None:
refund = SimpleNamespace(
id="refund-succeeded",
wallet_id="wallet-1",
payment_order_id=None,
amount_usd=Decimal("1"),
status="succeeded",
failure_reason=None,
updated_at=None,
)
refund_query = MagicMock()
refund_query.filter.return_value = refund_query
refund_query.with_for_update.return_value = refund_query
refund_query.one_or_none.return_value = refund
db = MagicMock()
def _query(model: object) -> MagicMock:
name = getattr(model, "__name__", "")
if name == "RefundRequest":
return refund_query
raise AssertionError(f"unexpected model query: {name}")
db.query.side_effect = _query
with pytest.raises(ValueError, match="cannot fail refund in status: succeeded"):
WalletService.fail_refund(
db,
refund=cast(Any, refund),
reason="should-not-override",
operator_id="admin-1",
)
assert refund.status == "succeeded"
assert refund.failure_reason is None