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:
bohd4nx
2026-05-20 23:42:24 +03:00
parent ae164d4fe7
commit 01a5befd87
38 changed files with 137 additions and 158 deletions
-5
View File
@@ -1,8 +1,3 @@
# Copyright (c) 2026 bohd4nx
#
# This source code is licensed under the MIT License found in the
# LICENSE file in the root directory of this source tree.
from importlib.metadata import version
from pyfragment.client import FragmentClient
+4 -5
View File
@@ -12,8 +12,7 @@ from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
from pyfragment.domains.giveaways.service import GiveawaysService
from pyfragment.domains.marketplace.service import MarketplaceService
from pyfragment.domains.purchases.service import PurchasesService
from pyfragment.domains.wallet.info import get_wallet_info
from pyfragment.domains.wallet.service import WalletService
from pyfragment.domains.tonapi.service import TonapiService
from pyfragment.exceptions import ConfigurationError, CookieError
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
from pyfragment.models.enums import PaymentMethod, WalletVersion
@@ -99,7 +98,7 @@ class FragmentClient:
self.marketplace = MarketplaceService(self)
self.purchases = PurchasesService(self)
self.giveaways = GiveawaysService(self)
self.wallet = WalletService(self)
self.tonapi = TonapiService(self)
self.anonymous_numbers = AnonymousNumbersService(self)
self.ads = AdsService(self)
@@ -163,7 +162,7 @@ class FragmentClient:
Returns:
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
"""
return await self.wallet.topup_ton(username, amount, show_sender=show_sender)
return await self.ads.topup_ton(username, amount, show_sender=show_sender)
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
"""Add funds to your own Telegram Ads account.
@@ -186,7 +185,7 @@ class FragmentClient:
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``),
``ton_balance`` in TON, and ``usdt_balance`` in USDT.
"""
return await get_wallet_info(self)
return await self.tonapi.get_wallet()
async def giveaway_stars(
self,
-1
View File
@@ -1 +0,0 @@
"""Low-level transport and shared helpers for pyfragment."""
-1
View File
@@ -1 +0,0 @@
"""Domain service package for pyfragment."""
-1
View File
@@ -1 +0,0 @@
"""Ads domain services."""
+2 -2
View File
@@ -4,8 +4,8 @@ import json
from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.domains.wallet.info import get_account_info
from pyfragment.domains.wallet.transaction import process_transaction
from pyfragment.domains.tonapi.info import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError
from pyfragment.models.payments import AdsRechargeResult
+5 -1
View File
@@ -3,8 +3,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.ads.recharge import recharge_ads
from pyfragment.domains.ads.tonup import topup_ton
from pyfragment.domains.base import BaseService
from pyfragment.models.payments import AdsRechargeResult
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult
if TYPE_CHECKING:
pass
@@ -13,3 +14,6 @@ if TYPE_CHECKING:
class AdsService(BaseService):
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
return await recharge_ads(self._client, account, amount)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
return await topup_ton(self._client, username, amount, show_sender=show_sender)
@@ -4,8 +4,9 @@ import json
from typing import TYPE_CHECKING
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.domains.wallet.info import get_account_info
from pyfragment.domains.wallet.transaction import process_transaction
from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.tonapi.info import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import (
ConfigurationError,
FragmentAPIError,
@@ -33,6 +34,7 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
result = await client.call("initAdsTopupRequest", {"recipient": recipient, "amount": amount}, page_url=ADS_TOPUP_PAGE)
required_payment_amount = parse_required_payment_amount(result)
req_id = result.get("req_id")
if not req_id:
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
@@ -52,7 +54,7 @@ async def topup_ton(client: FragmentClient, username: str, amount: int, show_sen
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
tx_hash = await process_transaction(client, transaction)
tx_hash = await process_transaction(client, transaction, required_payment_amount=required_payment_amount)
return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
except FragmentError:
@@ -1 +0,0 @@
"""Anonymous numbers domain services."""
-1
View File
@@ -1 +0,0 @@
"""Giveaways domain services."""
+2 -2
View File
@@ -5,8 +5,8 @@ from typing import TYPE_CHECKING, get_args
from pyfragment.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE
from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.wallet.info import get_account_info
from pyfragment.domains.wallet.transaction import process_transaction
from pyfragment.domains.tonapi.info import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import (
ConfigurationError,
FragmentAPIError,
@@ -1 +0,0 @@
"""Marketplace domain services."""
-1
View File
@@ -1 +0,0 @@
"""Purchases domain services."""
+2 -2
View File
@@ -6,8 +6,8 @@ from typing import TYPE_CHECKING, get_args
from pyfragment.core.constants import DEVICE, PREMIUM_PAGE, STARS_PAGE
from pyfragment.domains.payments import parse_required_payment_amount
from pyfragment.domains.wallet.info import get_account_info
from pyfragment.domains.wallet.transaction import process_transaction
from pyfragment.domains.tonapi.info import get_account_info
from pyfragment.domains.tonapi.transaction import process_transaction
from pyfragment.exceptions import (
ConfigurationError,
FragmentAPIError,
@@ -10,7 +10,15 @@ from pyfragment.exceptions import WalletError
async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
"""Return wallet USDT balance via tonutils jetton get-methods."""
"""Return the USDT balance for a Fragment-linked TON wallet.
Args:
ton: Active `TonapiClient` instance used to query the TON network.
wallet_address: Raw wallet address that owns the USDT jetton wallet.
Returns:
Wallet balance in USDT as a floating-point value.
"""
try:
jetton_wallet_address = await get_wallet_address_get_method(
client=ton,
@@ -21,7 +29,7 @@ async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
raw_balance = int(wallet_data[0]) if wallet_data else 0
return float(raw_balance) / 1_000_000.0
except ProviderResponseError as exc:
# No jetton wallet deployed yet -> effectively zero USDT balance.
# No jetton wallet deployed yet means the balance is effectively zero.
if exc.code == 404:
return 0.0
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
@@ -34,7 +42,13 @@ async def check_ton_payment_balance(
amount_ton: float,
required_payment_amount: float | None,
) -> None:
"""Validate balance requirements for TON payment method."""
"""Validate that the TON wallet can cover a TON-denominated payment.
Args:
balance_ton: Current TON balance in the signing wallet.
amount_ton: Requested payment amount in TON.
required_payment_amount: Fragment-provided minimum amount if the API returned one.
"""
tx_price_ton = amount_ton
if required_payment_amount is not None and required_payment_amount > 0:
tx_price_ton = max(tx_price_ton, required_payment_amount)
@@ -55,8 +69,15 @@ async def check_usdt_payment_balance(
ton: Any,
wallet_address: str,
) -> None:
"""Validate balance requirements for USDT payment method."""
# USDT payment still needs TON for network fees.
"""Validate that the wallet can cover a USDT-denominated payment.
Args:
balance_ton: TON balance used for gas fees.
required_payment_amount: Fragment-provided USDT amount, if available.
ton: Active `TonapiClient` instance used to query jetton balance.
wallet_address: Raw wallet address that owns the USDT jetton wallet.
"""
# USDT payments still need TON for network fees.
if balance_ton < MIN_TON_BALANCE:
raise WalletError(
WalletError.LOW_TON_BALANCE.format(
@@ -7,7 +7,7 @@ from ton_core import NetworkGlobalID
from tonutils.clients import TonapiClient
from pyfragment.core.constants import WALLET_CLASSES
from pyfragment.domains.wallet.balance import get_usdt_balance
from pyfragment.domains.tonapi.balance import get_usdt_balance
from pyfragment.exceptions import WalletError
from pyfragment.models.wallet import WalletInfo
@@ -16,20 +16,14 @@ if TYPE_CHECKING:
async def get_account_info(client: FragmentClient) -> dict[str, Any]:
"""Fetch wallet address, public key, and state-init for the Fragment API.
Fragment requires account info to build each transaction payload. The
returned dict is JSON-serialised and passed as the ``account`` field in
``getBuy*Link`` / ``get*Link`` requests.
"""Build the wallet payload Fragment needs to prepare a transaction.
Args:
client: Authenticated :class:`FragmentClient` instance.
client: Authenticated `FragmentClient` instance with seed and API key.
Returns:
Dict with ``address``, ``publicKey``, ``chain``, ``walletStateInit``.
Raises:
WalletError: If account info cannot be retrieved.
A JSON-serialisable dictionary containing address, public key, chain,
and wallet state-init bytes.
"""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try:
@@ -47,17 +41,14 @@ async def get_account_info(client: FragmentClient) -> dict[str, Any]:
async def get_wallet_info(client: FragmentClient) -> WalletInfo:
"""Return the address, state and balance of the TON wallet.
"""Fetch the wallet address, chain state, and TON/USDT balances.
Args:
client: Authenticated :class:`FragmentClient` instance.
client: Authenticated `FragmentClient` instance with seed and API key.
Returns:
:class:`WalletInfo` with ``address``, ``state``, ``balance`` in TON,
and ``usdt_balance`` in USDT.
Raises:
WalletError: If the wallet state cannot be fetched.
`WalletInfo` with the friendly wallet address, current state,
TON balance, and USDT balance.
"""
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
try:
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService
from pyfragment.domains.tonapi.info import get_wallet_info
from pyfragment.models.wallet import WalletInfo
if TYPE_CHECKING:
pass
class TonapiService(BaseService):
async def get_wallet(self) -> WalletInfo:
return await get_wallet_info(self._client)
@@ -11,7 +11,7 @@ from tonutils.clients import TonapiClient
from tonutils.exceptions import ProviderResponseError
from pyfragment.core.constants import WALLET_CLASSES
from pyfragment.domains.wallet.balance import check_ton_payment_balance, check_usdt_payment_balance
from pyfragment.domains.tonapi.balance import check_ton_payment_balance, check_usdt_payment_balance
from pyfragment.exceptions import ParseError, TransactionError, WalletError
from pyfragment.models.enums import PaymentMethod
@@ -20,7 +20,12 @@ if TYPE_CHECKING:
def clean_decode(payload: str) -> str | Cell:
"""Decode a base64-encoded BOC payload to a plain-text comment string."""
"""Decode a base64 BOC comment from Fragment into text when possible.
Some Fragment payloads are plain text comments, while others are structured
TON messages such as jetton transfers. Non-text payloads are returned as a
`Cell` so the caller can keep the raw binary structure.
"""
s = payload.strip()
if not s:
return ""
@@ -31,8 +36,7 @@ def clean_decode(payload: str) -> str | Cell:
sl = cell.begin_parse()
op = sl.load_uint(32)
if op != 0:
# Non-zero op code means this is a structured message (e.g. jetton transfer),
# not a plain text comment — return the full cell as-is.
# Non-zero op code means this is a structured TON message, not a plain text comment.
return cell
try:
return sl.load_snake_string().strip()
@@ -48,23 +52,16 @@ async def process_transaction(
payment_method: PaymentMethod = "ton",
required_payment_amount: float | None = None,
) -> str:
"""Sign and broadcast a Fragment transaction to the TON network.
Validates the payload structure, checks the wallet balance, decodes the
on-chain comment, and calls ``wallet.transfer``.
"""Sign and broadcast a Fragment transaction with the seeded TON wallet.
Args:
client: Authenticated :class:`FragmentClient` instance.
transaction_data: Raw transaction dict from ``execute_transaction_request``.
payment_method: Payment currency ``"ton"`` or ``"usdt_ton"``.
required_payment_amount: Optional price from init*Request response.
client: Authenticated `FragmentClient` instance.
transaction_data: Raw Fragment transaction payload returned by the API.
payment_method: Payment currency to use for the purchase flow.
required_payment_amount: Optional amount returned by Fragment's init request.
Returns:
Normalised transaction hash string.
Raises:
TransactionError: If the payload is malformed or the broadcast fails.
WalletError: If the wallet balance is too low or cannot be fetched.
Normalized transaction hash string.
"""
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
raise TransactionError(TransactionError.INVALID_PAYLOAD)
@@ -76,7 +73,7 @@ async def process_transaction(
wallet_cls = WALLET_CLASSES[client.wallet_version]
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
# Check balance covers selected payment flow requirements.
# Check balance first so we fail before trying to broadcast on-chain.
try:
await wallet.refresh()
balance_ton = wallet.balance / 1_000_000_000
@@ -84,8 +81,7 @@ async def process_transaction(
wallet.address.to_str(False, False)
await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount)
else:
# USDT is withdrawn from the Fragment-linked wallet (transaction["from"]),
# not from the signing seed wallet. Seed wallet only pays TON gas.
# USDT is paid from the Fragment-linked wallet, not the signing wallet.
fragment_wallet_address = transaction_data["transaction"].get("from", "")
await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address)
except WalletError:
@@ -110,7 +106,6 @@ async def process_transaction(
await asyncio.sleep(1 + random.uniform(0, 0.5))
continue
if exc.code == 406 and "seqno" in str(exc).lower():
# Previous tx seqno not yet confirmed — wallet will re-fetch seqno on retry
if attempt < 2:
await asyncio.sleep(2 + random.uniform(0, 1))
continue
@@ -20,19 +20,13 @@ async def send_ton_transfer(
amount: int,
body: str | None = None,
) -> TonTransferResult:
"""Send a direct TON transfer on-chain using ToncenterClient.
"""Send a direct TON transfer from the seeded wallet.
Args:
client: Authenticated :class:`FragmentClient` instance (seed and wallet_version used).
destination: Recipient TON address (any format, e.g. ``"UQ..."``).
amount: Amount in nanotons (1 TON = 1 000 000 000 nanotons).
body: Optional on-chain comment attached to the transfer.
Returns:
:class:`TonTransferResult` with ``transaction_id``, ``destination``, and ``amount``.
Raises:
TransactionError: If the transaction fails to broadcast.
client: Authenticated `FragmentClient` instance.
destination: Recipient address in any TON-compatible format.
amount: Amount in nanotons.
body: Optional on-chain comment.
"""
try:
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
@@ -63,20 +57,14 @@ async def send_usdt_transfer(
forward_payload: str | None = None,
ton_for_gas: int = 50_000_000,
) -> UsdtTransferResult:
"""Send a direct USDT (TON jetton) transfer on-chain using ToncenterClient.
"""Send a direct USDT transfer from the seeded wallet.
Args:
client: Authenticated :class:`FragmentClient` instance (seed and wallet_version used).
destination: Recipient TON address (any format, e.g. ``"UQ..."``).
usdt_amount: Amount in USDT base units (6 decimals; 1 USDT = 1 000 000).
forward_payload: Optional comment forwarded to the recipient with the transfer notification.
ton_for_gas: TON attached for gas in nanotons. Defaults to ``50_000_000`` (0.05 TON).
Returns:
:class:`UsdtTransferResult` with ``transaction_id``, ``destination``, and ``amount``.
Raises:
TransactionError: If the transaction fails to broadcast.
client: Authenticated `FragmentClient` instance.
destination: Recipient address in any TON-compatible format.
usdt_amount: Amount in USDT base units (6 decimals).
forward_payload: Optional comment passed through to the recipient.
ton_for_gas: TON attached for gas in nanotons.
"""
try:
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
-1
View File
@@ -1 +0,0 @@
"""Wallet domain services."""
-20
View File
@@ -1,20 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from pyfragment.domains.base import BaseService
from pyfragment.domains.wallet.info import get_wallet_info
from pyfragment.domains.wallet.topup import topup_ton
from pyfragment.models.payments import AdsTopupResult
from pyfragment.models.wallet import WalletInfo
if TYPE_CHECKING:
pass
class WalletService(BaseService):
async def get_wallet(self) -> WalletInfo:
return await get_wallet_info(self._client)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
return await topup_ton(self._client, username, amount, show_sender=show_sender)
-1
View File
@@ -1 +0,0 @@
"""Domain data models for pyfragment."""
+5 -5
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
+2 -2
View File
@@ -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
+7 -7
View File
@@ -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 -5
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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)