mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
- 为 Pipeline/Context 增加按需读取请求体能力,支持 async 懒加载 JSON body - 为 chat/cli/video/claude/openai-cli 适配器关闭默认预读,减少无效 body 读取与超时风险 - 将本地登录、JWT 用户加载、API Key 鉴权迁移到线程池隔离会话执行,避免阻塞事件循环 - 为 API Key 鉴权返回结构化余额结果,并在主请求会话中重新绑定 user/api_key 后再校验状态、过期和锁定信息 - 为 management/user token 前缀认证引入独立会话与结果回绑,避免跨会话对象写入失效 - 为 Usage 余额检查补充结构化返回,统一透出 remaining 与欠费/不可用文案映射 - 为用户与管理端活跃请求查询增加 maintain_status 开关,避免轮询指定 id 时误触发状态修复 - 重写 user_me usage 汇总逻辑,支持 group_by=None 的粗粒度聚合 - 修正 provider 维度成功率与平均响应时间统计,基于 success_count 和成功响应耗时汇总计算 - 前端 Usage 轮询由 setInterval 改为串行 setTimeout,避免并发轮询叠加 - 为 StatsAggregator 增加按本地日期批量计算百分位能力,替代逐天 fan-out 查询 - 为混合统计查询合并连续实时日期区间,并批量读取 StatsDaily,减少逐日查询次数 - 为用户日统计增加批量聚合入口,替代逐用户循环聚合 - 为系统配置导出改用 selectinload 预加载 provider 关联数据,减少 N+1 查询 - 为管理员用户列表增加钱包批量查询,避免逐用户回表 - 为调度器增加 provider 轻量引用预过滤,先按 allowed_providers 缩小范围再加载完整 provider 图 - 为 CandidateBuilder 增加 provider refs/provider_ids 查询能力,保留分页顺序 - 为模型缓存增加 provider_model_mappings 索引缓存与 model_mappings 规则缓存,减少重复全量扫描 - 为请求候选中间态改为 flush/batch commit,降低 pending/streaming 状态切换的事务往返 - 为钱包访问结果补充 balance_snapshot,并抽取余额快照复用逻辑 - 补充 pipeline、auth、admin users、user_me usage、stats aggregator、model cache、 scheduler、wallet、request candidate 等回归与契约测试
624 lines
22 KiB
Python
624 lines
22 KiB
Python
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
|