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,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 importlib.metadata import version
|
||||||
|
|
||||||
from pyfragment.client import FragmentClient
|
from pyfragment.client import FragmentClient
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ from pyfragment.domains.anonymous_numbers.service import AnonymousNumbersService
|
|||||||
from pyfragment.domains.giveaways.service import GiveawaysService
|
from pyfragment.domains.giveaways.service import GiveawaysService
|
||||||
from pyfragment.domains.marketplace.service import MarketplaceService
|
from pyfragment.domains.marketplace.service import MarketplaceService
|
||||||
from pyfragment.domains.purchases.service import PurchasesService
|
from pyfragment.domains.purchases.service import PurchasesService
|
||||||
from pyfragment.domains.wallet.info import get_wallet_info
|
from pyfragment.domains.tonapi.service import TonapiService
|
||||||
from pyfragment.domains.wallet.service import WalletService
|
|
||||||
from pyfragment.exceptions import ConfigurationError, CookieError
|
from pyfragment.exceptions import ConfigurationError, CookieError
|
||||||
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
|
from pyfragment.models.anonymous_numbers import LoginCodeResult, TerminateSessionsResult
|
||||||
from pyfragment.models.enums import PaymentMethod, WalletVersion
|
from pyfragment.models.enums import PaymentMethod, WalletVersion
|
||||||
@@ -99,7 +98,7 @@ class FragmentClient:
|
|||||||
self.marketplace = MarketplaceService(self)
|
self.marketplace = MarketplaceService(self)
|
||||||
self.purchases = PurchasesService(self)
|
self.purchases = PurchasesService(self)
|
||||||
self.giveaways = GiveawaysService(self)
|
self.giveaways = GiveawaysService(self)
|
||||||
self.wallet = WalletService(self)
|
self.tonapi = TonapiService(self)
|
||||||
self.anonymous_numbers = AnonymousNumbersService(self)
|
self.anonymous_numbers = AnonymousNumbersService(self)
|
||||||
self.ads = AdsService(self)
|
self.ads = AdsService(self)
|
||||||
|
|
||||||
@@ -163,7 +162,7 @@ class FragmentClient:
|
|||||||
Returns:
|
Returns:
|
||||||
:class:`AdsTopupResult` with ``transaction_id``, ``username``, and ``amount``.
|
: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:
|
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
|
||||||
"""Add funds to your own Telegram Ads account.
|
"""Add funds to your own Telegram Ads account.
|
||||||
@@ -186,7 +185,7 @@ class FragmentClient:
|
|||||||
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``),
|
(``"active"``, ``"uninit"``, ``"nonexist"``, or ``"frozen"``),
|
||||||
``ton_balance`` in TON, and ``usdt_balance`` in USDT.
|
``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(
|
async def giveaway_stars(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Low-level transport and shared helpers for pyfragment."""
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Domain service package for pyfragment."""
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Ads domain services."""
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import json
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
|
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
|
||||||
from pyfragment.domains.wallet.info import get_account_info
|
from pyfragment.domains.tonapi.info import get_account_info
|
||||||
from pyfragment.domains.wallet.transaction import process_transaction
|
from pyfragment.domains.tonapi.transaction import process_transaction
|
||||||
from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError
|
from pyfragment.exceptions import ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, VerificationError
|
||||||
from pyfragment.models.payments import AdsRechargeResult
|
from pyfragment.models.payments import AdsRechargeResult
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from pyfragment.domains.ads.recharge import recharge_ads
|
from pyfragment.domains.ads.recharge import recharge_ads
|
||||||
|
from pyfragment.domains.ads.tonup import topup_ton
|
||||||
from pyfragment.domains.base import BaseService
|
from pyfragment.domains.base import BaseService
|
||||||
from pyfragment.models.payments import AdsRechargeResult
|
from pyfragment.models.payments import AdsRechargeResult, AdsTopupResult
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
pass
|
pass
|
||||||
@@ -13,3 +14,6 @@ if TYPE_CHECKING:
|
|||||||
class AdsService(BaseService):
|
class AdsService(BaseService):
|
||||||
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
|
async def recharge_ads(self, account: str, amount: int) -> AdsRechargeResult:
|
||||||
return await recharge_ads(self._client, account, amount)
|
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 typing import TYPE_CHECKING
|
||||||
|
|
||||||
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
|
from pyfragment.core.constants import ADS_TOPUP_PAGE, DEVICE
|
||||||
from pyfragment.domains.wallet.info import get_account_info
|
from pyfragment.domains.payments import parse_required_payment_amount
|
||||||
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 (
|
from pyfragment.exceptions import (
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
FragmentAPIError,
|
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))
|
raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username))
|
||||||
|
|
||||||
result = await client.call("initAdsTopupRequest", {"recipient": recipient, "amount": amount}, page_url=ADS_TOPUP_PAGE)
|
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")
|
req_id = result.get("req_id")
|
||||||
if not req_id:
|
if not req_id:
|
||||||
raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup"))
|
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"):
|
if transaction.get("need_verify"):
|
||||||
raise VerificationError(VerificationError.KYC_REQUIRED)
|
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)
|
return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount)
|
||||||
|
|
||||||
except FragmentError:
|
except FragmentError:
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Anonymous numbers domain services."""
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Giveaways domain services."""
|
|
||||||
|
|||||||
@@ -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.core.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE, STARS_GIVEAWAY_PAGE
|
||||||
from pyfragment.domains.payments import parse_required_payment_amount
|
from pyfragment.domains.payments import parse_required_payment_amount
|
||||||
from pyfragment.domains.wallet.info import get_account_info
|
from pyfragment.domains.tonapi.info import get_account_info
|
||||||
from pyfragment.domains.wallet.transaction import process_transaction
|
from pyfragment.domains.tonapi.transaction import process_transaction
|
||||||
from pyfragment.exceptions import (
|
from pyfragment.exceptions import (
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
FragmentAPIError,
|
FragmentAPIError,
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Marketplace domain services."""
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Purchases domain services."""
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from typing import TYPE_CHECKING, get_args
|
|||||||
|
|
||||||
from pyfragment.core.constants import DEVICE, PREMIUM_PAGE, STARS_PAGE
|
from pyfragment.core.constants import DEVICE, PREMIUM_PAGE, STARS_PAGE
|
||||||
from pyfragment.domains.payments import parse_required_payment_amount
|
from pyfragment.domains.payments import parse_required_payment_amount
|
||||||
from pyfragment.domains.wallet.info import get_account_info
|
from pyfragment.domains.tonapi.info import get_account_info
|
||||||
from pyfragment.domains.wallet.transaction import process_transaction
|
from pyfragment.domains.tonapi.transaction import process_transaction
|
||||||
from pyfragment.exceptions import (
|
from pyfragment.exceptions import (
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
FragmentAPIError,
|
FragmentAPIError,
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ from pyfragment.exceptions import WalletError
|
|||||||
|
|
||||||
|
|
||||||
async def get_usdt_balance(ton: Any, wallet_address: str) -> float:
|
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:
|
try:
|
||||||
jetton_wallet_address = await get_wallet_address_get_method(
|
jetton_wallet_address = await get_wallet_address_get_method(
|
||||||
client=ton,
|
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
|
raw_balance = int(wallet_data[0]) if wallet_data else 0
|
||||||
return float(raw_balance) / 1_000_000.0
|
return float(raw_balance) / 1_000_000.0
|
||||||
except ProviderResponseError as exc:
|
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:
|
if exc.code == 404:
|
||||||
return 0.0
|
return 0.0
|
||||||
raise WalletError(WalletError.USDT_BALANCE_CHECK_FAILED.format(exc=exc)) from exc
|
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,
|
amount_ton: float,
|
||||||
required_payment_amount: float | None,
|
required_payment_amount: float | None,
|
||||||
) -> 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
|
tx_price_ton = amount_ton
|
||||||
if required_payment_amount is not None and required_payment_amount > 0:
|
if required_payment_amount is not None and required_payment_amount > 0:
|
||||||
tx_price_ton = max(tx_price_ton, required_payment_amount)
|
tx_price_ton = max(tx_price_ton, required_payment_amount)
|
||||||
@@ -55,8 +69,15 @@ async def check_usdt_payment_balance(
|
|||||||
ton: Any,
|
ton: Any,
|
||||||
wallet_address: str,
|
wallet_address: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Validate balance requirements for USDT payment method."""
|
"""Validate that the wallet can cover a USDT-denominated payment.
|
||||||
# USDT payment still needs TON for network fees.
|
|
||||||
|
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:
|
if balance_ton < MIN_TON_BALANCE:
|
||||||
raise WalletError(
|
raise WalletError(
|
||||||
WalletError.LOW_TON_BALANCE.format(
|
WalletError.LOW_TON_BALANCE.format(
|
||||||
@@ -7,7 +7,7 @@ from ton_core import NetworkGlobalID
|
|||||||
from tonutils.clients import TonapiClient
|
from tonutils.clients import TonapiClient
|
||||||
|
|
||||||
from pyfragment.core.constants import WALLET_CLASSES
|
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.exceptions import WalletError
|
||||||
from pyfragment.models.wallet import WalletInfo
|
from pyfragment.models.wallet import WalletInfo
|
||||||
|
|
||||||
@@ -16,20 +16,14 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
async def get_account_info(client: FragmentClient) -> dict[str, Any]:
|
async def get_account_info(client: FragmentClient) -> dict[str, Any]:
|
||||||
"""Fetch wallet address, public key, and state-init for the Fragment API.
|
"""Build the wallet payload Fragment needs to prepare a transaction.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
client: Authenticated `FragmentClient` instance with seed and API key.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with ``address``, ``publicKey``, ``chain``, ``walletStateInit``.
|
A JSON-serialisable dictionary containing address, public key, chain,
|
||||||
|
and wallet state-init bytes.
|
||||||
Raises:
|
|
||||||
WalletError: If account info cannot be retrieved.
|
|
||||||
"""
|
"""
|
||||||
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
||||||
try:
|
try:
|
||||||
@@ -47,17 +41,14 @@ async def get_account_info(client: FragmentClient) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
async def get_wallet_info(client: FragmentClient) -> WalletInfo:
|
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:
|
Args:
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
client: Authenticated `FragmentClient` instance with seed and API key.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
:class:`WalletInfo` with ``address``, ``state``, ``balance`` in TON,
|
`WalletInfo` with the friendly wallet address, current state,
|
||||||
and ``usdt_balance`` in USDT.
|
TON balance, and USDT balance.
|
||||||
|
|
||||||
Raises:
|
|
||||||
WalletError: If the wallet state cannot be fetched.
|
|
||||||
"""
|
"""
|
||||||
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
async with TonapiClient(network=NetworkGlobalID.MAINNET, api_key=client.api_key) as ton:
|
||||||
try:
|
try:
|
||||||
@@ -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)
|
||||||
+16
-21
@@ -11,7 +11,7 @@ from tonutils.clients import TonapiClient
|
|||||||
from tonutils.exceptions import ProviderResponseError
|
from tonutils.exceptions import ProviderResponseError
|
||||||
|
|
||||||
from pyfragment.core.constants import WALLET_CLASSES
|
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.exceptions import ParseError, TransactionError, WalletError
|
||||||
from pyfragment.models.enums import PaymentMethod
|
from pyfragment.models.enums import PaymentMethod
|
||||||
|
|
||||||
@@ -20,7 +20,12 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
def clean_decode(payload: str) -> str | Cell:
|
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()
|
s = payload.strip()
|
||||||
if not s:
|
if not s:
|
||||||
return ""
|
return ""
|
||||||
@@ -31,8 +36,7 @@ def clean_decode(payload: str) -> str | Cell:
|
|||||||
sl = cell.begin_parse()
|
sl = cell.begin_parse()
|
||||||
op = sl.load_uint(32)
|
op = sl.load_uint(32)
|
||||||
if op != 0:
|
if op != 0:
|
||||||
# Non-zero op code means this is a structured message (e.g. jetton transfer),
|
# Non-zero op code means this is a structured TON message, not a plain text comment.
|
||||||
# not a plain text comment — return the full cell as-is.
|
|
||||||
return cell
|
return cell
|
||||||
try:
|
try:
|
||||||
return sl.load_snake_string().strip()
|
return sl.load_snake_string().strip()
|
||||||
@@ -48,23 +52,16 @@ async def process_transaction(
|
|||||||
payment_method: PaymentMethod = "ton",
|
payment_method: PaymentMethod = "ton",
|
||||||
required_payment_amount: float | None = None,
|
required_payment_amount: float | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Sign and broadcast a Fragment transaction to the TON network.
|
"""Sign and broadcast a Fragment transaction with the seeded TON wallet.
|
||||||
|
|
||||||
Validates the payload structure, checks the wallet balance, decodes the
|
|
||||||
on-chain comment, and calls ``wallet.transfer``.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
client: Authenticated `FragmentClient` instance.
|
||||||
transaction_data: Raw transaction dict from ``execute_transaction_request``.
|
transaction_data: Raw Fragment transaction payload returned by the API.
|
||||||
payment_method: Payment currency — ``"ton"`` or ``"usdt_ton"``.
|
payment_method: Payment currency to use for the purchase flow.
|
||||||
required_payment_amount: Optional price from init*Request response.
|
required_payment_amount: Optional amount returned by Fragment's init request.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Normalised transaction hash string.
|
Normalized 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.
|
|
||||||
"""
|
"""
|
||||||
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
|
if "transaction" not in transaction_data or not transaction_data["transaction"].get("messages"):
|
||||||
raise TransactionError(TransactionError.INVALID_PAYLOAD)
|
raise TransactionError(TransactionError.INVALID_PAYLOAD)
|
||||||
@@ -76,7 +73,7 @@ async def process_transaction(
|
|||||||
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
wallet_cls = WALLET_CLASSES[client.wallet_version]
|
||||||
wallet, _, _, _ = wallet_cls.from_mnemonic(client=ton, mnemonic=client.seed)
|
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:
|
try:
|
||||||
await wallet.refresh()
|
await wallet.refresh()
|
||||||
balance_ton = wallet.balance / 1_000_000_000
|
balance_ton = wallet.balance / 1_000_000_000
|
||||||
@@ -84,8 +81,7 @@ async def process_transaction(
|
|||||||
wallet.address.to_str(False, False)
|
wallet.address.to_str(False, False)
|
||||||
await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount)
|
await check_ton_payment_balance(balance_ton, amount_ton, required_payment_amount)
|
||||||
else:
|
else:
|
||||||
# USDT is withdrawn from the Fragment-linked wallet (transaction["from"]),
|
# USDT is paid from the Fragment-linked wallet, not the signing wallet.
|
||||||
# not from the signing seed wallet. Seed wallet only pays TON gas.
|
|
||||||
fragment_wallet_address = transaction_data["transaction"].get("from", "")
|
fragment_wallet_address = transaction_data["transaction"].get("from", "")
|
||||||
await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address)
|
await check_usdt_payment_balance(balance_ton, required_payment_amount, ton, fragment_wallet_address)
|
||||||
except WalletError:
|
except WalletError:
|
||||||
@@ -110,7 +106,6 @@ async def process_transaction(
|
|||||||
await asyncio.sleep(1 + random.uniform(0, 0.5))
|
await asyncio.sleep(1 + random.uniform(0, 0.5))
|
||||||
continue
|
continue
|
||||||
if exc.code == 406 and "seqno" in str(exc).lower():
|
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:
|
if attempt < 2:
|
||||||
await asyncio.sleep(2 + random.uniform(0, 1))
|
await asyncio.sleep(2 + random.uniform(0, 1))
|
||||||
continue
|
continue
|
||||||
@@ -20,19 +20,13 @@ async def send_ton_transfer(
|
|||||||
amount: int,
|
amount: int,
|
||||||
body: str | None = None,
|
body: str | None = None,
|
||||||
) -> TonTransferResult:
|
) -> TonTransferResult:
|
||||||
"""Send a direct TON transfer on-chain using ToncenterClient.
|
"""Send a direct TON transfer from the seeded wallet.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Authenticated :class:`FragmentClient` instance (seed and wallet_version used).
|
client: Authenticated `FragmentClient` instance.
|
||||||
destination: Recipient TON address (any format, e.g. ``"UQ..."``).
|
destination: Recipient address in any TON-compatible format.
|
||||||
amount: Amount in nanotons (1 TON = 1 000 000 000 nanotons).
|
amount: Amount in nanotons.
|
||||||
body: Optional on-chain comment attached to the transfer.
|
body: Optional on-chain comment.
|
||||||
|
|
||||||
Returns:
|
|
||||||
:class:`TonTransferResult` with ``transaction_id``, ``destination``, and ``amount``.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TransactionError: If the transaction fails to broadcast.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
|
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
|
||||||
@@ -63,20 +57,14 @@ async def send_usdt_transfer(
|
|||||||
forward_payload: str | None = None,
|
forward_payload: str | None = None,
|
||||||
ton_for_gas: int = 50_000_000,
|
ton_for_gas: int = 50_000_000,
|
||||||
) -> UsdtTransferResult:
|
) -> UsdtTransferResult:
|
||||||
"""Send a direct USDT (TON jetton) transfer on-chain using ToncenterClient.
|
"""Send a direct USDT transfer from the seeded wallet.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: Authenticated :class:`FragmentClient` instance (seed and wallet_version used).
|
client: Authenticated `FragmentClient` instance.
|
||||||
destination: Recipient TON address (any format, e.g. ``"UQ..."``).
|
destination: Recipient address in any TON-compatible format.
|
||||||
usdt_amount: Amount in USDT base units (6 decimals; 1 USDT = 1 000 000).
|
usdt_amount: Amount in USDT base units (6 decimals).
|
||||||
forward_payload: Optional comment forwarded to the recipient with the transfer notification.
|
forward_payload: Optional comment passed through to the recipient.
|
||||||
ton_for_gas: TON attached for gas in nanotons. Defaults to ``50_000_000`` (0.05 TON).
|
ton_for_gas: TON attached for gas in nanotons.
|
||||||
|
|
||||||
Returns:
|
|
||||||
:class:`UsdtTransferResult` with ``transaction_id``, ``destination``, and ``amount``.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TransactionError: If the transaction fails to broadcast.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
|
async with ToncenterClient(network=NetworkGlobalID.MAINNET) as ton:
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Wallet domain services."""
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
"""Domain data models for pyfragment."""
|
|
||||||
|
|||||||
@@ -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 base64
|
||||||
import re
|
import re
|
||||||
@@ -8,7 +8,7 @@ import pytest
|
|||||||
from ton_core import Cell
|
from ton_core import Cell
|
||||||
|
|
||||||
from pyfragment import ParseError
|
from pyfragment import ParseError
|
||||||
from pyfragment.domains.wallet.transaction import clean_decode
|
from pyfragment.domains.tonapi.transaction import clean_decode
|
||||||
|
|
||||||
PAYLOAD_CASES = [
|
PAYLOAD_CASES = [
|
||||||
pytest.param(
|
pytest.param(
|
||||||
@@ -86,7 +86,7 @@ def test_decode_payload_accepts_base64url_alphabet() -> None:
|
|||||||
raw = b"\xfb\xef\xff\x00"
|
raw = b"\xfb\xef\xff\x00"
|
||||||
payload = base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
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)
|
result = clean_decode(payload)
|
||||||
|
|
||||||
mocked.assert_called_once_with(raw)
|
mocked.assert_called_once_with(raw)
|
||||||
@@ -106,7 +106,7 @@ def test_clean_decode_returns_text_comment_when_utf8() -> None:
|
|||||||
return _FakeSlice()
|
return _FakeSlice()
|
||||||
|
|
||||||
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
|
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)
|
parsed = clean_decode(payload)
|
||||||
|
|
||||||
assert parsed == "Telegram Premium Ref#abc"
|
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("=")
|
payload = base64.urlsafe_b64encode(b"\x00\x01").decode().rstrip("=")
|
||||||
fake_cell: object = _FakeCell()
|
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)
|
parsed = clean_decode(payload)
|
||||||
|
|
||||||
assert parsed is fake_cell
|
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
|
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 collections.abc import Generator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
@@ -8,7 +8,7 @@ import pytest
|
|||||||
from tonutils.exceptions import ProviderResponseError
|
from tonutils.exceptions import ProviderResponseError
|
||||||
|
|
||||||
from pyfragment import TransactionError, WalletError
|
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
|
from tests.shared import VALID_SEED
|
||||||
|
|
||||||
|
|
||||||
@@ -48,8 +48,8 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
|
|||||||
@contextmanager
|
@contextmanager
|
||||||
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
|
def _patch_wallet(wallet: MagicMock) -> Generator[None, None, None]:
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.domains.wallet.transaction.TonapiClient") as mock_tonapi,
|
patch("pyfragment.domains.tonapi.transaction.TonapiClient") as mock_tonapi,
|
||||||
patch("pyfragment.domains.wallet.transaction.WALLET_CLASSES") as mock_classes,
|
patch("pyfragment.domains.tonapi.transaction.WALLET_CLASSES") as mock_classes,
|
||||||
):
|
):
|
||||||
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_sufficient_balance_broadcasts() -> None:
|
async def test_sufficient_balance_broadcasts() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, above threshold
|
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)
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
assert result == "abc123"
|
assert result == "abc123"
|
||||||
wallet.transfer.assert_called_once()
|
wallet.transfer.assert_called_once()
|
||||||
@@ -81,7 +81,7 @@ async def test_insufficient_balance_raises() -> None:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_exact_minimum_balance_broadcasts() -> None:
|
async def test_exact_minimum_balance_broadcasts() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=500_000_000) # exactly transaction amount threshold
|
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)
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
assert result == "abc123"
|
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:
|
async def test_rate_limit_retries_and_succeeds() -> None:
|
||||||
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||||
wallet.transfer = AsyncMock(side_effect=[_provider_error(429, "rate limited"), MagicMock(normalized_hash="abc123")])
|
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)
|
result = await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
assert result == "abc123"
|
assert result == "abc123"
|
||||||
assert wallet.transfer.call_count == 2
|
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)
|
wallet = _make_wallet(balance_nanotons=1_000_000_000)
|
||||||
err = _provider_error(406, "Duplicate msg_seqno")
|
err = _provider_error(406, "Duplicate msg_seqno")
|
||||||
wallet.transfer = AsyncMock(side_effect=[err, err, err])
|
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"):
|
with pytest.raises(TransactionError, match="seqno"):
|
||||||
await process_transaction(_make_client(), TRANSACTION_DATA)
|
await process_transaction(_make_client(), TRANSACTION_DATA)
|
||||||
assert wallet.transfer.call_count == 3
|
assert wallet.transfer.call_count == 3
|
||||||
@@ -143,7 +143,7 @@ async def test_duplicate_seqno_raises_after_retries() -> None:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_usdt_payment_requires_min_ton_gas_reserve() -> None:
|
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
|
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"):
|
with pytest.raises(WalletError, match="Insufficient TON balance"):
|
||||||
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton")
|
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 (
|
with (
|
||||||
_patch_wallet(wallet),
|
_patch_wallet(wallet),
|
||||||
patch("pyfragment.domains.wallet.transaction.clean_decode", return_value=""),
|
patch("pyfragment.domains.tonapi.transaction.clean_decode", return_value=""),
|
||||||
patch("pyfragment.domains.wallet.balance.get_usdt_balance", AsyncMock(return_value=5.0)),
|
patch("pyfragment.domains.tonapi.balance.get_usdt_balance", AsyncMock(return_value=5.0)),
|
||||||
):
|
):
|
||||||
with pytest.raises(WalletError, match="Insufficient USDT balance"):
|
with pytest.raises(WalletError, match="Insufficient USDT balance"):
|
||||||
await process_transaction(
|
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
|
import importlib
|
||||||
from unittest.mock import AsyncMock, patch
|
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
|
import importlib
|
||||||
from unittest.mock import AsyncMock, patch
|
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
|
import importlib
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
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 pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError
|
||||||
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
|
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
|
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
|
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.domains.wallet.info.TonapiClient") as mock_tonapi,
|
patch("pyfragment.domains.tonapi.info.TonapiClient") as mock_tonapi,
|
||||||
patch("pyfragment.domains.wallet.info.WALLET_CLASSES") as mock_classes,
|
patch("pyfragment.domains.tonapi.info.WALLET_CLASSES") as mock_classes,
|
||||||
patch("pyfragment.domains.wallet.info.get_usdt_balance", AsyncMock(return_value=12.3456)),
|
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.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
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
|
mock_wallet.address.to_str.return_value = FAKE_ADDRESS
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.domains.wallet.info.TonapiClient") as mock_tonapi,
|
patch("pyfragment.domains.tonapi.info.TonapiClient") as mock_tonapi,
|
||||||
patch("pyfragment.domains.wallet.info.WALLET_CLASSES") as mock_classes,
|
patch("pyfragment.domains.tonapi.info.WALLET_CLASSES") as mock_classes,
|
||||||
patch("pyfragment.domains.wallet.info.get_usdt_balance", AsyncMock(return_value=0.0)),
|
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.__aenter__ = AsyncMock(return_value=MagicMock())
|
||||||
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
|
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
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
@@ -25,7 +25,6 @@ async def test_call_returns_api_response(client: FragmentClient) -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_call_default_page_url(client: FragmentClient) -> None:
|
async def test_call_default_page_url(client: FragmentClient) -> None:
|
||||||
"""call() works without explicitly passing page_url (defaults to FRAGMENT_BASE_URL)."""
|
|
||||||
with (
|
with (
|
||||||
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)),
|
||||||
patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)),
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_call_no_data(client: FragmentClient) -> None:
|
async def test_call_no_data(client: FragmentClient) -> None:
|
||||||
"""call() with no extra data passes only the method field."""
|
|
||||||
mock_request = AsyncMock(return_value={})
|
mock_request = AsyncMock(return_value={})
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -52,7 +50,6 @@ async def test_call_no_data(client: FragmentClient) -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_call_merges_extra_data(client: FragmentClient) -> None:
|
async def test_call_merges_extra_data(client: FragmentClient) -> None:
|
||||||
"""call() merges caller-supplied data with the method field."""
|
|
||||||
mock_request = AsyncMock(return_value={})
|
mock_request = AsyncMock(return_value={})
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -70,7 +67,6 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fragment_request_non_200_raises() -> None:
|
async def test_fragment_request_non_200_raises() -> None:
|
||||||
"""fragment_request raises FragmentPageError on non-200 HTTP responses."""
|
|
||||||
response = MagicMock(spec=httpx.Response)
|
response = MagicMock(spec=httpx.Response)
|
||||||
response.status_code = 429
|
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
|
import importlib
|
||||||
from unittest.mock import AsyncMock, patch
|
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
|
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
|
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
|
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
|
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
|
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 json
|
||||||
import os
|
import os
|
||||||
from typing import cast
|
from typing import cast
|
||||||
@@ -5,16 +7,17 @@ from typing import cast
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import pyfragment.domains.ads.recharge # noqa: F401
|
import pyfragment.domains.ads.recharge # noqa: F401
|
||||||
|
import pyfragment.domains.ads.tonup # noqa: F401
|
||||||
import pyfragment.domains.giveaways.giveaway # noqa: F401
|
import pyfragment.domains.giveaways.giveaway # noqa: F401
|
||||||
import pyfragment.domains.purchases.purchase # 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 pyfragment import FragmentClient
|
||||||
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
|
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def cookies() -> dict[str, str]:
|
def cookies() -> dict[str, str]:
|
||||||
"""Load Fragment cookies from COOKIES_JSON env var; skip if unavailable."""
|
|
||||||
raw = os.environ.get("COOKIES_JSON")
|
raw = os.environ.get("COOKIES_JSON")
|
||||||
if not raw:
|
if not raw:
|
||||||
pytest.skip("COOKIES_JSON env var not set")
|
pytest.skip("COOKIES_JSON env var not set")
|
||||||
@@ -26,5 +29,4 @@ def cookies() -> dict[str, str]:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client() -> FragmentClient:
|
def client() -> FragmentClient:
|
||||||
"""Pre-built FragmentClient with dummy credentials."""
|
|
||||||
return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES)
|
||||||
|
|||||||
Reference in New Issue
Block a user