mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-25 06:14:29 +00:00
Refactor wallet transaction handling and introduce tonapi module
- Removed wallet transaction and transfer logic from the wallet domain. - Introduced a new tonapi module to handle transactions and balance checks. - Updated tests to reflect the new structure and ensure functionality remains intact. - Added functionality for sending TON and USDT transfers through the tonapi module. - Improved error handling and validation for payment balances. - Cleaned up and organized imports across the codebase.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""Tests for clean_decode() — TON BOC payload decoding."""
|
||||
"""Decode Fragment BOC payloads so comments become text and structured messages stay raw."""
|
||||
|
||||
import base64
|
||||
import re
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
from ton_core import Cell
|
||||
|
||||
from pyfragment import ParseError
|
||||
from pyfragment.domains.wallet.transaction import clean_decode
|
||||
from pyfragment.domains.tonapi.transaction import clean_decode
|
||||
|
||||
PAYLOAD_CASES = [
|
||||
pytest.param(
|
||||
@@ -86,7 +86,7 @@ def test_decode_payload_accepts_base64url_alphabet() -> None:
|
||||
raw = b"\xfb\xef\xff\x00"
|
||||
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
|
||||
with patch("pyfragment.domains.wallet.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked:
|
||||
with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked:
|
||||
result = clean_decode(payload)
|
||||
|
||||
mocked.assert_called_once_with(raw)
|
||||
@@ -106,7 +106,7 @@ def test_clean_decode_returns_text_comment_when_utf8() -> None:
|
||||
return _FakeSlice()
|
||||
|
||||
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
|
||||
with patch("pyfragment.domains.wallet.transaction.Cell.one_from_boc", return_value=_FakeCell()):
|
||||
with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=_FakeCell()):
|
||||
parsed = clean_decode(payload)
|
||||
|
||||
assert parsed == "Telegram Premium Ref#abc"
|
||||
@@ -126,7 +126,7 @@ def test_clean_decode_returns_cell_for_binary_payload() -> None:
|
||||
|
||||
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
|
||||
fake_cell: object = _FakeCell()
|
||||
with patch("pyfragment.domains.wallet.transaction.Cell.one_from_boc", return_value=fake_cell):
|
||||
with patch("pyfragment.domains.tonapi.transaction.Cell.one_from_boc", return_value=fake_cell):
|
||||
parsed = clean_decode(payload)
|
||||
|
||||
assert parsed is fake_cell
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for FragmentClient — initialization, validation, and cookie parsing."""
|
||||
"""Validate FragmentClient setup, cookie parsing, and wallet version checks."""
|
||||
|
||||
import json
|
||||
|
||||
|
||||
+11
-11
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for process_transaction() — balance validation and broadcast retry logic."""
|
||||
"""Exercise transaction signing, balance checks, and retry behavior for TON and USDT flows."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
from tonutils.exceptions import ProviderResponseError
|
||||
|
||||
from pyfragment import TransactionError, WalletError
|
||||
from pyfragment.domains.wallet.transaction import process_transaction
|
||||
from pyfragment.domains.tonapi.transaction import process_transaction
|
||||
from tests.shared import VALID_SEED
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
|
||||
@contextmanager
|
||||
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
|
||||
with (
|
||||
patch("pyfragment.domains.wallet.transaction.TonapiClient") as mock_tonapi,
|
||||
patch("pyfragment.domains.wallet.transaction.WALLET_CLASSES") as mock_classes,
|
||||
patch("pyfragment.domains.tonapi.transaction.TonapiClient") as mock_tonapi,
|
||||
patch("pyfragment.domains.tonapi.transaction.WALLET_CLASSES") as mock_classes,
|
||||
):
|
||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
@@ -63,7 +63,7 @@ def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
|
||||
@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.domains.wallet.transaction.clean_decode", return_value="50 Telegram Stars"):
|
||||
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
|
||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
assert result == "abc123"
|
||||
wallet.transfer.assert_called_once()
|
||||
@@ -81,7 +81,7 @@ async def test_insufficient_balance_raises() -> None:
|
||||
@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.domains.wallet.transaction.clean_decode", return_value="50 Telegram Stars"):
|
||||
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value="50 Telegram Stars"):
|
||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
assert result == "abc123"
|
||||
|
||||
@@ -123,7 +123,7 @@ async def test_balance_check_failed_raises_wallet_error() -> None:
|
||||
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.domains.wallet.transaction.clean_decode", return_value=""):
|
||||
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""):
|
||||
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
assert result == "abc123"
|
||||
assert wallet.transfer.call_count == 2
|
||||
@@ -134,7 +134,7 @@ 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.domains.wallet.transaction.clean_decode", return_value=""):
|
||||
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""):
|
||||
with pytest.raises(TransactionError, match="seqno"):
|
||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||
assert wallet.transfer.call_count == 3
|
||||
@@ -143,7 +143,7 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
|
||||
@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.domains.wallet.balance.get_usdt_balance", AsyncMock(return_value=100.0)):
|
||||
with _patch_wallet(wallet), patch("pyfragment.domains.tonapi.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")
|
||||
|
||||
@@ -166,8 +166,8 @@ async def test_usdt_payment_checks_usdt_balance() -> None:
|
||||
|
||||
with (
|
||||
_patch_wallet(wallet),
|
||||
patch("pyfragment.domains.wallet.transaction.clean_decode", return_value=""),
|
||||
patch("pyfragment.domains.wallet.balance.get_usdt_balance", AsyncMock(return_value=5.0)),
|
||||
patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""),
|
||||
patch("pyfragment.domains.tonapi.balance.get_usdt_balance", AsyncMock(return_value=5.0)),
|
||||
):
|
||||
with pytest.raises(WalletError, match="Insufficient USDT balance"):
|
||||
await process_transaction(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for Stars methods — purchase_stars and giveaway_stars."""
|
||||
"""Cover stars purchase and giveaway flows, including validation and request wiring."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for Premium methods — purchase_premium and giveaway_premium."""
|
||||
"""Cover premium purchase and giveaway flows, including validation and request wiring."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Unit tests for topup_ton — TON Ads balance top-up."""
|
||||
"""Cover TON top-up through Telegram Ads, including recipient lookup and transaction building."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
_topup_ton_mod = importlib.import_module("pyfragment.domains.wallet.topup")
|
||||
_topup_ton_mod = importlib.import_module("pyfragment.domains.ads.tonup")
|
||||
from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError
|
||||
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for get_wallet() — wallet address/state with separate TON and USDT balances."""
|
||||
"""Verify wallet inspection returns friendly TON and USDT balances from Tonapi."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -19,9 +19,9 @@ async def test_get_wallet_returns_wallet_info(client: FragmentClient) -> None:
|
||||
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||
|
||||
with (
|
||||
patch("pyfragment.domains.wallet.info.TonapiClient") as mock_tonapi,
|
||||
patch("pyfragment.domains.wallet.info.WALLET_CLASSES") as mock_classes,
|
||||
patch("pyfragment.domains.wallet.info.get_usdt_balance", AsyncMock(return_value=12.3456)),
|
||||
patch("pyfragment.domains.tonapi.info.TonapiClient") as mock_tonapi,
|
||||
patch("pyfragment.domains.tonapi.info.WALLET_CLASSES") as mock_classes,
|
||||
patch("pyfragment.domains.tonapi.info.get_usdt_balance", AsyncMock(return_value=12.3456)),
|
||||
):
|
||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
@@ -45,9 +45,9 @@ async def test_get_wallet_balance_is_zero(client: FragmentClient) -> None:
|
||||
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||
|
||||
with (
|
||||
patch("pyfragment.domains.wallet.info.TonapiClient") as mock_tonapi,
|
||||
patch("pyfragment.domains.wallet.info.WALLET_CLASSES") as mock_classes,
|
||||
patch("pyfragment.domains.wallet.info.get_usdt_balance", AsyncMock(return_value=0.0)),
|
||||
patch("pyfragment.domains.tonapi.info.TonapiClient") as mock_tonapi,
|
||||
patch("pyfragment.domains.tonapi.info.WALLET_CLASSES") as mock_classes,
|
||||
patch("pyfragment.domains.tonapi.info.get_usdt_balance", AsyncMock(return_value=0.0)),
|
||||
):
|
||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for FragmentClient.call() — raw Fragment API access."""
|
||||
"""Check raw Fragment API calls and transport error handling."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -25,7 +25,6 @@ async def test_call_returns_api_response(client: FragmentClient) -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_default_page_url(client: FragmentClient) -> None:
|
||||
"""call() works without explicitly passing page_url (defaults to FRAGMENT_BASE_URL)."""
|
||||
with (
|
||||
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
|
||||
@@ -37,7 +36,6 @@ async def test_call_default_page_url(client: FragmentClient) -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_no_data(client: FragmentClient) -> None:
|
||||
"""call() with no extra data passes only the method field."""
|
||||
mock_request = AsyncMock(return_value={})
|
||||
|
||||
with (
|
||||
@@ -52,7 +50,6 @@ async def test_call_no_data(client: FragmentClient) -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_merges_extra_data(client: FragmentClient) -> None:
|
||||
"""call() merges caller-supplied data with the method field."""
|
||||
mock_request = AsyncMock(return_value={})
|
||||
|
||||
with (
|
||||
@@ -70,7 +67,6 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fragment_request_non_200_raises() -> None:
|
||||
"""fragment_request raises FragmentPageError on non-200 HTTP responses."""
|
||||
response = MagicMock(spec=httpx.Response)
|
||||
response.status_code = 429
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for recharge_ads — self-service Telegram Ads recharge."""
|
||||
"""Cover Telegram Ads recharge flow, including request preparation and KYC handling."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for search_usernames — Fragment marketplace username search."""
|
||||
"""Verify username search parsing and query forwarding."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for search_numbers — Fragment marketplace number search."""
|
||||
"""Verify anonymous number search parsing and query forwarding."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for search_gifts — Fragment gifts marketplace search."""
|
||||
"""Verify gift search parsing and pagination handling."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for get_cookies_from_browser() — browser cookie extraction helper."""
|
||||
"""Extract Fragment cookies from browser stores and validate required keys."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for init payment amount parsing."""
|
||||
"""Parse Fragment init responses to the payment amount the transaction should cover."""
|
||||
|
||||
from pyfragment.domains.payments import parse_required_payment_amount
|
||||
|
||||
|
||||
+5
-3
@@ -1,3 +1,5 @@
|
||||
"""Shared pytest fixtures for Fragment client tests."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import cast
|
||||
@@ -5,16 +7,17 @@ from typing import cast
|
||||
import pytest
|
||||
|
||||
import pyfragment.domains.ads.recharge # noqa: F401
|
||||
import pyfragment.domains.ads.tonup # noqa: F401
|
||||
import pyfragment.domains.giveaways.giveaway # noqa: F401
|
||||
import pyfragment.domains.purchases.purchase # noqa: F401
|
||||
import pyfragment.domains.wallet.topup # noqa: F401
|
||||
import pyfragment.domains.tonapi.info # noqa: F401
|
||||
import pyfragment.domains.tonapi.transaction # noqa: F401
|
||||
from pyfragment import FragmentClient
|
||||
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cookies() -> dict[str, str]:
|
||||
"""Load Fragment cookies from COOKIES_JSON env var; skip if unavailable."""
|
||||
raw = os.environ.get("COOKIES_JSON")
|
||||
if not raw:
|
||||
pytest.skip("COOKIES_JSON env var not set")
|
||||
@@ -26,5 +29,4 @@ def cookies() -> dict[str, str]:
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> FragmentClient:
|
||||
"""Pre-built FragmentClient with dummy credentials."""
|
||||
return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||
|
||||
Reference in New Issue
Block a user