mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(wallet): 钱包系统替代配额系统,新增支付与退款机制
- 新增钱包余额管理、充值、扣费、退款完整流程 - 新增支付网关抽象层(支持手动/支付宝/微信) - 用量计费从配额系统迁移到钱包余额扣费 - 新增管理员钱包管理与支付订单管理页面 - 新增用户钱包中心页面 - 移除独立 Key 锁定机制,统一由钱包余额控制 - 新增相关 API 路由、序列化器与数据库迁移 - 新增钱包、支付、退款相关测试
This commit is contained in:
289
tests/api/test_admin_api_key_scope_routes.py
Normal file
289
tests/api/test_admin_api_key_scope_routes.py
Normal file
@@ -0,0 +1,289 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.api.admin.api_keys.routes import (
|
||||
AdminGetFullKeyAdapter,
|
||||
AdminToggleApiKeyAdapter,
|
||||
router as admin_api_keys_router,
|
||||
)
|
||||
from src.api.admin.users.routes import (
|
||||
AdminGetUserKeyFullKeyAdapter,
|
||||
AdminToggleUserKeyLockAdapter,
|
||||
router as admin_users_router,
|
||||
)
|
||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||
from src.database import get_db
|
||||
|
||||
|
||||
def _build_context(db: MagicMock) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
db=db,
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
|
||||
|
||||
def _mock_query_first(db: MagicMock, value: object | None) -> None:
|
||||
db.query.return_value.filter.return_value.first.return_value = value
|
||||
|
||||
|
||||
def _build_admin_users_app(db: MagicMock, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(admin_users_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="admin-1"),
|
||||
ensure_json_body=lambda: {},
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
return await adapter.handle(context)
|
||||
|
||||
monkeypatch.setattr("src.api.admin.users.routes.pipeline.run", _fake_pipeline_run)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _build_admin_api_keys_app(db: MagicMock, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(admin_api_keys_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="admin-1"),
|
||||
ensure_json_body=lambda: {},
|
||||
add_audit_metadata=lambda **_: None,
|
||||
)
|
||||
return await adapter.handle(context)
|
||||
|
||||
monkeypatch.setattr("src.api.admin.api_keys.routes.pipeline.run", _fake_pipeline_run)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_user_key_lock_adapter_success() -> None:
|
||||
db = MagicMock()
|
||||
api_key = SimpleNamespace(id="key-1", user_id="user-1", is_standalone=False, is_locked=False)
|
||||
_mock_query_first(db, api_key)
|
||||
|
||||
adapter = AdminToggleUserKeyLockAdapter(user_id="user-1", key_id="key-1")
|
||||
result = await adapter.handle(_build_context(db))
|
||||
|
||||
assert result["id"] == "key-1"
|
||||
assert result["is_locked"] is True
|
||||
assert "锁定" in result["message"]
|
||||
db.commit.assert_called_once()
|
||||
db.refresh.assert_called_once_with(api_key)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_user_key_lock_adapter_not_found_for_standalone_or_wrong_owner() -> None:
|
||||
db = MagicMock()
|
||||
_mock_query_first(db, None)
|
||||
|
||||
adapter = AdminToggleUserKeyLockAdapter(user_id="user-1", key_id="key-standalone")
|
||||
with pytest.raises(NotFoundException):
|
||||
await adapter.handle(_build_context(db))
|
||||
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_key_full_key_adapter_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
api_key = SimpleNamespace(
|
||||
id="key-2",
|
||||
user_id="user-1",
|
||||
is_standalone=False,
|
||||
key_encrypted="encrypted-value",
|
||||
)
|
||||
_mock_query_first(db, api_key)
|
||||
|
||||
monkeypatch.setattr("src.core.crypto.crypto_service.decrypt", lambda _v: "sk-user-full-key")
|
||||
|
||||
adapter = AdminGetUserKeyFullKeyAdapter(user_id="user-1", key_id="key-2")
|
||||
result = await adapter.handle(_build_context(db))
|
||||
|
||||
assert result == {"key": "sk-user-full-key"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_key_full_key_adapter_requires_encrypted_key() -> None:
|
||||
db = MagicMock()
|
||||
api_key = SimpleNamespace(
|
||||
id="key-3",
|
||||
user_id="user-1",
|
||||
is_standalone=False,
|
||||
key_encrypted=None,
|
||||
)
|
||||
_mock_query_first(db, api_key)
|
||||
|
||||
adapter = AdminGetUserKeyFullKeyAdapter(user_id="user-1", key_id="key-3")
|
||||
with pytest.raises(InvalidRequestException):
|
||||
await adapter.handle(_build_context(db))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_key_full_key_adapter_returns_500_on_decrypt_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
api_key = SimpleNamespace(
|
||||
id="key-4",
|
||||
user_id="user-1",
|
||||
is_standalone=False,
|
||||
key_encrypted="encrypted-value",
|
||||
)
|
||||
_mock_query_first(db, api_key)
|
||||
|
||||
def _raise(_: str) -> str:
|
||||
raise ValueError("decrypt failed")
|
||||
|
||||
monkeypatch.setattr("src.core.crypto.crypto_service.decrypt", _raise)
|
||||
|
||||
adapter = AdminGetUserKeyFullKeyAdapter(user_id="user-1", key_id="key-4")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await adapter.handle(_build_context(db))
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standalone_toggle_adapters_reject_normal_user_key() -> None:
|
||||
db = MagicMock()
|
||||
normal_key = SimpleNamespace(
|
||||
id="key-user",
|
||||
user_id="user-1",
|
||||
is_standalone=False,
|
||||
is_active=True,
|
||||
is_locked=False,
|
||||
key_encrypted="encrypted-value",
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
_mock_query_first(db, normal_key)
|
||||
context = _build_context(db)
|
||||
|
||||
with pytest.raises(InvalidRequestException):
|
||||
await AdminToggleApiKeyAdapter(key_id="key-user").handle(context)
|
||||
|
||||
with pytest.raises(InvalidRequestException):
|
||||
await AdminGetFullKeyAdapter(key_id="key-user").handle(context)
|
||||
|
||||
|
||||
def test_user_key_lock_route_path_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
api_key = SimpleNamespace(id="key-5", user_id="user-2", is_standalone=False, is_locked=False)
|
||||
_mock_query_first(db, api_key)
|
||||
client = _build_admin_users_app(db, monkeypatch)
|
||||
|
||||
response = client.patch("/api/admin/users/user-2/api-keys/key-5/lock")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == "key-5"
|
||||
assert response.json()["is_locked"] is True
|
||||
|
||||
|
||||
def test_user_key_full_key_route_path_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
api_key = SimpleNamespace(
|
||||
id="key-6",
|
||||
user_id="user-2",
|
||||
is_standalone=False,
|
||||
key_encrypted="enc",
|
||||
)
|
||||
_mock_query_first(db, api_key)
|
||||
monkeypatch.setattr("src.core.crypto.crypto_service.decrypt", lambda _v: "sk-user-route-key")
|
||||
client = _build_admin_users_app(db, monkeypatch)
|
||||
|
||||
response = client.get("/api/admin/users/user-2/api-keys/key-6/full-key")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"key": "sk-user-route-key"}
|
||||
|
||||
|
||||
def test_standalone_lock_route_removed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _build_admin_api_keys_app(MagicMock(), monkeypatch)
|
||||
response = client.patch("/api/admin/api-keys/key-1/lock")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_standalone_list_route_does_not_expose_is_locked(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
api_key = SimpleNamespace(
|
||||
id="sa-key-1",
|
||||
user_id="admin-1",
|
||||
name="Standalone Key",
|
||||
get_display_key=lambda: "sk-stand...1234",
|
||||
is_active=True,
|
||||
is_standalone=True,
|
||||
total_requests=0,
|
||||
total_cost_usd=0,
|
||||
rate_limit=None,
|
||||
allowed_providers=None,
|
||||
allowed_api_formats=None,
|
||||
allowed_models=None,
|
||||
last_used_at=None,
|
||||
expires_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=None,
|
||||
auto_delete_on_expiry=False,
|
||||
)
|
||||
query = db.query.return_value.filter.return_value
|
||||
query.count.return_value = 1
|
||||
query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [api_key]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.api_keys.routes.WalletService.get_wallet",
|
||||
lambda _db, user_id=None, api_key_id=None, user=None, api_key=None: SimpleNamespace(id="w-1"),
|
||||
)
|
||||
client = _build_admin_api_keys_app(db, monkeypatch)
|
||||
|
||||
response = client.get("/api/admin/api-keys")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert len(payload["api_keys"]) == 1
|
||||
assert "is_locked" not in payload["api_keys"][0]
|
||||
|
||||
|
||||
def test_standalone_detail_route_does_not_expose_is_locked(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
api_key = SimpleNamespace(
|
||||
id="sa-key-2",
|
||||
user_id="admin-1",
|
||||
name="Standalone Key 2",
|
||||
get_display_key=lambda: "sk-stand...5678",
|
||||
is_active=True,
|
||||
is_standalone=True,
|
||||
total_requests=0,
|
||||
total_cost_usd=0,
|
||||
rate_limit=None,
|
||||
allowed_providers=[],
|
||||
allowed_api_formats=[],
|
||||
allowed_models=[],
|
||||
last_used_at=None,
|
||||
expires_at=None,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=None,
|
||||
)
|
||||
_mock_query_first(db, api_key)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.api_keys.routes.WalletService.get_wallet",
|
||||
lambda _db, user_id=None, api_key_id=None, user=None, api_key=None: None,
|
||||
)
|
||||
client = _build_admin_api_keys_app(db, monkeypatch)
|
||||
|
||||
response = client.get("/api/admin/api-keys/sa-key-2")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["id"] == "sa-key-2"
|
||||
assert "is_locked" not in payload
|
||||
232
tests/api/test_payment_routes.py
Normal file
232
tests/api/test_payment_routes.py
Normal file
@@ -0,0 +1,232 @@
|
||||
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.admin.payments.routes import AdminPaymentOrderCreditAdapter
|
||||
from src.api.payment.routes import router as payment_router
|
||||
from src.config import config
|
||||
from src.database import get_db
|
||||
from src.models.database import PaymentOrder
|
||||
from src.services.payment.gateway import get_payment_gateway
|
||||
|
||||
CALLBACK_SECRET = "test-callback-secret"
|
||||
|
||||
|
||||
def _build_payment_app(db: MagicMock) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(payment_router)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _sign_payload(payload: dict[str, object]) -> str:
|
||||
gateway = get_payment_gateway("alipay")
|
||||
signature = gateway.build_callback_signature(payload=payload, callback_secret=CALLBACK_SECRET)
|
||||
assert signature is not None
|
||||
return signature
|
||||
|
||||
|
||||
def test_specific_wechat_callback_route_is_not_shadowed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_payment_app(db)
|
||||
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
|
||||
|
||||
captured_kwargs: dict[str, object] = {}
|
||||
|
||||
def _fake_handle_callback(*args: object, **kwargs: object) -> dict[str, object]:
|
||||
captured_kwargs.update(kwargs)
|
||||
return {
|
||||
"ok": True,
|
||||
"credited": True,
|
||||
"duplicate": False,
|
||||
"payment_method_seen": kwargs["payment_method"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr("src.api.payment.routes.PaymentService.handle_callback", _fake_handle_callback)
|
||||
|
||||
callback_payload = {"callback_key": "cb-wechat", "amount_usd": 1.0}
|
||||
response = client.post(
|
||||
"/api/payment/callback/wechat",
|
||||
json=callback_payload,
|
||||
headers={
|
||||
"x-payment-callback-token": CALLBACK_SECRET,
|
||||
"x-payment-callback-signature": _sign_payload(callback_payload),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["payment_method"] == "wechat"
|
||||
assert payload["payment_method_seen"] == "wechat"
|
||||
assert payload["request_path"] == "/api/payment/callback/wechat"
|
||||
assert captured_kwargs["callback_signature"] == _sign_payload(callback_payload)
|
||||
assert captured_kwargs["callback_secret"] == CALLBACK_SECRET
|
||||
assert "signature_valid" not in captured_kwargs
|
||||
db.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_generic_payment_callback_route_still_handles_custom_methods(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_payment_app(db)
|
||||
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
|
||||
|
||||
captured_kwargs: dict[str, object] = {}
|
||||
|
||||
def _fake_handle_callback(*args: object, **kwargs: object) -> dict[str, object]:
|
||||
captured_kwargs.update(kwargs)
|
||||
return {
|
||||
"ok": True,
|
||||
"credited": False,
|
||||
"duplicate": False,
|
||||
"payment_method_seen": kwargs["payment_method"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr("src.api.payment.routes.PaymentService.handle_callback", _fake_handle_callback)
|
||||
|
||||
callback_payload = {"callback_key": "cb-generic", "amount_usd": 1.0}
|
||||
response = client.post(
|
||||
"/api/payment/callback/mockpay",
|
||||
json=callback_payload,
|
||||
headers={
|
||||
"x-payment-callback-token": CALLBACK_SECRET,
|
||||
"x-payment-callback-signature": _sign_payload(callback_payload),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["payment_method"] == "mockpay"
|
||||
assert payload["payment_method_seen"] == "mockpay"
|
||||
assert captured_kwargs["callback_signature"] == _sign_payload(callback_payload)
|
||||
assert captured_kwargs["callback_secret"] == CALLBACK_SECRET
|
||||
assert "signature_valid" not in captured_kwargs
|
||||
|
||||
|
||||
def test_callback_requires_shared_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_payment_app(db)
|
||||
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
|
||||
|
||||
response = client.post(
|
||||
"/api/payment/callback/alipay",
|
||||
json={"callback_key": "cb-missing-token", "amount_usd": 1.0},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_callback_rejects_invalid_shared_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_payment_app(db)
|
||||
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
|
||||
|
||||
callback_payload = {"callback_key": "cb-invalid-token", "amount_usd": 1.0}
|
||||
|
||||
response = client.post(
|
||||
"/api/payment/callback/alipay",
|
||||
json=callback_payload,
|
||||
headers={
|
||||
"x-payment-callback-token": "wrong-token",
|
||||
"x-payment-callback-signature": _sign_payload(callback_payload),
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_callback_rejects_missing_signature(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_payment_app(db)
|
||||
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
|
||||
|
||||
response = client.post(
|
||||
"/api/payment/callback/alipay",
|
||||
json={"callback_key": "cb-missing-signature", "amount_usd": 1.0},
|
||||
headers={"x-payment-callback-token": CALLBACK_SECRET},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_callback_disabled_when_secret_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
db = MagicMock()
|
||||
client = _build_payment_app(db)
|
||||
monkeypatch.setattr(config, "payment_callback_secret", "")
|
||||
|
||||
response = client.post(
|
||||
"/api/payment/callback/alipay",
|
||||
json={"callback_key": "cb-secret-missing", "amount_usd": 1.0},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
db.commit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_payment_credit_adapter_marks_manual_credit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
db = MagicMock()
|
||||
order = PaymentOrder(
|
||||
id="po-credit",
|
||||
order_no="order-credit",
|
||||
wallet_id="w1",
|
||||
user_id="u1",
|
||||
amount_usd=Decimal("8.00000000"),
|
||||
refunded_amount_usd=Decimal("0"),
|
||||
refundable_amount_usd=Decimal("8.00000000"),
|
||||
payment_method="alipay",
|
||||
status="pending",
|
||||
gateway_response={"existing": True},
|
||||
)
|
||||
adapter = AdminPaymentOrderCreditAdapter(order_id=order.id)
|
||||
context = SimpleNamespace(
|
||||
db=db,
|
||||
raw_body=b"{}",
|
||||
ensure_json_body=lambda: {
|
||||
"pay_amount": 58.0,
|
||||
"pay_currency": "CNY",
|
||||
"exchange_rate": 7.25,
|
||||
},
|
||||
user=SimpleNamespace(id="admin-1"),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.payments.routes.PaymentService.get_order",
|
||||
lambda _db, order_id: order if order_id == "po-credit" else None,
|
||||
)
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _fake_credit_order(_db: MagicMock, **kwargs: object) -> tuple[PaymentOrder, bool]:
|
||||
captured.update(kwargs)
|
||||
return order, True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.api.admin.payments.routes.PaymentService.credit_order",
|
||||
_fake_credit_order,
|
||||
)
|
||||
|
||||
result = await adapter.handle(context)
|
||||
|
||||
assert result["credited"] is True
|
||||
assert result["order"]["id"] == "po-credit"
|
||||
gateway_response = captured["gateway_response"]
|
||||
assert isinstance(gateway_response, dict)
|
||||
assert gateway_response["existing"] is True
|
||||
assert gateway_response["manual_credit"] is True
|
||||
assert gateway_response["credited_by"] == "admin-1"
|
||||
db.commit.assert_called_once()
|
||||
@@ -3,7 +3,7 @@ API Pipeline 测试
|
||||
|
||||
测试 ApiRequestPipeline 的核心功能:
|
||||
- 认证流程(API Key、JWT Token)
|
||||
- 配额计算
|
||||
- 余额计算
|
||||
- 审计日志记录
|
||||
"""
|
||||
|
||||
@@ -17,56 +17,43 @@ from src.api.base.pipeline import ApiRequestPipeline
|
||||
from src.core.enums import UserRole
|
||||
|
||||
|
||||
class TestPipelineQuotaCalculation:
|
||||
"""测试 Pipeline 配额计算"""
|
||||
class TestPipelineBalanceCalculation:
|
||||
"""测试 Pipeline 余额计算"""
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline(self) -> ApiRequestPipeline:
|
||||
return ApiRequestPipeline()
|
||||
|
||||
def test_calculate_quota_remaining_with_quota(self, pipeline: ApiRequestPipeline) -> None:
|
||||
"""测试有配额限制时计算剩余配额"""
|
||||
def test_calculate_balance_remaining_with_balance(self, pipeline: ApiRequestPipeline) -> None:
|
||||
"""测试有限制钱包时计算剩余余额"""
|
||||
mock_user = MagicMock()
|
||||
mock_user.quota_usd = 100.0
|
||||
mock_user.used_usd = 30.0
|
||||
mock_db = MagicMock()
|
||||
|
||||
remaining = pipeline._calculate_quota_remaining(mock_user)
|
||||
with patch(
|
||||
"src.api.base.pipeline.WalletService.get_balance_snapshot",
|
||||
return_value=70.0,
|
||||
):
|
||||
remaining = pipeline._calculate_balance_remaining(mock_db, mock_user)
|
||||
|
||||
assert remaining == 70.0
|
||||
|
||||
def test_calculate_quota_remaining_no_quota(self, pipeline: ApiRequestPipeline) -> None:
|
||||
"""测试无配额限制时返回 None"""
|
||||
def test_calculate_balance_remaining_unlimited(self, pipeline: ApiRequestPipeline) -> None:
|
||||
"""测试无限制钱包时返回 None"""
|
||||
mock_user = MagicMock()
|
||||
mock_user.quota_usd = None
|
||||
mock_user.used_usd = 30.0
|
||||
mock_db = MagicMock()
|
||||
|
||||
remaining = pipeline._calculate_quota_remaining(mock_user)
|
||||
with patch(
|
||||
"src.api.base.pipeline.WalletService.get_balance_snapshot",
|
||||
return_value=None,
|
||||
):
|
||||
remaining = pipeline._calculate_balance_remaining(mock_db, mock_user)
|
||||
|
||||
assert remaining is None
|
||||
|
||||
def test_calculate_quota_remaining_negative_quota(self, pipeline: ApiRequestPipeline) -> None:
|
||||
"""测试负配额时返回 None"""
|
||||
mock_user = MagicMock()
|
||||
mock_user.quota_usd = -1
|
||||
mock_user.used_usd = 0.0
|
||||
|
||||
remaining = pipeline._calculate_quota_remaining(mock_user)
|
||||
|
||||
assert remaining is None
|
||||
|
||||
def test_calculate_quota_remaining_exceeded(self, pipeline: ApiRequestPipeline) -> None:
|
||||
"""测试配额已超时返回 0"""
|
||||
mock_user = MagicMock()
|
||||
mock_user.quota_usd = 100.0
|
||||
mock_user.used_usd = 150.0
|
||||
|
||||
remaining = pipeline._calculate_quota_remaining(mock_user)
|
||||
|
||||
assert remaining == 0.0
|
||||
|
||||
def test_calculate_quota_remaining_none_user(self, pipeline: ApiRequestPipeline) -> None:
|
||||
def test_calculate_balance_remaining_none_user(self, pipeline: ApiRequestPipeline) -> None:
|
||||
"""测试用户为 None 时返回 None"""
|
||||
remaining = pipeline._calculate_quota_remaining(None)
|
||||
mock_db = MagicMock()
|
||||
remaining = pipeline._calculate_balance_remaining(mock_db, None)
|
||||
|
||||
assert remaining is None
|
||||
|
||||
@@ -266,12 +253,10 @@ class TestPipelineAuthentication:
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_authenticate_client_quota_exceeded(self, pipeline: ApiRequestPipeline) -> None:
|
||||
"""测试配额超限时抛出异常"""
|
||||
def test_authenticate_client_balance_exceeded(self, pipeline: ApiRequestPipeline) -> None:
|
||||
"""测试余额不足时抛出异常"""
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "user-123"
|
||||
mock_user.quota_usd = 100.0
|
||||
mock_user.used_usd = 100.0
|
||||
|
||||
mock_api_key = MagicMock()
|
||||
mock_api_key.id = "key-123"
|
||||
@@ -294,13 +279,17 @@ class TestPipelineAuthentication:
|
||||
):
|
||||
with patch.object(
|
||||
pipeline.usage_service,
|
||||
"check_user_quota",
|
||||
return_value=(False, "配额不足"),
|
||||
"check_request_balance",
|
||||
return_value=(False, "余额不足"),
|
||||
):
|
||||
from src.core.exceptions import QuotaExceededException
|
||||
with patch(
|
||||
"src.api.base.pipeline.WalletService.get_balance_snapshot",
|
||||
return_value=0.0,
|
||||
):
|
||||
from src.core.exceptions import BalanceInsufficientException
|
||||
|
||||
with pytest.raises(QuotaExceededException):
|
||||
pipeline._authenticate_client(mock_request, mock_db, mock_adapter)
|
||||
with pytest.raises(BalanceInsufficientException):
|
||||
pipeline._authenticate_client(mock_request, mock_db, mock_adapter)
|
||||
|
||||
|
||||
class TestPipelineAdminAuth:
|
||||
|
||||
202
tests/api/test_wallet_refund_routes.py
Normal file
202
tests/api/test_wallet_refund_routes.py
Normal file
@@ -0,0 +1,202 @@
|
||||
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"
|
||||
Reference in New Issue
Block a user