feat: Airwallex 发卡管理后台完整实现

- 后端: FastAPI + SQLAlchemy + SQLite, JWT认证, 代理支持的AirwallexClient
- 前端: React 18 + Vite + Ant Design 5, 中文界面
- 功能: 卡片管理, 持卡人管理, 交易记录, API令牌, 系统设置, 审计日志
- 第三方API: X-API-Key认证, 权限控制
- Docker部署: docker-compose编排前后端
This commit is contained in:
zqq61
2026-03-15 23:05:08 +08:00
commit 4f53889a8e
98 changed files with 10847 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
"""
Tests for the Airwallex SDK client.
"""
import unittest
from unittest.mock import patch, MagicMock
from datetime import datetime, timedelta
import json
from airwallex import AirwallexClient, AirwallexAsyncClient
from airwallex.exceptions import AuthenticationError, create_exception_from_response
class TestAirwallexClient(unittest.TestCase):
"""Tests for the AirwallexClient class."""
def setUp(self):
"""Set up test fixtures."""
self.client_id = "test_client_id"
self.api_key = "test_api_key"
@patch('httpx.Client.post')
def test_authentication(self, mock_post):
"""Test authentication flow."""
# Mock response for authentication
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.json.return_value = {
"token": "test_token",
"expires_at": (datetime.now() + timedelta(minutes=30)).isoformat()
}
mock_post.return_value = mock_response
# Initialize client and authenticate
client = AirwallexClient(client_id=self.client_id, api_key=self.api_key)
client.authenticate()
# Check authentication request
mock_post.assert_called_once()
args, kwargs = mock_post.call_args
self.assertEqual(kwargs['headers']['x-client-id'], self.client_id)
self.assertEqual(kwargs['headers']['x-api-key'], self.api_key)
# Check token is stored
self.assertEqual(client._token, "test_token")
self.assertIsNotNone(client._token_expiry)
@patch('httpx.Client.post')
def test_authentication_error(self, mock_post):
"""Test authentication error handling."""
# Mock authentication error
mock_response = MagicMock()
mock_response.status_code = 401
mock_response.json.return_value = {
"code": "credentials_invalid",
"message": "Invalid credentials"
}
mock_post.return_value = mock_response
# Initialize client
client = AirwallexClient(client_id=self.client_id, api_key=self.api_key)
# Authentication should raise error
with self.assertRaises(AuthenticationError):
client.authenticate()
@patch('httpx.Client.request')
@patch('httpx.Client.post')
def test_request_with_authentication(self, mock_post, mock_request):
"""Test request flow with authentication."""
# Mock authentication response
auth_response = MagicMock()
auth_response.status_code = 201
auth_response.json.return_value = {
"token": "test_token",
"expires_at": (datetime.now() + timedelta(minutes=30)).isoformat()
}
mock_post.return_value = auth_response
# Mock API request response
request_response = MagicMock()
request_response.status_code = 200
request_response.json.return_value = {"id": "test_id", "name": "Test Account"}
mock_request.return_value = request_response
# Initialize client
client = AirwallexClient(client_id=self.client_id, api_key=self.api_key)
# Make request
response = client._request("GET", "/api/v1/test")
# Check authentication was called
mock_post.assert_called_once()
# Check request was made with authentication token
mock_request.assert_called_once()
args, kwargs = mock_request.call_args
self.assertEqual(args[0], "GET")
self.assertEqual(args[1], "/api/v1/test")
self.assertEqual(kwargs['headers']['Authorization'], "Bearer test_token")
# Check response
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"id": "test_id", "name": "Test Account"})
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,249 @@
"""
Tests for the Airwallex SDK exception handling.
"""
import unittest
from unittest.mock import MagicMock
import json
from airwallex.exceptions import (
AirwallexAPIError,
AuthenticationError,
RateLimitError,
ResourceNotFoundError,
ValidationError,
ServerError,
ResourceExistsError,
AmountLimitError,
EditForbiddenError,
CurrencyError,
DateError,
TransferMethodError,
ConversionError,
ServiceUnavailableError,
create_exception_from_response
)
class TestExceptions(unittest.TestCase):
"""Tests for the exception handling system."""
def setUp(self):
"""Set up test fixtures."""
self.mock_response = MagicMock()
self.method = "GET"
self.url = "/api/v1/test"
self.kwargs = {"headers": {"Authorization": "Bearer test_token"}}
def test_exception_from_status_code(self):
"""Test creating exceptions based only on status code."""
# 401 should create AuthenticationError
self.mock_response.status_code = 401
self.mock_response.json.side_effect = ValueError("Invalid JSON")
exception = create_exception_from_response(
response=self.mock_response,
method=self.method,
url=self.url,
kwargs=self.kwargs
)
self.assertIsInstance(exception, AuthenticationError)
self.assertEqual(exception.status_code, 401)
# 404 should create ResourceNotFoundError
self.mock_response.status_code = 404
exception = create_exception_from_response(
response=self.mock_response,
method=self.method,
url=self.url,
kwargs=self.kwargs
)
self.assertIsInstance(exception, ResourceNotFoundError)
self.assertEqual(exception.status_code, 404)
# 429 should create RateLimitError
self.mock_response.status_code = 429
exception = create_exception_from_response(
response=self.mock_response,
method=self.method,
url=self.url,
kwargs=self.kwargs
)
self.assertIsInstance(exception, RateLimitError)
self.assertEqual(exception.status_code, 429)
# 400 should create ValidationError
self.mock_response.status_code = 400
exception = create_exception_from_response(
response=self.mock_response,
method=self.method,
url=self.url,
kwargs=self.kwargs
)
self.assertIsInstance(exception, ValidationError)
self.assertEqual(exception.status_code, 400)
# 500 should create ServerError
self.mock_response.status_code = 500
exception = create_exception_from_response(
response=self.mock_response,
method=self.method,
url=self.url,
kwargs=self.kwargs
)
self.assertIsInstance(exception, ServerError)
self.assertEqual(exception.status_code, 500)
def test_exception_from_error_code(self):
"""Test creating exceptions based on error code."""
self.mock_response.status_code = 400
# Test credentials_invalid (should be AuthenticationError)
self.mock_response.json.return_value = {
"code": "credentials_invalid",
"message": "Invalid credentials",
"source": "api_key"
}
exception = create_exception_from_response(
response=self.mock_response,
method=self.method,
url=self.url,
kwargs=self.kwargs
)
self.assertIsInstance(exception, AuthenticationError)
self.assertEqual(exception.error_code, "credentials_invalid")
self.assertEqual(exception.error_source, "api_key")
# Test already_exists (should be ResourceExistsError)
self.mock_response.json.return_value = {
"code": "already_exists",
"message": "Resource already exists",
"source": "id"
}
exception = create_exception_from_response(
response=self.mock_response,
method=self.method,
url=self.url,
kwargs=self.kwargs
)
self.assertIsInstance(exception, ResourceExistsError)
self.assertEqual(exception.error_code, "already_exists")
# Test amount_above_limit (should be AmountLimitError)
self.mock_response.json.return_value = {
"code": "amount_above_limit",
"message": "Amount exceeds the maximum limit",
"source": "amount"
}
exception = create_exception_from_response(
response=self.mock_response,
method=self.method,
url=self.url,
kwargs=self.kwargs
)
self.assertIsInstance(exception, AmountLimitError)
self.assertEqual(exception.error_code, "amount_above_limit")
# Test invalid_currency_pair (should be CurrencyError)
self.mock_response.json.return_value = {
"code": "invalid_currency_pair",
"message": "The currency pair is invalid",
"source": "currency_pair"
}
exception = create_exception_from_response(
response=self.mock_response,
method=self.method,
url=self.url,
kwargs=self.kwargs
)
self.assertIsInstance(exception, CurrencyError)
self.assertEqual(exception.error_code, "invalid_currency_pair")
# Test invalid_transfer_date (should be DateError)
self.mock_response.json.return_value = {
"code": "invalid_transfer_date",
"message": "The transfer date is invalid",
"source": "transfer_date"
}
exception = create_exception_from_response(
response=self.mock_response,
method=self.method,
url=self.url,
kwargs=self.kwargs
)
self.assertIsInstance(exception, DateError)
self.assertEqual(exception.error_code, "invalid_transfer_date")
# Test service_unavailable (should be ServiceUnavailableError)
self.mock_response.status_code = 503
self.mock_response.json.return_value = {
"code": "service_unavailable",
"message": "The service is currently unavailable",
}
exception = create_exception_from_response(
response=self.mock_response,
method=self.method,
url=self.url,
kwargs=self.kwargs
)
self.assertIsInstance(exception, ServiceUnavailableError)
self.assertEqual(exception.error_code, "service_unavailable")
def test_exception_string_representation(self):
"""Test the string representation of exceptions."""
self.mock_response.status_code = 400
self.mock_response.json.return_value = {
"code": "invalid_argument",
"message": "The argument is invalid",
"source": "amount"
}
exception = create_exception_from_response(
response=self.mock_response,
method=self.method,
url=self.url,
kwargs=self.kwargs
)
expected_str = "Airwallex API Error (HTTP 400): [invalid_argument] The argument is invalid (source: amount) for GET /api/v1/test"
self.assertEqual(str(exception), expected_str)
# Test without source
self.mock_response.json.return_value = {
"code": "invalid_argument",
"message": "The argument is invalid"
}
exception = create_exception_from_response(
response=self.mock_response,
method=self.method,
url=self.url,
kwargs=self.kwargs
)
expected_str = "Airwallex API Error (HTTP 400): [invalid_argument] The argument is invalid for GET /api/v1/test"
self.assertEqual(str(exception), expected_str)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,168 @@
"""
Tests for the Airwallex SDK invoice API.
"""
import unittest
from unittest.mock import patch, MagicMock
from datetime import datetime, timedelta
from airwallex import AirwallexClient
from airwallex.models.invoice import (
Invoice,
InvoiceItem,
InvoicePreviewRequest,
InvoicePreviewResponse
)
class TestInvoiceAPI(unittest.TestCase):
"""Tests for the Invoice API."""
def setUp(self):
"""Set up test fixtures."""
self.client_id = "test_client_id"
self.api_key = "test_api_key"
# Create a client with authentication mocked
patcher = patch.object(AirwallexClient, 'authenticate')
self.mock_auth = patcher.start()
self.addCleanup(patcher.stop)
self.client = AirwallexClient(client_id=self.client_id, api_key=self.api_key)
self.client._token = "test_token"
self.client._token_expiry = datetime.now() + timedelta(minutes=30)
@patch('httpx.Client.request')
def test_list_invoices(self, mock_request):
"""Test listing invoices."""
# Mock response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"items": [
{
"id": "inv_test123",
"customer_id": "cus_test456",
"currency": "USD",
"total_amount": 100.00,
"status": "PAID",
"period_start_at": "2023-01-01T00:00:00Z",
"period_end_at": "2023-02-01T00:00:00Z",
"created_at": "2023-01-01T00:00:00Z"
}
],
"has_more": False
}
mock_request.return_value = mock_response
# Call list method
invoices = self.client.invoice.list()
# Check request was made correctly
mock_request.assert_called_once()
args, kwargs = mock_request.call_args
self.assertEqual(args[0], "GET")
self.assertTrue(args[1].endswith("/invoices"))
# Check response parsing
self.assertEqual(len(invoices), 1)
self.assertIsInstance(invoices[0], Invoice)
self.assertEqual(invoices[0].id, "inv_test123")
self.assertEqual(invoices[0].currency, "USD")
self.assertEqual(invoices[0].total_amount, 100.00)
@patch('httpx.Client.request')
def test_get_invoice(self, mock_request):
"""Test fetching a single invoice."""
# Mock response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"id": "inv_test123",
"customer_id": "cus_test456",
"currency": "USD",
"total_amount": 100.00,
"status": "PAID",
"period_start_at": "2023-01-01T00:00:00Z",
"period_end_at": "2023-02-01T00:00:00Z",
"created_at": "2023-01-01T00:00:00Z"
}
mock_request.return_value = mock_response
# Call fetch method
invoice = self.client.invoice.fetch("inv_test123")
# Check request was made correctly
mock_request.assert_called_once()
args, kwargs = mock_request.call_args
self.assertEqual(args[0], "GET")
self.assertTrue(args[1].endswith("/invoices/inv_test123"))
# Check response parsing
self.assertIsInstance(invoice, Invoice)
self.assertEqual(invoice.id, "inv_test123")
self.assertEqual(invoice.currency, "USD")
self.assertEqual(invoice.total_amount, 100.00)
@patch('httpx.Client.request')
def test_preview_invoice(self, mock_request):
"""Test previewing an invoice."""
# Mock response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"customer_id": "cus_test456",
"currency": "USD",
"total_amount": 100.00,
"created_at": "2023-01-01T00:00:00Z",
"items": [
{
"id": "item_test123",
"invoice_id": "inv_test123",
"amount": 100.00,
"currency": "USD",
"period_start_at": "2023-01-01T00:00:00Z",
"period_end_at": "2023-02-01T00:00:00Z",
"price": {
"id": "pri_test123",
"name": "Test Price",
"active": True,
"currency": "USD",
"product_id": "prod_test123",
"pricing_model": "flat"
}
}
]
}
mock_request.return_value = mock_response
# Create preview request
preview_request = InvoicePreviewRequest(
customer_id="cus_test456",
items=[
InvoicePreviewRequest.SubscriptionItem(
price_id="pri_test123",
quantity=1
)
]
)
# Call preview method
preview = self.client.invoice.preview(preview_request)
# Check request was made correctly
mock_request.assert_called_once()
args, kwargs = mock_request.call_args
self.assertEqual(args[0], "POST")
self.assertTrue(args[1].endswith("/invoices/preview"))
# Check response parsing
self.assertIsInstance(preview, InvoicePreviewResponse)
self.assertEqual(preview.customer_id, "cus_test456")
self.assertEqual(preview.currency, "USD")
self.assertEqual(preview.total_amount, 100.00)
self.assertEqual(len(preview.items), 1)
self.assertEqual(preview.items[0].amount, 100.00)
if __name__ == '__main__':
unittest.main()