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:
107
airwallex-sdk/tests/test_client.py
Normal file
107
airwallex-sdk/tests/test_client.py
Normal 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()
|
||||
249
airwallex-sdk/tests/test_exceptions.py
Normal file
249
airwallex-sdk/tests/test_exceptions.py
Normal 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()
|
||||
168
airwallex-sdk/tests/test_invoice.py
Normal file
168
airwallex-sdk/tests/test_invoice.py
Normal 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()
|
||||
Reference in New Issue
Block a user