diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index 73c2732..ac06351 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -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 diff --git a/pyfragment/client.py b/pyfragment/client.py index 78d11e1..f452581 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -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, diff --git a/pyfragment/core/__init__.py b/pyfragment/core/__init__.py index 87ec652..e69de29 100644 --- a/pyfragment/core/__init__.py +++ b/pyfragment/core/__init__.py @@ -1 +0,0 @@ -"""Low-level transport and shared helpers for pyfragment.""" diff --git a/pyfragment/domains/__init__.py b/pyfragment/domains/__init__.py index 858b79e..e69de29 100644 --- a/pyfragment/domains/__init__.py +++ b/pyfragment/domains/__init__.py @@ -1 +0,0 @@ -"""Domain service package for pyfragment.""" diff --git a/pyfragment/domains/ads/__init__.py b/pyfragment/domains/ads/__init__.py index c532ac4..e69de29 100644 --- a/pyfragment/domains/ads/__init__.py +++ b/pyfragment/domains/ads/__init__.py @@ -1 +0,0 @@ -"""Ads domain services.""" diff --git a/pyfragment/domains/ads/recharge.py b/pyfragment/domains/ads/recharge.py index 9879ca7..2ded2a2 100644 --- a/pyfragment/domains/ads/recharge.py +++ b/pyfragment/domains/ads/recharge.py @@ -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 diff --git a/pyfragment/domains/ads/service.py b/pyfragment/domains/ads/service.py index d55de1f..ce310cc 100644 --- a/pyfragment/domains/ads/service.py +++ b/pyfragment/domains/ads/service.py @@ -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) diff --git a/pyfragment/domains/wallet/topup.py b/pyfragment/domains/ads/tonup.py similarity index 84% rename from pyfragment/domains/wallet/topup.py rename to pyfragment/domains/ads/tonup.py index cf426ca..9e60135 100644 --- a/pyfragment/domains/wallet/topup.py +++ b/pyfragment/domains/ads/tonup.py @@ -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: diff --git a/pyfragment/domains/anonymous_numbers/__init__.py b/pyfragment/domains/anonymous_numbers/__init__.py index b1aa820..e69de29 100644 --- a/pyfragment/domains/anonymous_numbers/__init__.py +++ b/pyfragment/domains/anonymous_numbers/__init__.py @@ -1 +0,0 @@ -"""Anonymous numbers domain services.""" diff --git a/pyfragment/domains/giveaways/__init__.py b/pyfragment/domains/giveaways/__init__.py index 21a406f..e69de29 100644 --- a/pyfragment/domains/giveaways/__init__.py +++ b/pyfragment/domains/giveaways/__init__.py @@ -1 +0,0 @@ -"""Giveaways domain services.""" diff --git a/pyfragment/domains/giveaways/giveaway.py b/pyfragment/domains/giveaways/giveaway.py index a7cf68d..4e7d766 100644 --- a/pyfragment/domains/giveaways/giveaway.py +++ b/pyfragment/domains/giveaways/giveaway.py @@ -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, diff --git a/pyfragment/domains/marketplace/__init__.py b/pyfragment/domains/marketplace/__init__.py index 2d86908..e69de29 100644 --- a/pyfragment/domains/marketplace/__init__.py +++ b/pyfragment/domains/marketplace/__init__.py @@ -1 +0,0 @@ -"""Marketplace domain services.""" diff --git a/pyfragment/domains/purchases/__init__.py b/pyfragment/domains/purchases/__init__.py index 3992413..e69de29 100644 --- a/pyfragment/domains/purchases/__init__.py +++ b/pyfragment/domains/purchases/__init__.py @@ -1 +0,0 @@ -"""Purchases domain services.""" diff --git a/pyfragment/domains/purchases/purchase.py b/pyfragment/domains/purchases/purchase.py index 7c0a7d5..62ff944 100644 --- a/pyfragment/domains/purchases/purchase.py +++ b/pyfragment/domains/purchases/purchase.py @@ -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, diff --git a/pyfragment/domains/tonapi/__init__.py b/pyfragment/domains/tonapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyfragment/domains/wallet/balance.py b/pyfragment/domains/tonapi/balance.py similarity index 68% rename from pyfragment/domains/wallet/balance.py rename to pyfragment/domains/tonapi/balance.py index ecab1c0..125defe 100644 --- a/pyfragment/domains/wallet/balance.py +++ b/pyfragment/domains/tonapi/balance.py @@ -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( diff --git a/pyfragment/domains/wallet/info.py b/pyfragment/domains/tonapi/info.py similarity index 71% rename from pyfragment/domains/wallet/info.py rename to pyfragment/domains/tonapi/info.py index dccffe8..91db14b 100644 --- a/pyfragment/domains/wallet/info.py +++ b/pyfragment/domains/tonapi/info.py @@ -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: diff --git a/pyfragment/domains/tonapi/service.py b/pyfragment/domains/tonapi/service.py new file mode 100644 index 0000000..789c36a --- /dev/null +++ b/pyfragment/domains/tonapi/service.py @@ -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) diff --git a/pyfragment/domains/wallet/transaction.py b/pyfragment/domains/tonapi/transaction.py similarity index 75% rename from pyfragment/domains/wallet/transaction.py rename to pyfragment/domains/tonapi/transaction.py index d4b43c2..33930e1 100644 --- a/pyfragment/domains/wallet/transaction.py +++ b/pyfragment/domains/tonapi/transaction.py @@ -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 diff --git a/pyfragment/domains/wallet/transfer.py b/pyfragment/domains/tonapi/transfer.py similarity index 68% rename from pyfragment/domains/wallet/transfer.py rename to pyfragment/domains/tonapi/transfer.py index c59463f..0870a89 100644 --- a/pyfragment/domains/wallet/transfer.py +++ b/pyfragment/domains/tonapi/transfer.py @@ -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: diff --git a/pyfragment/domains/wallet/__init__.py b/pyfragment/domains/wallet/__init__.py deleted file mode 100644 index 7f2aabd..0000000 --- a/pyfragment/domains/wallet/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Wallet domain services.""" diff --git a/pyfragment/domains/wallet/service.py b/pyfragment/domains/wallet/service.py deleted file mode 100644 index b85758a..0000000 --- a/pyfragment/domains/wallet/service.py +++ /dev/null @@ -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) diff --git a/pyfragment/models/__init__.py b/pyfragment/models/__init__.py index 87a008c..e69de29 100644 --- a/pyfragment/models/__init__.py +++ b/pyfragment/models/__init__.py @@ -1 +0,0 @@ -"""Domain data models for pyfragment.""" diff --git a/tests/001_test_decode.py b/tests/001_test_decode.py index b2c0e30..9be867e 100644 --- a/tests/001_test_decode.py +++ b/tests/001_test_decode.py @@ -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 diff --git a/tests/002_test_client.py b/tests/002_test_client.py index 98733dc..c8692ec 100644 --- a/tests/002_test_client.py +++ b/tests/002_test_client.py @@ -1,4 +1,4 @@ -"""Unit tests for FragmentClient — initialization, validation, and cookie parsing.""" +"""Validate FragmentClient setup, cookie parsing, and wallet version checks.""" import json diff --git a/tests/003_test_balance.py b/tests/003_test_balance.py index d764750..2af0d09 100644 --- a/tests/003_test_balance.py +++ b/tests/003_test_balance.py @@ -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( diff --git a/tests/004_test_stars.py b/tests/004_test_stars.py index cbcac4d..69428d7 100644 --- a/tests/004_test_stars.py +++ b/tests/004_test_stars.py @@ -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 diff --git a/tests/005_test_premium.py b/tests/005_test_premium.py index 1e8be29..5ff8caf 100644 --- a/tests/005_test_premium.py +++ b/tests/005_test_premium.py @@ -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 diff --git a/tests/006_test_topup.py b/tests/006_test_topup.py index 49893c6..0fd366d 100644 --- a/tests/006_test_topup.py +++ b/tests/006_test_topup.py @@ -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 diff --git a/tests/007_test_wallet.py b/tests/007_test_wallet.py index 10df86e..c27b2ff 100644 --- a/tests/007_test_wallet.py +++ b/tests/007_test_wallet.py @@ -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) diff --git a/tests/008_test_call.py b/tests/008_test_call.py index c663b76..2ff6e75 100644 --- a/tests/008_test_call.py +++ b/tests/008_test_call.py @@ -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 diff --git a/tests/010_test_recharge_ads.py b/tests/010_test_recharge_ads.py index ea7cd42..9706b48 100644 --- a/tests/010_test_recharge_ads.py +++ b/tests/010_test_recharge_ads.py @@ -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 diff --git a/tests/011_test_usernames.py b/tests/011_test_usernames.py index 74ddde7..fe12671 100644 --- a/tests/011_test_usernames.py +++ b/tests/011_test_usernames.py @@ -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 diff --git a/tests/012_test_numbers.py b/tests/012_test_numbers.py index a648c49..1492b10 100644 --- a/tests/012_test_numbers.py +++ b/tests/012_test_numbers.py @@ -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 diff --git a/tests/013_test_gifts.py b/tests/013_test_gifts.py index 4aff041..8c0ec0b 100644 --- a/tests/013_test_gifts.py +++ b/tests/013_test_gifts.py @@ -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 diff --git a/tests/014_test_cookies.py b/tests/014_test_cookies.py index c1be13a..a390e29 100644 --- a/tests/014_test_cookies.py +++ b/tests/014_test_cookies.py @@ -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 diff --git a/tests/015_test_payment_amount.py b/tests/015_test_payment_amount.py index 01295de..5a89fa9 100644 --- a/tests/015_test_payment_amount.py +++ b/tests/015_test_payment_amount.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 1aa85a6..036a39f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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)