mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
- 新增钱包余额管理、充值、扣费、退款完整流程 - 新增支付网关抽象层(支持手动/支付宝/微信) - 用量计费从配额系统迁移到钱包余额扣费 - 新增管理员钱包管理与支付订单管理页面 - 新增用户钱包中心页面 - 移除独立 Key 锁定机制,统一由钱包余额控制 - 新增相关 API 路由、序列化器与数据库迁移 - 新增钱包、支付、退款相关测试
203 lines
6.7 KiB
Python
203 lines
6.7 KiB
Python
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from src.api.wallet.routes import router as wallet_router
|
|
from src.database import get_db
|
|
|
|
|
|
def _build_wallet_app(
|
|
db: MagicMock,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
*,
|
|
payload: dict[str, object],
|
|
user_id: str = "user-1",
|
|
) -> TestClient:
|
|
app = FastAPI()
|
|
app.include_router(wallet_router)
|
|
app.dependency_overrides[get_db] = lambda: db
|
|
|
|
async def _fake_pipeline_run(*, adapter: object, http_request: object, db: MagicMock, mode: object) -> object:
|
|
_ = http_request, mode
|
|
context = SimpleNamespace(
|
|
db=db,
|
|
user=SimpleNamespace(id=user_id),
|
|
ensure_json_body=lambda: payload,
|
|
add_audit_metadata=lambda **_: None,
|
|
)
|
|
return await adapter.handle(context)
|
|
|
|
monkeypatch.setattr("src.api.wallet.routes.pipeline.run", _fake_pipeline_run)
|
|
return TestClient(app)
|
|
|
|
|
|
def test_create_refund_route_maps_uncredited_order_to_400(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
db = MagicMock()
|
|
payload = {"amount_usd": 2.0, "payment_order_id": "order-1"}
|
|
client = _build_wallet_app(db, monkeypatch, payload=payload)
|
|
wallet = SimpleNamespace(id="wallet-1")
|
|
payment_order = SimpleNamespace(id="order-1", wallet_id="wallet-1", payment_method="alipay")
|
|
|
|
monkeypatch.setattr(
|
|
"src.api.wallet.routes.WalletService.get_or_create_wallet",
|
|
lambda _db, user: wallet,
|
|
)
|
|
db.query.return_value.filter.return_value.first.return_value = payment_order
|
|
|
|
def _raise(*args: object, **kwargs: object) -> object:
|
|
raise ValueError("payment order is not refundable")
|
|
|
|
monkeypatch.setattr("src.api.wallet.routes.WalletService.create_refund_request", _raise)
|
|
|
|
response = client.post("/api/wallet/refunds", json=payload)
|
|
|
|
assert response.status_code == 400
|
|
assert "not refundable" in response.json()["detail"]
|
|
db.rollback.assert_called_once()
|
|
db.commit.assert_not_called()
|
|
|
|
|
|
def test_create_refund_route_maps_reserved_wallet_amount_to_400(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
db = MagicMock()
|
|
payload = {"amount_usd": 2.0}
|
|
client = _build_wallet_app(db, monkeypatch, payload=payload)
|
|
wallet = SimpleNamespace(id="wallet-1")
|
|
|
|
monkeypatch.setattr(
|
|
"src.api.wallet.routes.WalletService.get_or_create_wallet",
|
|
lambda _db, user: wallet,
|
|
)
|
|
|
|
def _raise(*args: object, **kwargs: object) -> object:
|
|
raise ValueError("refund amount exceeds available refundable recharge balance")
|
|
|
|
monkeypatch.setattr("src.api.wallet.routes.WalletService.create_refund_request", _raise)
|
|
|
|
response = client.post("/api/wallet/refunds", json=payload)
|
|
|
|
assert response.status_code == 400
|
|
assert "available refundable recharge balance" in response.json()["detail"]
|
|
db.rollback.assert_called_once()
|
|
db.commit.assert_not_called()
|
|
|
|
|
|
def test_create_refund_route_passes_default_order_refund_mode_and_commits(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
db = MagicMock()
|
|
payload = {"amount_usd": 2.0, "payment_order_id": "order-1", "reason": "test"}
|
|
client = _build_wallet_app(db, monkeypatch, payload=payload)
|
|
wallet = SimpleNamespace(id="wallet-1")
|
|
payment_order = SimpleNamespace(id="order-1", wallet_id="wallet-1", payment_method="alipay")
|
|
refund = SimpleNamespace(
|
|
id="refund-1",
|
|
refund_no="rf-1",
|
|
payment_order_id="order-1",
|
|
source_type="payment_order",
|
|
source_id="order-1",
|
|
refund_mode="original_channel",
|
|
amount_usd=Decimal("2.00000000"),
|
|
status="pending_approval",
|
|
reason="test",
|
|
failure_reason=None,
|
|
gateway_refund_id=None,
|
|
payout_method=None,
|
|
payout_reference=None,
|
|
payout_proof=None,
|
|
created_at="2026-03-07T00:00:00Z",
|
|
updated_at="2026-03-07T00:00:00Z",
|
|
processed_at=None,
|
|
completed_at=None,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"src.api.wallet.routes.WalletService.get_or_create_wallet",
|
|
lambda _db, user: wallet,
|
|
)
|
|
db.query.return_value.filter.return_value.first.return_value = payment_order
|
|
captured: dict[str, object] = {}
|
|
|
|
def _create_refund_request(_db: MagicMock, **kwargs: object) -> object:
|
|
captured.update(kwargs)
|
|
return refund
|
|
|
|
monkeypatch.setattr(
|
|
"src.api.wallet.routes.WalletService.create_refund_request",
|
|
_create_refund_request,
|
|
)
|
|
|
|
response = client.post("/api/wallet/refunds", json=payload)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["id"] == "refund-1"
|
|
assert body["status"] == "pending_approval"
|
|
assert captured["refund_mode"] == "original_channel"
|
|
assert captured["source_type"] == "payment_order"
|
|
assert captured["source_id"] == "order-1"
|
|
assert captured["payment_order"] is payment_order
|
|
db.commit.assert_called_once()
|
|
db.refresh.assert_called_once_with(refund)
|
|
db.rollback.assert_not_called()
|
|
|
|
|
|
def test_create_refund_route_uses_offline_payout_for_manual_recharge(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
db = MagicMock()
|
|
payload = {"amount_usd": 2.0, "payment_order_id": "order-2"}
|
|
client = _build_wallet_app(db, monkeypatch, payload=payload)
|
|
wallet = SimpleNamespace(id="wallet-1")
|
|
payment_order = SimpleNamespace(id="order-2", wallet_id="wallet-1", payment_method="admin_manual")
|
|
refund = SimpleNamespace(
|
|
id="refund-2",
|
|
refund_no="rf-2",
|
|
payment_order_id="order-2",
|
|
source_type="payment_order",
|
|
source_id="order-2",
|
|
refund_mode="offline_payout",
|
|
amount_usd=Decimal("2.00000000"),
|
|
status="pending_approval",
|
|
reason=None,
|
|
failure_reason=None,
|
|
gateway_refund_id=None,
|
|
payout_method=None,
|
|
payout_reference=None,
|
|
payout_proof=None,
|
|
created_at="2026-03-07T00:00:00Z",
|
|
updated_at="2026-03-07T00:00:00Z",
|
|
processed_at=None,
|
|
completed_at=None,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"src.api.wallet.routes.WalletService.get_or_create_wallet",
|
|
lambda _db, user: wallet,
|
|
)
|
|
db.query.return_value.filter.return_value.first.return_value = payment_order
|
|
captured: dict[str, object] = {}
|
|
|
|
def _create_refund_request(_db: MagicMock, **kwargs: object) -> object:
|
|
captured.update(kwargs)
|
|
return refund
|
|
|
|
monkeypatch.setattr(
|
|
"src.api.wallet.routes.WalletService.create_refund_request",
|
|
_create_refund_request,
|
|
)
|
|
|
|
response = client.post("/api/wallet/refunds", json=payload)
|
|
|
|
assert response.status_code == 200
|
|
assert captured["refund_mode"] == "offline_payout"
|