549 lines
23 KiB
Python
549 lines
23 KiB
Python
"""Tests for Apple In-App Purchase service and integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
ROOT_DIR = Path(__file__).resolve().parents[2]
|
|
if str(ROOT_DIR) not in sys.path:
|
|
sys.path.insert(0, str(ROOT_DIR))
|
|
|
|
from app.config import settings
|
|
from app.external.apple_iap import AppleIAPService
|
|
|
|
|
|
@pytest.fixture
|
|
def anyio_backend() -> str:
|
|
return 'asyncio'
|
|
|
|
|
|
def _enable_apple_iap(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_ENABLED', True, raising=False)
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_KEY_ID', 'TEST_KEY_ID', raising=False)
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_ISSUER_ID', 'test-issuer-id', raising=False)
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_BUNDLE_ID', 'com.bitnet.vpnclient', raising=False)
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Sandbox', raising=False)
|
|
# Use a dummy key -- we won't actually sign in tests
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY', 'dummy-key', raising=False)
|
|
monkeypatch.setattr(
|
|
settings,
|
|
'APPLE_IAP_PRODUCTS',
|
|
json.dumps({
|
|
'com.bitnet.vpnclient.topup.100': 10_000,
|
|
'com.bitnet.vpnclient.topup.300': 30_000,
|
|
'com.bitnet.vpnclient.topup.500': 50_000,
|
|
'com.bitnet.vpnclient.topup.1000': 100_000,
|
|
'com.bitnet.vpnclient.topup.3000': 300_000,
|
|
}),
|
|
raising=False,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Product mapping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestProductMapping:
|
|
"""Test product ID to kopeks mapping."""
|
|
|
|
def test_all_products_mapped(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
products = settings.get_apple_iap_products()
|
|
assert len(products) == 5
|
|
|
|
def test_product_100(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
products = settings.get_apple_iap_products()
|
|
assert products['com.bitnet.vpnclient.topup.100'] == 10_000
|
|
|
|
def test_product_300(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
products = settings.get_apple_iap_products()
|
|
assert products['com.bitnet.vpnclient.topup.300'] == 30_000
|
|
|
|
def test_product_500(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
products = settings.get_apple_iap_products()
|
|
assert products['com.bitnet.vpnclient.topup.500'] == 50_000
|
|
|
|
def test_product_1000(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
products = settings.get_apple_iap_products()
|
|
assert products['com.bitnet.vpnclient.topup.1000'] == 100_000
|
|
|
|
def test_product_3000(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
products = settings.get_apple_iap_products()
|
|
assert products['com.bitnet.vpnclient.topup.3000'] == 300_000
|
|
|
|
def test_unknown_product_not_in_map(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
products = settings.get_apple_iap_products()
|
|
assert 'com.bitnet.vpnclient.topup.999' not in products
|
|
|
|
def test_invalid_json_returns_empty(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_PRODUCTS', 'invalid-json', raising=False)
|
|
products = settings.get_apple_iap_products()
|
|
assert products == {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_apple_iap_enabled()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAppleIAPEnabled:
|
|
"""Test is_apple_iap_enabled() helper."""
|
|
|
|
def test_enabled_with_all_params(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
assert settings.is_apple_iap_enabled() is True
|
|
|
|
def test_disabled_when_flag_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_ENABLED', False, raising=False)
|
|
assert settings.is_apple_iap_enabled() is False
|
|
|
|
def test_disabled_when_key_id_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_KEY_ID', None, raising=False)
|
|
assert settings.is_apple_iap_enabled() is False
|
|
|
|
def test_disabled_when_issuer_id_missing(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_ISSUER_ID', None, raising=False)
|
|
assert settings.is_apple_iap_enabled() is False
|
|
|
|
def test_disabled_when_no_private_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY', None, raising=False)
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY_PATH', None, raising=False)
|
|
assert settings.is_apple_iap_enabled() is False
|
|
|
|
def test_enabled_with_key_path_only(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY', None, raising=False)
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_PRIVATE_KEY_PATH', '/tmp/test.p8', raising=False)
|
|
assert settings.is_apple_iap_enabled() is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_transaction_info
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestTransactionValidation:
|
|
"""Test validate_transaction_info."""
|
|
|
|
def test_valid_transaction(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
txn_info = {
|
|
'bundleId': 'com.bitnet.vpnclient',
|
|
'productId': 'com.bitnet.vpnclient.topup.100',
|
|
'type': 'Consumable',
|
|
}
|
|
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
|
|
assert result is None # None means valid
|
|
|
|
def test_wrong_bundle_id(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
txn_info = {
|
|
'bundleId': 'com.other.app',
|
|
'productId': 'com.bitnet.vpnclient.topup.100',
|
|
'type': 'Consumable',
|
|
}
|
|
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
|
|
assert result is not None
|
|
assert 'Bundle ID' in result
|
|
|
|
def test_wrong_product_id(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
txn_info = {
|
|
'bundleId': 'com.bitnet.vpnclient',
|
|
'productId': 'com.bitnet.vpnclient.topup.500',
|
|
'type': 'Consumable',
|
|
}
|
|
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
|
|
assert result is not None
|
|
assert 'Product ID' in result
|
|
|
|
def test_wrong_type(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
txn_info = {
|
|
'bundleId': 'com.bitnet.vpnclient',
|
|
'productId': 'com.bitnet.vpnclient.topup.100',
|
|
'type': 'Auto-Renewable Subscription',
|
|
}
|
|
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
|
|
assert result is not None
|
|
assert 'type' in result
|
|
|
|
def test_revoked_transaction(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
txn_info = {
|
|
'bundleId': 'com.bitnet.vpnclient',
|
|
'productId': 'com.bitnet.vpnclient.topup.100',
|
|
'type': 'Consumable',
|
|
'revocationDate': 1700000000000,
|
|
}
|
|
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
|
|
assert result is not None
|
|
assert 'revoked' in result.lower()
|
|
|
|
def test_valid_without_revocation(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A transaction without revocationDate should be valid."""
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
txn_info = {
|
|
'bundleId': 'com.bitnet.vpnclient',
|
|
'productId': 'com.bitnet.vpnclient.topup.100',
|
|
'type': 'Consumable',
|
|
}
|
|
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Environment URL selection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBaseUrl:
|
|
"""Test environment URL selection."""
|
|
|
|
def test_production_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
url = service._get_base_url('Production')
|
|
assert 'api.storekit.itunes.apple.com' in url
|
|
|
|
def test_sandbox_url(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
url = service._get_base_url('Sandbox')
|
|
assert 'api.storekit-sandbox.itunes.apple.com' in url
|
|
|
|
def test_default_uses_config(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
url = service._get_base_url()
|
|
assert 'sandbox' in url # Fixture sets Sandbox
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _decode_jws_payload (raw decode, no verification)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestJWSPayloadDecoding:
|
|
"""Test _decode_jws_payload."""
|
|
|
|
def test_decode_valid_jws(self) -> None:
|
|
service = AppleIAPService()
|
|
header = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b'=').decode()
|
|
payload_data = {'bundleId': 'com.test', 'productId': 'test.product'}
|
|
payload = base64.urlsafe_b64encode(json.dumps(payload_data).encode()).rstrip(b'=').decode()
|
|
signature = base64.urlsafe_b64encode(b'fake-signature').rstrip(b'=').decode()
|
|
jws = f'{header}.{payload}.{signature}'
|
|
|
|
result = service._decode_jws_payload(jws)
|
|
assert result is not None
|
|
assert result['bundleId'] == 'com.test'
|
|
assert result['productId'] == 'test.product'
|
|
|
|
def test_decode_invalid_jws(self) -> None:
|
|
service = AppleIAPService()
|
|
result = service._decode_jws_payload('not-a-jws')
|
|
assert result is None
|
|
|
|
def test_decode_empty_string(self) -> None:
|
|
service = AppleIAPService()
|
|
result = service._decode_jws_payload('')
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _verify_and_decode_jws (x5c + ES256 verification)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestVerifyAndDecodeJWS:
|
|
"""Test _verify_and_decode_jws -- the full x5c chain + signature path."""
|
|
|
|
def test_rejects_bad_format(self) -> None:
|
|
service = AppleIAPService()
|
|
assert service._verify_and_decode_jws('only-two.parts') is None
|
|
|
|
def test_rejects_missing_x5c(self) -> None:
|
|
service = AppleIAPService()
|
|
# Valid 3-part JWS but header has no x5c
|
|
header = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b'=').decode()
|
|
payload = base64.urlsafe_b64encode(b'{}').rstrip(b'=').decode()
|
|
sig = base64.urlsafe_b64encode(b'sig').rstrip(b'=').decode()
|
|
assert service._verify_and_decode_jws(f'{header}.{payload}.{sig}') is None
|
|
|
|
def test_rejects_empty_x5c(self) -> None:
|
|
service = AppleIAPService()
|
|
header = (
|
|
base64.urlsafe_b64encode(json.dumps({'alg': 'ES256', 'x5c': []}).encode()).rstrip(b'=').decode()
|
|
)
|
|
payload = base64.urlsafe_b64encode(b'{}').rstrip(b'=').decode()
|
|
sig = base64.urlsafe_b64encode(b'sig').rstrip(b'=').decode()
|
|
assert service._verify_and_decode_jws(f'{header}.{payload}.{sig}') is None
|
|
|
|
def test_verify_notification_delegates(self) -> None:
|
|
"""verify_notification should delegate to _verify_and_decode_jws."""
|
|
service = AppleIAPService()
|
|
service._verify_and_decode_jws = MagicMock(return_value={'test': True})
|
|
result = service.verify_notification('signed.payload.jws')
|
|
service._verify_and_decode_jws.assert_called_once_with('signed.payload.jws')
|
|
assert result == {'test': True}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# verify_transaction with mocked HTTP
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.anyio('asyncio')
|
|
class TestVerifyTransaction:
|
|
"""Test verify_transaction with mocked _fetch_transaction."""
|
|
|
|
@staticmethod
|
|
def _ok_response(signed_info: str = 'header.payload.sig') -> MagicMock:
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.json.return_value = {'signedTransactionInfo': signed_info}
|
|
return resp
|
|
|
|
@staticmethod
|
|
def _error_response(status: int, text: str = '') -> MagicMock:
|
|
resp = MagicMock()
|
|
resp.status_code = status
|
|
resp.text = text
|
|
return resp
|
|
|
|
async def test_successful_verification(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
|
|
txn_data = {
|
|
'bundleId': 'com.bitnet.vpnclient',
|
|
'productId': 'com.bitnet.vpnclient.topup.100',
|
|
'type': 'Consumable',
|
|
'transactionId': '2000000123456789',
|
|
'environment': 'Sandbox',
|
|
}
|
|
|
|
monkeypatch.setattr(service, '_fetch_transaction', AsyncMock(return_value=self._ok_response()))
|
|
monkeypatch.setattr(service, '_verify_and_decode_jws', lambda token: txn_data)
|
|
|
|
result = await service.verify_transaction('2000000123456789', 'Sandbox')
|
|
|
|
assert result is not None
|
|
assert result['bundleId'] == 'com.bitnet.vpnclient'
|
|
assert result['transactionId'] == '2000000123456789'
|
|
|
|
async def test_verification_with_jws_failure(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""If _verify_and_decode_jws returns None, verify_transaction returns None."""
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
|
|
monkeypatch.setattr(service, '_fetch_transaction', AsyncMock(return_value=self._ok_response()))
|
|
monkeypatch.setattr(service, '_verify_and_decode_jws', lambda token: None)
|
|
|
|
result = await service.verify_transaction('2000000123456789', 'Sandbox')
|
|
assert result is None
|
|
|
|
async def test_transaction_not_found_both_envs(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""404 on primary triggers fallback; 404 on fallback returns None."""
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
|
|
fetch_mock = AsyncMock(return_value=self._error_response(404))
|
|
monkeypatch.setattr(service, '_fetch_transaction', fetch_mock)
|
|
|
|
result = await service.verify_transaction('nonexistent', 'Sandbox')
|
|
assert result is None
|
|
# Should have been called twice (primary + fallback)
|
|
assert fetch_mock.call_count == 2
|
|
|
|
async def test_fallback_succeeds_on_404(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""404 on primary, 200 on fallback -- should succeed."""
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
|
|
txn_data = {'bundleId': 'com.bitnet.vpnclient', 'type': 'Consumable'}
|
|
responses = [self._error_response(404), self._ok_response()]
|
|
fetch_mock = AsyncMock(side_effect=responses)
|
|
monkeypatch.setattr(service, '_fetch_transaction', fetch_mock)
|
|
monkeypatch.setattr(service, '_verify_and_decode_jws', lambda token: txn_data)
|
|
|
|
result = await service.verify_transaction('12345', 'Production')
|
|
assert result is not None
|
|
assert fetch_mock.call_count == 2
|
|
|
|
async def test_network_error_no_retry(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Network error (None response) should not retry."""
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
|
|
fetch_mock = AsyncMock(return_value=None)
|
|
monkeypatch.setattr(service, '_fetch_transaction', fetch_mock)
|
|
|
|
result = await service.verify_transaction('12345', 'Sandbox')
|
|
assert result is None
|
|
assert fetch_mock.call_count == 1 # no fallback on network error
|
|
|
|
async def test_5xx_no_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""5xx errors should not trigger fallback (only 4xx does)."""
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
|
|
fetch_mock = AsyncMock(return_value=self._error_response(500, 'Internal'))
|
|
monkeypatch.setattr(service, '_fetch_transaction', fetch_mock)
|
|
|
|
result = await service.verify_transaction('12345', 'Sandbox')
|
|
assert result is None
|
|
assert fetch_mock.call_count == 1
|
|
|
|
async def test_rate_limit_triggers_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""429 is 4xx -> triggers fallback."""
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
|
|
fetch_mock = AsyncMock(return_value=self._error_response(429))
|
|
monkeypatch.setattr(service, '_fetch_transaction', fetch_mock)
|
|
|
|
result = await service.verify_transaction('123', 'Sandbox')
|
|
assert result is None
|
|
assert fetch_mock.call_count == 2 # primary + fallback
|
|
|
|
async def test_no_signed_transaction_info(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.json.return_value = {} # missing signedTransactionInfo
|
|
monkeypatch.setattr(service, '_fetch_transaction', AsyncMock(return_value=resp))
|
|
|
|
result = await service.verify_transaction('123', 'Sandbox')
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schema validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestApplePurchaseRequestSchema:
|
|
"""Test ApplePurchaseRequest pydantic validation."""
|
|
|
|
def test_valid_request(self) -> None:
|
|
from app.cabinet.schemas.apple_iap import ApplePurchaseRequest
|
|
|
|
req = ApplePurchaseRequest(
|
|
product_id='com.bitnet.vpnclient.topup.100',
|
|
transaction_id='2000000123456789',
|
|
)
|
|
assert req.transaction_id == '2000000123456789'
|
|
|
|
def test_rejects_non_numeric_transaction_id(self) -> None:
|
|
from app.cabinet.schemas.apple_iap import ApplePurchaseRequest
|
|
|
|
with pytest.raises(Exception, match='digits'):
|
|
ApplePurchaseRequest(
|
|
product_id='com.bitnet.vpnclient.topup.100',
|
|
transaction_id='abc-not-numeric',
|
|
)
|
|
|
|
def test_rejects_empty_transaction_id(self) -> None:
|
|
from app.cabinet.schemas.apple_iap import ApplePurchaseRequest
|
|
|
|
with pytest.raises(Exception):
|
|
ApplePurchaseRequest(
|
|
product_id='com.bitnet.vpnclient.topup.100',
|
|
transaction_id='',
|
|
)
|
|
|
|
def test_rejects_too_long_transaction_id(self) -> None:
|
|
from app.cabinet.schemas.apple_iap import ApplePurchaseRequest
|
|
|
|
with pytest.raises(Exception):
|
|
ApplePurchaseRequest(
|
|
product_id='com.bitnet.vpnclient.topup.100',
|
|
transaction_id='1' * 65,
|
|
)
|
|
|
|
def test_no_environment_field(self) -> None:
|
|
"""Schema should not accept environment -- it's server-side only."""
|
|
from app.cabinet.schemas.apple_iap import ApplePurchaseRequest
|
|
|
|
req = ApplePurchaseRequest(
|
|
product_id='com.bitnet.vpnclient.topup.100',
|
|
transaction_id='123',
|
|
)
|
|
assert not hasattr(req, 'environment')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sandbox detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSandboxDetection:
|
|
"""Test that sandbox transactions don't credit real balance."""
|
|
|
|
def test_sandbox_env_detected_from_txn_info(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""validate_transaction_info does not reject sandbox env -- that's handled at the route level."""
|
|
_enable_apple_iap(monkeypatch)
|
|
service = AppleIAPService()
|
|
txn_info = {
|
|
'bundleId': 'com.bitnet.vpnclient',
|
|
'productId': 'com.bitnet.vpnclient.topup.100',
|
|
'type': 'Consumable',
|
|
'environment': 'Sandbox',
|
|
}
|
|
result = service.validate_transaction_info(txn_info, 'com.bitnet.vpnclient.topup.100')
|
|
assert result is None # validation passes -- sandbox check is higher up
|
|
|
|
def test_production_txn_on_production_passes(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Production environment in txn_info + Production config = proceed normally."""
|
|
_enable_apple_iap(monkeypatch)
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Production', raising=False)
|
|
txn_info = {'environment': 'Production'}
|
|
is_sandbox = txn_info.get('environment') == 'Sandbox'
|
|
assert is_sandbox is False
|
|
|
|
def test_sandbox_txn_on_production_detected(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Sandbox environment in txn_info + Production config = sandbox detected."""
|
|
_enable_apple_iap(monkeypatch)
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Production', raising=False)
|
|
txn_info = {'environment': 'Sandbox'}
|
|
is_sandbox = txn_info.get('environment') == 'Sandbox'
|
|
should_skip_balance = is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production'
|
|
assert should_skip_balance is True
|
|
|
|
def test_sandbox_txn_on_sandbox_credits_normally(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Sandbox environment in txn_info + Sandbox config = credit normally (testing)."""
|
|
_enable_apple_iap(monkeypatch)
|
|
monkeypatch.setattr(settings, 'APPLE_IAP_ENVIRONMENT', 'Sandbox', raising=False)
|
|
txn_info = {'environment': 'Sandbox'}
|
|
is_sandbox = txn_info.get('environment') == 'Sandbox'
|
|
should_skip_balance = is_sandbox and settings.APPLE_IAP_ENVIRONMENT == 'Production'
|
|
assert should_skip_balance is False
|