mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-25 06:14:29 +00:00
363e0719ab
- Updated version in pyproject.toml to 2026.2.3. - Refactored wallet utilities: - Moved `clean_decode` and `process_transaction` to `transaction.py`. - Created `balance.py` for balance-related functions. - Created `info.py` for wallet information retrieval. - Created `transfer.py` for sending TON and USDT transfers. - Updated tests to reflect new module structure and imports. - Added new API utility functions for handling Fragment API requests.
179 lines
6.7 KiB
Python
179 lines
6.7 KiB
Python
"""Unit tests for process_transaction() — balance validation and broadcast retry logic."""
|
|
|
|
from collections.abc import Generator
|
|
from contextlib import contextmanager
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from tonutils.exceptions import ProviderResponseError
|
|
|
|
from pyfragment.types import TransactionError, WalletError
|
|
from pyfragment.utils.wallet import process_transaction
|
|
from tests.shared import VALID_SEED
|
|
|
|
|
|
def _provider_error(code: int, message: str = "error") -> ProviderResponseError:
|
|
return ProviderResponseError(code=code, message=message, endpoint="api.tonapi.io")
|
|
|
|
|
|
TRANSACTION_DATA = {
|
|
"transaction": {
|
|
"messages": [
|
|
{
|
|
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
|
|
"amount": "500000000", # 0.5 TON
|
|
"payload": "",
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
|
|
def _make_client() -> MagicMock:
|
|
client = MagicMock()
|
|
client.api_key = "test_key"
|
|
client.seed = VALID_SEED.split()
|
|
client.wallet_version = "V5R1"
|
|
return client
|
|
|
|
|
|
def _make_wallet(balance_nanotons: int) -> MagicMock:
|
|
wallet = MagicMock()
|
|
wallet.refresh = AsyncMock()
|
|
wallet.balance = balance_nanotons
|
|
wallet.transfer = AsyncMock(return_value=MagicMock(normalized_hash="abc123"))
|
|
return wallet
|
|
|
|
|
|
@contextmanager
|
|
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
|
|
with (
|
|
patch("pyfragment.utils.wallet.transaction.TonapiClient") as mock_tonapi,
|
|
patch("pyfragment.utils.wallet.transaction.WALLET_CLASSES") as mock_classes,
|
|
):
|
|
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
|
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None)
|
|
yield
|
|
|
|
|
|
# Balance threshold tests
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sufficient_balance_broadcasts() -> None:
|
|
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold
|
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value="50 Telegram Stars"):
|
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
|
assert result == "abc123"
|
|
wallet.transfer.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_insufficient_balance_raises() -> None:
|
|
wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, below threshold
|
|
with _patch_wallet(wallet):
|
|
with pytest.raises(WalletError, match="required"):
|
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
|
wallet.transfer.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exact_minimum_balance_broadcasts() -> None:
|
|
wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold
|
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value="50 Telegram Stars"):
|
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
|
assert result == "abc123"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_one_nanoton_below_minimum_raises() -> None:
|
|
wallet = _make_wallet(balance_nanotons=499_999_999) # 1 nanoton below transaction amount threshold
|
|
with _patch_wallet(wallet):
|
|
with pytest.raises(WalletError, match="required"):
|
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
|
|
|
|
|
# Error handling tests
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_payload_raises() -> None:
|
|
with pytest.raises(TransactionError):
|
|
await process_transaction(_make_client(), {"transaction": {}})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_messages_list_raises() -> None:
|
|
with pytest.raises(TransactionError):
|
|
await process_transaction(_make_client(), {"transaction": {"messages": []}})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_balance_check_failed_raises_wallet_error() -> None:
|
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
|
wallet.refresh = AsyncMock(side_effect=RuntimeError("network timeout"))
|
|
with _patch_wallet(wallet):
|
|
with pytest.raises(WalletError, match="balance"):
|
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
|
wallet.transfer.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limit_retries_and_succeeds() -> None:
|
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
|
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
|
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value=""):
|
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
|
assert result == "abc123"
|
|
assert wallet.transfer.call_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_duplicate_seqno_raises_after_retries() -> None:
|
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
|
err = _provider_error(406, "Duplicate msg_seqno")
|
|
wallet.transfer = AsyncMock(side_effect=[err, err, err])
|
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.transaction.clean_decode", return_value=""):
|
|
with pytest.raises(TransactionError, match="seqno"):
|
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
|
assert wallet.transfer.call_count == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_usdt_payment_requires_min_ton_gas_reserve() -> None:
|
|
wallet = _make_wallet(balance_nanotons=10_000_000) # 0.01 TON below MIN_TON_BALANCE
|
|
with _patch_wallet(wallet), patch("pyfragment.utils.wallet.balance.get_usdt_balance", AsyncMock(return_value=100.0)):
|
|
with pytest.raises(WalletError, match="Insufficient TON balance"):
|
|
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_usdt_payment_checks_usdt_balance() -> None:
|
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
|
transaction = {
|
|
"transaction": {
|
|
"messages": [
|
|
{
|
|
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
|
|
"amount": "50000000",
|
|
"payload": "",
|
|
}
|
|
]
|
|
},
|
|
"required_usdt": 12.5,
|
|
}
|
|
|
|
with (
|
|
_patch_wallet(wallet),
|
|
patch("pyfragment.utils.wallet.transaction.clean_decode", return_value=""),
|
|
patch("pyfragment.utils.wallet.balance.get_usdt_balance", AsyncMock(return_value=5.0)),
|
|
):
|
|
with pytest.raises(WalletError, match="Insufficient USDT balance"):
|
|
await process_transaction(
|
|
_make_client(),
|
|
transaction,
|
|
payment_method="usdt_ton",
|
|
required_payment_amount=12.5,
|
|
)
|