diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..7a1f2fc --- /dev/null +++ b/tests/README.md @@ -0,0 +1,258 @@ +# FastAPI PayFast Tests + +Comprehensive test suite for the FastAPI PayFast integration package. + +## Test Structure + +``` +tests/ +├── __init__.py # Package initialization +├── conftest.py # Pytest configuration and shared fixtures +├── test_config.py # Configuration tests +├── test_models.py # Data model tests +├── test_utils.py # Utility function tests +├── test_client.py # Client functionality tests +├── test_exceptions.py # Exception handling tests +└── test_integration.py # Integration tests +``` + +## Running Tests + +### Run all tests +```bash +pytest +``` + +### Run with coverage +```bash +pytest --cov=fastapi_payfast --cov-report=html --cov-report=term +``` + +### Run specific test file +```bash +pytest tests/test_config.py +``` + +### Run specific test class +```bash +pytest tests/test_config.py::TestPayFastConfig +``` + +### Run specific test method +```bash +pytest tests/test_config.py::TestPayFastConfig::test_config_initialization +``` + +### Run tests by marker +```bash +# Run only unit tests +pytest -m unit + +# Run only integration tests +pytest -m integration + +# Skip slow tests +pytest -m "not slow" +``` + +### Run with verbose output +```bash +pytest -v +``` + +### Run with output capture disabled (see print statements) +```bash +pytest -s +``` + +## Test Categories + +### Unit Tests + +**test_config.py** - Configuration tests +- Configuration initialization +- Sandbox/production URL generation +- Valid IP addresses +- Immutability +- Default values + +**test_models.py** - Data model tests +- Payment data validation +- ITN data parsing +- Enum values +- Amount rounding +- Field validation + +**test_utils.py** - Utility function tests +- Signature generation +- Signature determinism +- HTML form generation +- Special character handling +- Passphrase support + +**test_exceptions.py** - Exception tests +- Custom exception creation +- Exception inheritance +- HTTP exception conversion +- Error messages + +### Integration Tests + +**test_client.py** - Client functionality tests +- Payment creation +- Form generation +- ITN verification +- Amount validation +- Payment status checking + +**test_integration.py** - Full workflow tests +- Complete payment flow +- Endpoint integration +- Error handling +- Concurrent requests + +## Test Fixtures + +### Configuration Fixtures +- `test_config` - Session-scoped test configuration +- `config` - PayFast configuration object +- `client` - PayFast client instance + +### Data Fixtures +- `sample_payment_data` - Sample payment data dictionary +- `sample_itn_data` - Sample ITN data dictionary +- `payment_data` - PayFastPaymentData object + +### Mock Fixtures +- `mock_request_factory` - Factory for creating mock FastAPI requests +- `app` - FastAPI application instance +- `client` - TestClient for API testing + +## Coverage Goals + +The test suite aims for: +- **90%+ overall coverage** +- **100% coverage** for critical paths: + - Signature verification + - Amount validation + - Payment status checking + - Exception handling + +## Writing New Tests + +### Test Naming Convention +```python +def test__(): + """Brief description of what the test does""" + # Arrange + # Act + # Assert +``` + +### Example Test +```python +def test_validate_payment_amount_exact_match(client): + """Test payment amount validation with exact match""" + # Arrange + itn_data = PayFastITNData(...) + expected_amount = 100.00 + + # Act + result = client.validate_payment_amount(itn_data, expected_amount) + + # Assert + assert result is True +``` + +### Async Tests +```python +@pytest.mark.asyncio +async def test_verify_itn_success(client): + """Test successful ITN verification""" + request = mock_request_factory(valid_data) + itn_data = await client.verify_itn(request) + assert itn_data.payment_status == PaymentStatus.COMPLETE +``` + +## Continuous Integration + +Tests are automatically run on: +- Every push to main branch +- Every pull request +- Before package release + +### GitHub Actions Workflow +```yaml +- name: Run tests + run: | + pytest --cov=fastapi_payfast --cov-report=xml + +- name: Upload coverage + uses: codecov/codecov-action@v3 +``` + +## Troubleshooting + +### Common Issues + +**Import Errors** +```bash +# Make sure package is installed in editable mode +pip install -e . +``` + +**Async Test Errors** +```bash +# Install pytest-asyncio +pip install pytest-asyncio +``` + +**Coverage Not Generated** +```bash +# Install pytest-cov +pip install pytest-cov +``` + +### Debug Mode +```bash +# Run with Python debugger +pytest --pdb + +# Drop into debugger on failure +pytest --pdb --maxfail=1 +``` + +## Performance Testing + +### Measure Test Execution Time +```bash +pytest --durations=10 +``` + +### Profile Tests +```bash +pytest --profile +``` + +## Test Data + +All test data uses PayFast sandbox credentials: +- Merchant ID: `10000100` +- Merchant Key: `46f0cd694581a` +- Passphrase: `jt7NOE43FZPn` + +**Never use production credentials in tests!** + +## Contributing + +When adding new features: +1. Write tests first (TDD approach) +2. Ensure all tests pass +3. Maintain coverage above 90% +4. Update this README if adding new test categories + +## Resources + +- [Pytest Documentation](https://docs.pytest.org/) +- [FastAPI Testing](https://fastapi.tiangolo.com/tutorial/testing/) +- [PayFast API Documentation](https://developers.payfast.co.za/) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d30fcf5 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +"""Tests package initialization""" + +# This file makes the tests directory a Python package \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d6b6a3d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,88 @@ +"""Pytest configuration and shared fixtures""" + +import pytest +import sys +from pathlib import Path + +# Add project root to Python path +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + + +@pytest.fixture(scope="session") +def test_config(): + """Session-scoped test configuration""" + return { + "merchant_id": "10000100", + "merchant_key": "46f0cd694581a", + "passphrase": "jt7NOE43FZPn", + "sandbox": True + } + + +@pytest.fixture +def sample_payment_data(): + """Sample payment data for testing""" + return { + "merchant_id": "10000100", + "merchant_key": "46f0cd694581a", + "amount": 100.00, + "item_name": "Test Product", + "item_description": "Test Description", + "return_url": "https://example.com/success", + "cancel_url": "https://example.com/cancel", + "notify_url": "https://example.com/notify" + } + + +@pytest.fixture +def sample_itn_data(): + """Sample ITN data for testing""" + return { + "merchant_id": "10000100", + "pf_payment_id": "12345", + "payment_status": "COMPLETE", + "item_name": "Test Product", + "amount_gross": 100.00, + "amount_fee": 5.00, + "amount_net": 95.00, + "custom_str1": "custom_value", + "custom_int1": 42 + } + + +@pytest.fixture +def mock_request_factory(): + """Factory for creating mock FastAPI requests""" + from unittest.mock import Mock, AsyncMock + from fastapi import Request + + def create_mock_request(form_data, client_ip="197.97.145.144"): + request = Mock(spec=Request) + request.form = AsyncMock(return_value=form_data) + request.client = Mock() + request.client.host = client_ip + return request + + return create_mock_request + + +@pytest.fixture(autouse=True) +def reset_environment(): + """Reset environment before each test""" + yield + # Cleanup after test if needed + + +# Pytest configuration +def pytest_configure(config): + """Configure pytest with custom markers""" + config.addinivalue_line( + "markers", "slow: marks tests as slow (deselect with '-m \"not slow\"')" + ) + config.addinivalue_line( + "markers", "integration: marks tests as integration tests" + ) + config.addinivalue_line( + "markers", "unit: marks tests as unit tests" + ) \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..a887341 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,318 @@ +"""Tests for PayFast client""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch +from fastapi import Request +from fastapi.responses import HTMLResponse +from dotzen import config + +from fastapi_payfast import ( + PayFastClient, + PayFastConfig, + PayFastPaymentData, + PayFastITNData, + PaymentStatus, + SignatureVerificationError, + InvalidMerchantError +) + + +@pytest.fixture +def config(): + """Fixture for PayFast configuration""" + return PayFastConfig( + merchant_id="10000100", + merchant_key="46f0cd694581a", + passphrase=config.get("PAYFAST_PASSPHRASE", "your_passphrase"), + sandbox=True + ) + + +@pytest.fixture +def client(config): + """Fixture for PayFast client""" + return PayFastClient(config) + + +@pytest.fixture +def payment_data(config): + """Fixture for payment data""" + return PayFastPaymentData( + merchant_id=config.merchant_id, + merchant_key=config.merchant_key, + amount=100.00, + item_name="Test Product", + return_url="https://example.com/success", + cancel_url="https://example.com/cancel", + notify_url="https://example.com/notify" + ) + + +class TestPayFastClient: + """Test suite for PayFastClient""" + + def test_client_initialization(self, config): + """Test client initialization""" + client = PayFastClient(config) + assert client.config == config + + def test_create_payment(self, client, payment_data): + """Test creating payment request""" + result = client.create_payment(payment_data) + + assert 'action_url' in result + assert 'data' in result + assert result['action_url'] == "https://sandbox.payfast.co.za/eng/process" + assert 'signature' in result['data'] + assert result['data']['merchant_id'] == "10000100" + assert result['data']['amount'] == 100.00 + + def test_create_payment_signature_included(self, client, payment_data): + """Test that signature is included in payment data""" + result = client.create_payment(payment_data) + + assert 'signature' in result['data'] + assert len(result['data']['signature']) == 32 # MD5 hash + + def test_create_payment_removes_none_values(self, client, config): + """Test that None values are removed from payment data""" + payment_data = PayFastPaymentData( + merchant_id=config.merchant_id, + merchant_key=config.merchant_key, + amount=100.00, + item_name="Test Product", + item_description=None # None value + ) + + result = client.create_payment(payment_data) + assert 'item_description' not in result['data'] + + def test_generate_payment_form(self, client, payment_data): + """Test generating payment form HTML""" + html = client.generate_payment_form(payment_data) + + assert isinstance(html, str) + assert '' in html + assert 'sandbox.payfast.co.za' in html + assert 'merchant_id' in html + assert 'signature' in html + + def test_generate_payment_response(self, client, payment_data): + """Test generating payment HTMLResponse""" + response = client.generate_payment_response(payment_data) + + assert isinstance(response, HTMLResponse) + assert '' in response.body.decode() + + @pytest.mark.asyncio + async def test_verify_itn_success(self, client, config): + """Test successful ITN verification""" + # Create mock request with form data + form_data = { + 'merchant_id': config.merchant_id, + 'pf_payment_id': '12345', + 'payment_status': 'COMPLETE', + 'item_name': 'Test Product', + 'amount_gross': '100.00', + 'amount_fee': '5.00', + 'amount_net': '95.00', + } + + # Generate valid signature + from fastapi_payfast.utils import generate_signature + form_data['signature'] = generate_signature(form_data, config.passphrase) + + # Create mock request + request = Mock(spec=Request) + request.form = AsyncMock(return_value=form_data) + request.client = Mock() + request.client.host = "197.97.145.144" + + # Verify ITN + itn_data = await client.verify_itn(request) + + assert isinstance(itn_data, PayFastITNData) + assert itn_data.pf_payment_id == '12345' + assert itn_data.payment_status == PaymentStatus.COMPLETE + assert itn_data.amount_gross == 100.00 + + @pytest.mark.asyncio + async def test_verify_itn_missing_signature(self, client, config): + """Test ITN verification with missing signature""" + form_data = { + 'merchant_id': config.merchant_id, + 'pf_payment_id': '12345', + 'payment_status': 'COMPLETE', + 'item_name': 'Test Product', + 'amount_gross': '100.00', + 'amount_fee': '5.00', + 'amount_net': '95.00', + } + + request = Mock(spec=Request) + request.form = AsyncMock(return_value=form_data) + + with pytest.raises(SignatureVerificationError, match="Missing signature"): + await client.verify_itn(request) + + @pytest.mark.asyncio + async def test_verify_itn_invalid_signature(self, client, config): + """Test ITN verification with invalid signature""" + form_data = { + 'merchant_id': config.merchant_id, + 'pf_payment_id': '12345', + 'payment_status': 'COMPLETE', + 'item_name': 'Test Product', + 'amount_gross': '100.00', + 'amount_fee': '5.00', + 'amount_net': '95.00', + 'signature': 'invalid_signature_123' + } + + request = Mock(spec=Request) + request.form = AsyncMock(return_value=form_data) + + with pytest.raises(SignatureVerificationError, match="Signature mismatch"): + await client.verify_itn(request) + + @pytest.mark.asyncio + async def test_verify_itn_invalid_merchant(self, client, config): + """Test ITN verification with invalid merchant ID""" + form_data = { + 'merchant_id': 'wrong_merchant_id', + 'pf_payment_id': '12345', + 'payment_status': 'COMPLETE', + 'item_name': 'Test Product', + 'amount_gross': '100.00', + 'amount_fee': '5.00', + 'amount_net': '95.00', + } + + from fastapi_payfast.utils import generate_signature + form_data['signature'] = generate_signature(form_data, config.passphrase) + + request = Mock(spec=Request) + request.form = AsyncMock(return_value=form_data) + + with pytest.raises(InvalidMerchantError): + await client.verify_itn(request) + + def test_validate_payment_amount_exact_match(self, client): + """Test payment amount validation with exact match""" + itn_data = PayFastITNData( + pf_payment_id="12345", + payment_status=PaymentStatus.COMPLETE, + item_name="Test", + amount_gross=100.00, + amount_fee=5.00, + amount_net=95.00, + merchant_id="10000100", + signature="abc123" + ) + + assert client.validate_payment_amount(itn_data, 100.00) + + def test_validate_payment_amount_within_tolerance(self, client): + """Test payment amount validation within tolerance""" + itn_data = PayFastITNData( + pf_payment_id="12345", + payment_status=PaymentStatus.COMPLETE, + item_name="Test", + amount_gross=100.00, + amount_fee=5.00, + amount_net=95.00, + merchant_id="10000100", + signature="abc123" + ) + + assert client.validate_payment_amount(itn_data, 100.005) + + def test_validate_payment_amount_outside_tolerance(self, client): + """Test payment amount validation outside tolerance""" + itn_data = PayFastITNData( + pf_payment_id="12345", + payment_status=PaymentStatus.COMPLETE, + item_name="Test", + amount_gross=100.00, + amount_fee=5.00, + amount_net=95.00, + merchant_id="10000100", + signature="abc123" + ) + + assert not client.validate_payment_amount(itn_data, 105.00) + + def test_validate_payment_amount_custom_tolerance(self, client): + """Test payment amount validation with custom tolerance""" + itn_data = PayFastITNData( + pf_payment_id="12345", + payment_status=PaymentStatus.COMPLETE, + item_name="Test", + amount_gross=100.00, + amount_fee=5.00, + amount_net=95.00, + merchant_id="10000100", + signature="abc123" + ) + + assert client.validate_payment_amount(itn_data, 105.00, tolerance=5.0) + + def test_is_payment_successful_complete(self, client): + """Test is_payment_successful with COMPLETE status""" + itn_data = PayFastITNData( + pf_payment_id="12345", + payment_status=PaymentStatus.COMPLETE, + item_name="Test", + amount_gross=100.00, + amount_fee=5.00, + amount_net=95.00, + merchant_id="10000100", + signature="abc123" + ) + + assert client.is_payment_successful(itn_data) + + def test_is_payment_successful_failed(self, client): + """Test is_payment_successful with FAILED status""" + itn_data = PayFastITNData( + pf_payment_id="12345", + payment_status=PaymentStatus.FAILED, + item_name="Test", + amount_gross=100.00, + amount_fee=5.00, + amount_net=95.00, + merchant_id="10000100", + signature="abc123" + ) + + assert not client.is_payment_successful(itn_data) + + def test_is_payment_successful_pending(self, client): + """Test is_payment_successful with PENDING status""" + itn_data = PayFastITNData( + pf_payment_id="12345", + payment_status=PaymentStatus.PENDING, + item_name="Test", + amount_gross=100.00, + amount_fee=5.00, + amount_net=95.00, + merchant_id="10000100", + signature="abc123" + ) + + assert not client.is_payment_successful(itn_data) + + def test_is_payment_successful_cancelled(self, client): + """Test is_payment_successful with CANCELLED status""" + itn_data = PayFastITNData( + pf_payment_id="12345", + payment_status=PaymentStatus.CANCELLED, + item_name="Test", + amount_gross=100.00, + amount_fee=5.00, + amount_net=95.00, + merchant_id="10000100", + signature="abc123" + ) + + assert not client.is_payment_successful(itn_data) \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..d47a7c0 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,113 @@ +"""Tests for PayFast configuration""" + +import pytest +from fastapi_payfast.config import PayFastConfig + + +class TestPayFastConfig: + """Test suite for PayFastConfig""" + + def test_config_initialization(self): + """Test basic configuration initialization""" + config = PayFastConfig( + merchant_id="10000100", + merchant_key="46f0cd694581a", + passphrase="jt7NOE43FZPn", + sandbox=True + ) + + assert config.merchant_id == "10000100" + assert config.merchant_key == "46f0cd694581a" + assert config.passphrase == "jt7NOE43FZPn" + assert config.sandbox is True + assert config.validate_ip is True + + def test_config_sandbox_process_url(self): + """Test sandbox process URL""" + config = PayFastConfig( + merchant_id="10000100", + merchant_key="46f0cd694581a", + passphrase="jt7NOE43FZPn", + sandbox=True + ) + + assert config.process_url == "https://sandbox.payfast.co.za/eng/process" + + def test_config_production_process_url(self): + """Test production process URL""" + config = PayFastConfig( + merchant_id="10000100", + merchant_key="46f0cd694581a", + passphrase="jt7NOE43FZPn", + sandbox=False + ) + + assert config.process_url == "https://www.payfast.co.za/eng/process" + + def test_config_sandbox_validate_url(self): + """Test sandbox validate URL""" + config = PayFastConfig( + merchant_id="10000100", + merchant_key="46f0cd694581a", + passphrase="jt7NOE43FZPn", + sandbox=True + ) + + assert config.validate_url == "https://sandbox.payfast.co.za/eng/query/validate" + + def test_config_production_validate_url(self): + """Test production validate URL""" + config = PayFastConfig( + merchant_id="10000100", + merchant_key="46f0cd694581a", + passphrase="jt7NOE43FZPn", + sandbox=False + ) + + assert config.validate_url == "https://www.payfast.co.za/eng/query/validate" + + def test_config_valid_ips(self): + """Test valid PayFast IP addresses""" + config = PayFastConfig( + merchant_id="10000100", + merchant_key="46f0cd694581a", + passphrase="jt7NOE43FZPn" + ) + + expected_ips = [ + "197.97.145.144", + "41.74.179.194", + ] + + assert config.valid_ips == expected_ips + + def test_config_immutability(self): + """Test that config is immutable""" + config = PayFastConfig( + merchant_id="10000100", + merchant_key="46f0cd694581a", + passphrase="jt7NOE43FZPn" + ) + + with pytest.raises(Exception): + config.merchant_id = "new_id" + + def test_config_missing_required_fields(self): + """Test that missing required fields raise validation error""" + with pytest.raises(Exception): + PayFastConfig( + merchant_id="10000100", + merchant_key="46f0cd694581a" + # Missing passphrase + ) + + def test_config_default_values(self): + """Test default configuration values""" + config = PayFastConfig( + merchant_id="10000100", + merchant_key="46f0cd694581a", + passphrase="jt7NOE43FZPn" + ) + + assert config.sandbox is True + assert config.validate_ip is True \ No newline at end of file diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..2b259e4 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,173 @@ +"""Tests for PayFast exceptions""" + +import pytest +from fastapi import HTTPException, status + +from fastapi_payfast.exceptions import ( + PayFastException, + SignatureVerificationError, + InvalidMerchantError, + InvalidAmountError +) + + +class TestPayFastException: + """Test suite for PayFastException""" + + def test_payfast_exception_basic(self): + """Test basic PayFastException""" + exc = PayFastException("Test error") + assert str(exc) == "Test error" + + def test_payfast_exception_inheritance(self): + """Test that PayFastException inherits from Exception""" + exc = PayFastException("Test error") + assert isinstance(exc, Exception) + + +class TestSignatureVerificationError: + """Test suite for SignatureVerificationError""" + + def test_signature_verification_error_default_message(self): + """Test SignatureVerificationError with default message""" + exc = SignatureVerificationError() + assert exc.message == "Invalid signature" + assert str(exc) == "Invalid signature" + + def test_signature_verification_error_custom_message(self): + """Test SignatureVerificationError with custom message""" + exc = SignatureVerificationError("Custom error message") + assert exc.message == "Custom error message" + assert str(exc) == "Custom error message" + + def test_signature_verification_error_inheritance(self): + """Test that SignatureVerificationError inherits from PayFastException""" + exc = SignatureVerificationError() + assert isinstance(exc, PayFastException) + + def test_signature_verification_error_to_http_exception(self): + """Test converting to HTTPException""" + exc = SignatureVerificationError("Signature mismatch") + http_exc = exc.to_http_exception() + + assert isinstance(http_exc, HTTPException) + assert http_exc.status_code == status.HTTP_400_BAD_REQUEST + assert http_exc.detail == "Signature mismatch" + + +class TestInvalidMerchantError: + """Test suite for InvalidMerchantError""" + + def test_invalid_merchant_error_default_message(self): + """Test InvalidMerchantError with default message""" + exc = InvalidMerchantError() + assert exc.message == "Invalid merchant ID" + assert str(exc) == "Invalid merchant ID" + + def test_invalid_merchant_error_custom_message(self): + """Test InvalidMerchantError with custom message""" + exc = InvalidMerchantError("Merchant ID does not match") + assert exc.message == "Merchant ID does not match" + assert str(exc) == "Merchant ID does not match" + + def test_invalid_merchant_error_inheritance(self): + """Test that InvalidMerchantError inherits from PayFastException""" + exc = InvalidMerchantError() + assert isinstance(exc, PayFastException) + + def test_invalid_merchant_error_to_http_exception(self): + """Test converting to HTTPException""" + exc = InvalidMerchantError("Invalid merchant") + http_exc = exc.to_http_exception() + + assert isinstance(http_exc, HTTPException) + assert http_exc.status_code == status.HTTP_400_BAD_REQUEST + assert http_exc.detail == "Invalid merchant" + + +class TestInvalidAmountError: + """Test suite for InvalidAmountError""" + + def test_invalid_amount_error_basic(self): + """Test InvalidAmountError with expected and received amounts""" + exc = InvalidAmountError(expected=100.00, received=95.00) + + assert exc.expected == 100.00 + assert exc.received == 95.00 + assert "expected 100.0" in exc.message + assert "received 95.0" in exc.message + + def test_invalid_amount_error_message_format(self): + """Test InvalidAmountError message format""" + exc = InvalidAmountError(expected=100.00, received=105.50) + + assert exc.message == "Amount mismatch: expected 100.0, received 105.5" + assert str(exc) == "Amount mismatch: expected 100.0, received 105.5" + + def test_invalid_amount_error_inheritance(self): + """Test that InvalidAmountError inherits from PayFastException""" + exc = InvalidAmountError(expected=100.00, received=95.00) + assert isinstance(exc, PayFastException) + + def test_invalid_amount_error_to_http_exception(self): + """Test converting to HTTPException""" + exc = InvalidAmountError(expected=100.00, received=95.00) + http_exc = exc.to_http_exception() + + assert isinstance(http_exc, HTTPException) + assert http_exc.status_code == status.HTTP_400_BAD_REQUEST + assert "Amount mismatch" in http_exc.detail + + def test_invalid_amount_error_different_amounts(self): + """Test InvalidAmountError with various amounts""" + test_cases = [ + (50.00, 45.00), + (1000.00, 1005.00), + (0.01, 0.02), + (99.99, 100.00) + ] + + for expected, received in test_cases: + exc = InvalidAmountError(expected=expected, received=received) + assert exc.expected == expected + assert exc.received == received + assert f"expected {expected}" in exc.message + assert f"received {received}" in exc.message + + +class TestExceptionHandling: + """Test suite for exception handling scenarios""" + + def test_catching_payfast_exception(self): + """Test catching PayFastException base class""" + try: + raise SignatureVerificationError("Test error") + except PayFastException as e: + assert str(e) == "Test error" + + def test_catching_specific_exceptions(self): + """Test catching specific exception types""" + exceptions = [ + SignatureVerificationError("Sig error"), + InvalidMerchantError("Merchant error"), + InvalidAmountError(100.00, 95.00) + ] + + for exc in exceptions: + try: + raise exc + except PayFastException as e: + assert isinstance(e, PayFastException) + + def test_http_exception_conversion(self): + """Test converting all custom exceptions to HTTPException""" + exceptions = [ + SignatureVerificationError("Sig error"), + InvalidMerchantError("Merchant error"), + InvalidAmountError(100.00, 95.00) + ] + + for exc in exceptions: + http_exc = exc.to_http_exception() + assert isinstance(http_exc, HTTPException) + assert http_exc.status_code == status.HTTP_400_BAD_REQUEST \ No newline at end of file diff --git a/tests/test_integrations.py b/tests/test_integrations.py new file mode 100644 index 0000000..2b259e4 --- /dev/null +++ b/tests/test_integrations.py @@ -0,0 +1,173 @@ +"""Tests for PayFast exceptions""" + +import pytest +from fastapi import HTTPException, status + +from fastapi_payfast.exceptions import ( + PayFastException, + SignatureVerificationError, + InvalidMerchantError, + InvalidAmountError +) + + +class TestPayFastException: + """Test suite for PayFastException""" + + def test_payfast_exception_basic(self): + """Test basic PayFastException""" + exc = PayFastException("Test error") + assert str(exc) == "Test error" + + def test_payfast_exception_inheritance(self): + """Test that PayFastException inherits from Exception""" + exc = PayFastException("Test error") + assert isinstance(exc, Exception) + + +class TestSignatureVerificationError: + """Test suite for SignatureVerificationError""" + + def test_signature_verification_error_default_message(self): + """Test SignatureVerificationError with default message""" + exc = SignatureVerificationError() + assert exc.message == "Invalid signature" + assert str(exc) == "Invalid signature" + + def test_signature_verification_error_custom_message(self): + """Test SignatureVerificationError with custom message""" + exc = SignatureVerificationError("Custom error message") + assert exc.message == "Custom error message" + assert str(exc) == "Custom error message" + + def test_signature_verification_error_inheritance(self): + """Test that SignatureVerificationError inherits from PayFastException""" + exc = SignatureVerificationError() + assert isinstance(exc, PayFastException) + + def test_signature_verification_error_to_http_exception(self): + """Test converting to HTTPException""" + exc = SignatureVerificationError("Signature mismatch") + http_exc = exc.to_http_exception() + + assert isinstance(http_exc, HTTPException) + assert http_exc.status_code == status.HTTP_400_BAD_REQUEST + assert http_exc.detail == "Signature mismatch" + + +class TestInvalidMerchantError: + """Test suite for InvalidMerchantError""" + + def test_invalid_merchant_error_default_message(self): + """Test InvalidMerchantError with default message""" + exc = InvalidMerchantError() + assert exc.message == "Invalid merchant ID" + assert str(exc) == "Invalid merchant ID" + + def test_invalid_merchant_error_custom_message(self): + """Test InvalidMerchantError with custom message""" + exc = InvalidMerchantError("Merchant ID does not match") + assert exc.message == "Merchant ID does not match" + assert str(exc) == "Merchant ID does not match" + + def test_invalid_merchant_error_inheritance(self): + """Test that InvalidMerchantError inherits from PayFastException""" + exc = InvalidMerchantError() + assert isinstance(exc, PayFastException) + + def test_invalid_merchant_error_to_http_exception(self): + """Test converting to HTTPException""" + exc = InvalidMerchantError("Invalid merchant") + http_exc = exc.to_http_exception() + + assert isinstance(http_exc, HTTPException) + assert http_exc.status_code == status.HTTP_400_BAD_REQUEST + assert http_exc.detail == "Invalid merchant" + + +class TestInvalidAmountError: + """Test suite for InvalidAmountError""" + + def test_invalid_amount_error_basic(self): + """Test InvalidAmountError with expected and received amounts""" + exc = InvalidAmountError(expected=100.00, received=95.00) + + assert exc.expected == 100.00 + assert exc.received == 95.00 + assert "expected 100.0" in exc.message + assert "received 95.0" in exc.message + + def test_invalid_amount_error_message_format(self): + """Test InvalidAmountError message format""" + exc = InvalidAmountError(expected=100.00, received=105.50) + + assert exc.message == "Amount mismatch: expected 100.0, received 105.5" + assert str(exc) == "Amount mismatch: expected 100.0, received 105.5" + + def test_invalid_amount_error_inheritance(self): + """Test that InvalidAmountError inherits from PayFastException""" + exc = InvalidAmountError(expected=100.00, received=95.00) + assert isinstance(exc, PayFastException) + + def test_invalid_amount_error_to_http_exception(self): + """Test converting to HTTPException""" + exc = InvalidAmountError(expected=100.00, received=95.00) + http_exc = exc.to_http_exception() + + assert isinstance(http_exc, HTTPException) + assert http_exc.status_code == status.HTTP_400_BAD_REQUEST + assert "Amount mismatch" in http_exc.detail + + def test_invalid_amount_error_different_amounts(self): + """Test InvalidAmountError with various amounts""" + test_cases = [ + (50.00, 45.00), + (1000.00, 1005.00), + (0.01, 0.02), + (99.99, 100.00) + ] + + for expected, received in test_cases: + exc = InvalidAmountError(expected=expected, received=received) + assert exc.expected == expected + assert exc.received == received + assert f"expected {expected}" in exc.message + assert f"received {received}" in exc.message + + +class TestExceptionHandling: + """Test suite for exception handling scenarios""" + + def test_catching_payfast_exception(self): + """Test catching PayFastException base class""" + try: + raise SignatureVerificationError("Test error") + except PayFastException as e: + assert str(e) == "Test error" + + def test_catching_specific_exceptions(self): + """Test catching specific exception types""" + exceptions = [ + SignatureVerificationError("Sig error"), + InvalidMerchantError("Merchant error"), + InvalidAmountError(100.00, 95.00) + ] + + for exc in exceptions: + try: + raise exc + except PayFastException as e: + assert isinstance(e, PayFastException) + + def test_http_exception_conversion(self): + """Test converting all custom exceptions to HTTPException""" + exceptions = [ + SignatureVerificationError("Sig error"), + InvalidMerchantError("Merchant error"), + InvalidAmountError(100.00, 95.00) + ] + + for exc in exceptions: + http_exc = exc.to_http_exception() + assert isinstance(http_exc, HTTPException) + assert http_exc.status_code == status.HTTP_400_BAD_REQUEST \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..ca46821 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,317 @@ +"""Tests for PayFast client""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch +from fastapi import Request +from fastapi.responses import HTMLResponse + +from fastapi_payfast import ( + PayFastClient, + PayFastConfig, + PayFastPaymentData, + PayFastITNData, + PaymentStatus, + SignatureVerificationError, + InvalidMerchantError +) + + +@pytest.fixture +def config(): + """Fixture for PayFast configuration""" + return PayFastConfig( + merchant_id="10000100", + merchant_key="46f0cd694581a", + passphrase="jt7NOE43FZPn", + sandbox=True + ) + + +@pytest.fixture +def client(config): + """Fixture for PayFast client""" + return PayFastClient(config) + + +@pytest.fixture +def payment_data(config): + """Fixture for payment data""" + return PayFastPaymentData( + merchant_id=config.merchant_id, + merchant_key=config.merchant_key, + amount=100.00, + item_name="Test Product", + return_url="https://example.com/success", + cancel_url="https://example.com/cancel", + notify_url="https://example.com/notify" + ) + + +class TestPayFastClient: + """Test suite for PayFastClient""" + + def test_client_initialization(self, config): + """Test client initialization""" + client = PayFastClient(config) + assert client.config == config + + def test_create_payment(self, client, payment_data): + """Test creating payment request""" + result = client.create_payment(payment_data) + + assert 'action_url' in result + assert 'data' in result + assert result['action_url'] == "https://sandbox.payfast.co.za/eng/process" + assert 'signature' in result['data'] + assert result['data']['merchant_id'] == "10000100" + assert result['data']['amount'] == 100.00 + + def test_create_payment_signature_included(self, client, payment_data): + """Test that signature is included in payment data""" + result = client.create_payment(payment_data) + + assert 'signature' in result['data'] + assert len(result['data']['signature']) == 32 # MD5 hash + + def test_create_payment_removes_none_values(self, client, config): + """Test that None values are removed from payment data""" + payment_data = PayFastPaymentData( + merchant_id=config.merchant_id, + merchant_key=config.merchant_key, + amount=100.00, + item_name="Test Product", + item_description=None # None value + ) + + result = client.create_payment(payment_data) + assert 'item_description' not in result['data'] + + def test_generate_payment_form(self, client, payment_data): + """Test generating payment form HTML""" + html = client.generate_payment_form(payment_data) + + assert isinstance(html, str) + assert '' in html + assert 'sandbox.payfast.co.za' in html + assert 'merchant_id' in html + assert 'signature' in html + + def test_generate_payment_response(self, client, payment_data): + """Test generating payment HTMLResponse""" + response = client.generate_payment_response(payment_data) + + assert isinstance(response, HTMLResponse) + assert '' in response.body.decode() + + @pytest.mark.asyncio + async def test_verify_itn_success(self, client, config): + """Test successful ITN verification""" + # Create mock request with form data + form_data = { + 'merchant_id': config.merchant_id, + 'pf_payment_id': '12345', + 'payment_status': 'COMPLETE', + 'item_name': 'Test Product', + 'amount_gross': '100.00', + 'amount_fee': '5.00', + 'amount_net': '95.00', + } + + # Generate valid signature + from fastapi_payfast.utils import generate_signature + form_data['signature'] = generate_signature(form_data, config.passphrase) + + # Create mock request + request = Mock(spec=Request) + request.form = AsyncMock(return_value=form_data) + request.client = Mock() + request.client.host = "197.97.145.144" + + # Verify ITN + itn_data = await client.verify_itn(request) + + assert isinstance(itn_data, PayFastITNData) + assert itn_data.pf_payment_id == '12345' + assert itn_data.payment_status == PaymentStatus.COMPLETE + assert itn_data.amount_gross == 100.00 + + @pytest.mark.asyncio + async def test_verify_itn_missing_signature(self, client, config): + """Test ITN verification with missing signature""" + form_data = { + 'merchant_id': config.merchant_id, + 'pf_payment_id': '12345', + 'payment_status': 'COMPLETE', + 'item_name': 'Test Product', + 'amount_gross': '100.00', + 'amount_fee': '5.00', + 'amount_net': '95.00', + } + + request = Mock(spec=Request) + request.form = AsyncMock(return_value=form_data) + + with pytest.raises(SignatureVerificationError, match="Missing signature"): + await client.verify_itn(request) + + @pytest.mark.asyncio + async def test_verify_itn_invalid_signature(self, client, config): + """Test ITN verification with invalid signature""" + form_data = { + 'merchant_id': config.merchant_id, + 'pf_payment_id': '12345', + 'payment_status': 'COMPLETE', + 'item_name': 'Test Product', + 'amount_gross': '100.00', + 'amount_fee': '5.00', + 'amount_net': '95.00', + 'signature': 'invalid_signature_123' + } + + request = Mock(spec=Request) + request.form = AsyncMock(return_value=form_data) + + with pytest.raises(SignatureVerificationError, match="Signature mismatch"): + await client.verify_itn(request) + + @pytest.mark.asyncio + async def test_verify_itn_invalid_merchant(self, client, config): + """Test ITN verification with invalid merchant ID""" + form_data = { + 'merchant_id': 'wrong_merchant_id', + 'pf_payment_id': '12345', + 'payment_status': 'COMPLETE', + 'item_name': 'Test Product', + 'amount_gross': '100.00', + 'amount_fee': '5.00', + 'amount_net': '95.00', + } + + from fastapi_payfast.utils import generate_signature + form_data['signature'] = generate_signature(form_data, config.passphrase) + + request = Mock(spec=Request) + request.form = AsyncMock(return_value=form_data) + + with pytest.raises(InvalidMerchantError): + await client.verify_itn(request) + + def test_validate_payment_amount_exact_match(self, client): + """Test payment amount validation with exact match""" + itn_data = PayFastITNData( + pf_payment_id="12345", + payment_status=PaymentStatus.COMPLETE, + item_name="Test", + amount_gross=100.00, + amount_fee=5.00, + amount_net=95.00, + merchant_id="10000100", + signature="abc123" + ) + + assert client.validate_payment_amount(itn_data, 100.00) + + def test_validate_payment_amount_within_tolerance(self, client): + """Test payment amount validation within tolerance""" + itn_data = PayFastITNData( + pf_payment_id="12345", + payment_status=PaymentStatus.COMPLETE, + item_name="Test", + amount_gross=100.00, + amount_fee=5.00, + amount_net=95.00, + merchant_id="10000100", + signature="abc123" + ) + + assert client.validate_payment_amount(itn_data, 100.005) + + def test_validate_payment_amount_outside_tolerance(self, client): + """Test payment amount validation outside tolerance""" + itn_data = PayFastITNData( + pf_payment_id="12345", + payment_status=PaymentStatus.COMPLETE, + item_name="Test", + amount_gross=100.00, + amount_fee=5.00, + amount_net=95.00, + merchant_id="10000100", + signature="abc123" + ) + + assert not client.validate_payment_amount(itn_data, 105.00) + + def test_validate_payment_amount_custom_tolerance(self, client): + """Test payment amount validation with custom tolerance""" + itn_data = PayFastITNData( + pf_payment_id="12345", + payment_status=PaymentStatus.COMPLETE, + item_name="Test", + amount_gross=100.00, + amount_fee=5.00, + amount_net=95.00, + merchant_id="10000100", + signature="abc123" + ) + + assert client.validate_payment_amount(itn_data, 105.00, tolerance=5.0) + + def test_is_payment_successful_complete(self, client): + """Test is_payment_successful with COMPLETE status""" + itn_data = PayFastITNData( + pf_payment_id="12345", + payment_status=PaymentStatus.COMPLETE, + item_name="Test", + amount_gross=100.00, + amount_fee=5.00, + amount_net=95.00, + merchant_id="10000100", + signature="abc123" + ) + + assert client.is_payment_successful(itn_data) + + def test_is_payment_successful_failed(self, client): + """Test is_payment_successful with FAILED status""" + itn_data = PayFastITNData( + pf_payment_id="12345", + payment_status=PaymentStatus.FAILED, + item_name="Test", + amount_gross=100.00, + amount_fee=5.00, + amount_net=95.00, + merchant_id="10000100", + signature="abc123" + ) + + assert not client.is_payment_successful(itn_data) + + def test_is_payment_successful_pending(self, client): + """Test is_payment_successful with PENDING status""" + itn_data = PayFastITNData( + pf_payment_id="12345", + payment_status=PaymentStatus.PENDING, + item_name="Test", + amount_gross=100.00, + amount_fee=5.00, + amount_net=95.00, + merchant_id="10000100", + signature="abc123" + ) + + assert not client.is_payment_successful(itn_data) + + def test_is_payment_successful_cancelled(self, client): + """Test is_payment_successful with CANCELLED status""" + itn_data = PayFastITNData( + pf_payment_id="12345", + payment_status=PaymentStatus.CANCELLED, + item_name="Test", + amount_gross=100.00, + amount_fee=5.00, + amount_net=95.00, + merchant_id="10000100", + signature="abc123" + ) + + assert not client.is_payment_successful(itn_data) \ No newline at end of file diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..e8d5d17 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,227 @@ +"""Tests for PayFast utility functions""" + +import pytest +from fastapi_payfast.utils import generate_signature, generate_payment_form_html + + +class TestGenerateSignature: + """Test suite for generate_signature function""" + + def test_generate_signature_basic(self): + """Test basic signature generation""" + data = { + 'merchant_id': '10000100', + 'merchant_key': '46f0cd694581a', + 'amount': '100.00', + 'item_name': 'Test Product' + } + + signature = generate_signature(data) + assert isinstance(signature, str) + assert len(signature) == 32 # MD5 hash length + + def test_generate_signature_with_passphrase(self): + """Test signature generation with passphrase""" + data = { + 'merchant_id': '10000100', + 'merchant_key': '46f0cd694581a', + 'amount': '100.00', + 'item_name': 'Test Product' + } + + signature_without = generate_signature(data) + signature_with = generate_signature(data, 'test_passphrase') + + assert signature_without != signature_with + + def test_generate_signature_deterministic(self): + """Test that signature generation is deterministic""" + data = { + 'merchant_id': '10000100', + 'merchant_key': '46f0cd694581a', + 'amount': '100.00', + 'item_name': 'Test Product' + } + + sig1 = generate_signature(data) + sig2 = generate_signature(data) + + assert sig1 == sig2 + + def test_generate_signature_order_independent(self): + """Test that signature is independent of dict order""" + data1 = { + 'merchant_id': '10000100', + 'amount': '100.00', + 'merchant_key': '46f0cd694581a', + 'item_name': 'Test Product' + } + + data2 = { + 'item_name': 'Test Product', + 'merchant_key': '46f0cd694581a', + 'amount': '100.00', + 'merchant_id': '10000100' + } + + assert generate_signature(data1) == generate_signature(data2) + + def test_generate_signature_ignores_none_values(self): + """Test that None values are ignored""" + data = { + 'merchant_id': '10000100', + 'merchant_key': '46f0cd694581a', + 'amount': '100.00', + 'item_name': 'Test Product', + 'item_description': None + } + + data_without_none = { + 'merchant_id': '10000100', + 'merchant_key': '46f0cd694581a', + 'amount': '100.00', + 'item_name': 'Test Product' + } + + assert generate_signature(data) == generate_signature(data_without_none) + + def test_generate_signature_ignores_empty_strings(self): + """Test that empty strings are ignored""" + data = { + 'merchant_id': '10000100', + 'merchant_key': '46f0cd694581a', + 'amount': '100.00', + 'item_name': 'Test Product', + 'item_description': '' + } + + data_without_empty = { + 'merchant_id': '10000100', + 'merchant_key': '46f0cd694581a', + 'amount': '100.00', + 'item_name': 'Test Product' + } + + assert generate_signature(data) == generate_signature(data_without_empty) + + def test_generate_signature_ignores_signature_field(self): + """Test that signature field is ignored""" + data = { + 'merchant_id': '10000100', + 'merchant_key': '46f0cd694581a', + 'amount': '100.00', + 'item_name': 'Test Product', + 'signature': 'old_signature' + } + + data_without_sig = { + 'merchant_id': '10000100', + 'merchant_key': '46f0cd694581a', + 'amount': '100.00', + 'item_name': 'Test Product' + } + + assert generate_signature(data) == generate_signature(data_without_sig) + + def test_generate_signature_special_characters(self): + """Test signature generation with special characters""" + data = { + 'merchant_id': '10000100', + 'merchant_key': '46f0cd694581a', + 'amount': '100.00', + 'item_name': 'Test Product & Special Chars!', + 'item_description': 'With spaces and @#$%' + } + + signature = generate_signature(data) + assert isinstance(signature, str) + assert len(signature) == 32 + + +class TestGeneratePaymentFormHTML: + """Test suite for generate_payment_form_html function""" + + def test_generate_form_basic(self): + """Test basic form generation""" + action_url = "https://sandbox.payfast.co.za/eng/process" + data = { + 'merchant_id': '10000100', + 'merchant_key': '46f0cd694581a', + 'amount': '100.00', + 'item_name': 'Test Product' + } + + html = generate_payment_form_html(action_url, data) + + assert isinstance(html, str) + assert '' in html + assert action_url in html + assert 'merchant_id' in html + assert '10000100' in html + + def test_generate_form_all_fields(self): + """Test form generation with all fields""" + action_url = "https://sandbox.payfast.co.za/eng/process" + data = { + 'merchant_id': '10000100', + 'merchant_key': '46f0cd694581a', + 'amount': '100.00', + 'item_name': 'Test Product', + 'item_description': 'Test Description', + 'return_url': 'https://example.com/success', + 'signature': 'abc123' + } + + html = generate_payment_form_html(action_url, data) + + for key, value in data.items(): + assert key in html + assert str(value) in html + + def test_generate_form_auto_submit_script(self): + """Test that form includes auto-submit script""" + action_url = "https://sandbox.payfast.co.za/eng/process" + data = {'merchant_id': '10000100'} + + html = generate_payment_form_html(action_url, data) + + assert 'submit()' in html + assert '