feat: Implement marketplace and purchases services

- Added MarketplaceService for searching usernames, numbers, and gifts.
- Introduced PurchasesService for purchasing stars and premium subscriptions.
- Created WalletService for wallet operations including balance checks and top-ups.
- Developed transaction handling for TON and USDT transfers.
- Added models for payments, marketplace results, and wallet information.
- Implemented error handling for various operations including wallet and transaction errors.
- Established domain structure for purchases, marketplace, and wallet functionalities.
This commit is contained in:
bohd4nx
2026-05-20 23:33:01 +03:00
parent e7dc0e051e
commit ae164d4fe7
83 changed files with 1090 additions and 1550 deletions
+5 -5
View File
@@ -7,8 +7,8 @@ from unittest.mock import patch
import pytest
from ton_core import Cell
from pyfragment.types import ParseError
from pyfragment.utils.wallet.transaction import clean_decode
from pyfragment import ParseError
from pyfragment.domains.wallet.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.utils.wallet.transaction.Cell.one_from_boc", return_value=_FakeCell()) as mocked:
with patch("pyfragment.domains.wallet.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.utils.wallet.transaction.Cell.one_from_boc", return_value=_FakeCell()):
with patch("pyfragment.domains.wallet.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.utils.wallet.transaction.Cell.one_from_boc", return_value=fake_cell):
with patch("pyfragment.domains.wallet.transaction.Cell.one_from_boc", return_value=fake_cell):
parsed = clean_decode(payload)
assert parsed is fake_cell
+1 -2
View File
@@ -4,8 +4,7 @@ import json
import pytest
from pyfragment import FragmentClient
from pyfragment.types import ConfigurationError, CookieError
from pyfragment import ConfigurationError, CookieError, FragmentClient
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED
# Client init tests
+11 -11
View File
@@ -7,8 +7,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from tonutils.exceptions import ProviderResponseError
from pyfragment.types import TransactionError, WalletError
from pyfragment.utils.wallet import process_transaction
from pyfragment import TransactionError, WalletError
from pyfragment.domains.wallet.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.utils.wallet.transaction.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.transaction.WALLET_CLASSES") as mock_classes,
patch("pyfragment.domains.wallet.transaction.TonapiClient") as mock_tonapi,
patch("pyfragment.domains.wallet.transaction.WALLET_CLASSES") as mock_classes,
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
@@ -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.utils.wallet.transaction.clean_decode", return_value="50 Telegram Stars"):
with _patch_wallet(wallet), patch("pyfragment.domains.wallet.transaction.clean_decode", return_value="50 Telegram Stars"):
result = await process_transaction(_make_client(), TRANSACTION_DATA)
assert result == "abc123"
wallet.transfer.assert_called_once()
@@ -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.utils.wallet.transaction.clean_decode", return_value="50 Telegram Stars"):
with _patch_wallet(wallet), patch("pyfragment.domains.wallet.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.utils.wallet.transaction.clean_decode", return_value=""):
with _patch_wallet(wallet), patch("pyfragment.domains.wallet.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.utils.wallet.transaction.clean_decode", return_value=""):
with _patch_wallet(wallet), patch("pyfragment.domains.wallet.transaction.clean_decode", return_value=""):
with pytest.raises(TransactionError, match="seqno"):
await process_transaction(_make_client(), TRANSACTION_DATA)
assert wallet.transfer.call_count == 3
@@ -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.utils.wallet.balance.get_usdt_balance", AsyncMock(return_value=100.0)):
with _patch_wallet(wallet), patch("pyfragment.domains.wallet.balance.get_usdt_balance", AsyncMock(return_value=100.0)):
with pytest.raises(WalletError, match="Insufficient TON balance"):
await process_transaction(_make_client(), TRANSACTION_DATA, payment_method="usdt_ton")
@@ -166,8 +166,8 @@ async def test_usdt_payment_checks_usdt_balance() -> None:
with (
_patch_wallet(wallet),
patch("pyfragment.utils.wallet.transaction.clean_decode", return_value=""),
patch("pyfragment.utils.wallet.balance.get_usdt_balance", AsyncMock(return_value=5.0)),
patch("pyfragment.domains.wallet.transaction.clean_decode", return_value=""),
patch("pyfragment.domains.wallet.balance.get_usdt_balance", AsyncMock(return_value=5.0)),
):
with pytest.raises(WalletError, match="Insufficient USDT balance"):
await process_transaction(
+3 -4
View File
@@ -5,10 +5,9 @@ from unittest.mock import AsyncMock, patch
import pytest
_purchase_stars_mod = importlib.import_module("pyfragment.methods.purchase_stars")
_giveaway_stars_mod = importlib.import_module("pyfragment.methods.giveaway_stars")
from pyfragment import FragmentClient
from pyfragment.types import ConfigurationError, StarsGiveawayResult, StarsResult, UserNotFoundError
_purchase_stars_mod = importlib.import_module("pyfragment.domains.purchases.purchase")
_giveaway_stars_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway")
from pyfragment import ConfigurationError, FragmentClient, StarsGiveawayResult, StarsResult, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Stars purchase validation tests
+3 -4
View File
@@ -5,10 +5,9 @@ from unittest.mock import AsyncMock, patch
import pytest
_purchase_premium_mod = importlib.import_module("pyfragment.methods.purchase_premium")
_giveaway_premium_mod = importlib.import_module("pyfragment.methods.giveaway_premium")
from pyfragment import FragmentClient
from pyfragment.types import ConfigurationError, PremiumGiveawayResult, PremiumResult, UserNotFoundError
_purchase_premium_mod = importlib.import_module("pyfragment.domains.purchases.purchase")
_giveaway_premium_mod = importlib.import_module("pyfragment.domains.giveaways.giveaway")
from pyfragment import ConfigurationError, FragmentClient, PremiumGiveawayResult, PremiumResult, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Premium purchase validation tests
+2 -3
View File
@@ -5,9 +5,8 @@ from unittest.mock import AsyncMock, patch
import pytest
_topup_ton_mod = importlib.import_module("pyfragment.methods.topup_ton")
from pyfragment import FragmentClient
from pyfragment.types import AdsTopupResult, ConfigurationError, UserNotFoundError
_topup_ton_mod = importlib.import_module("pyfragment.domains.wallet.topup")
from pyfragment import AdsTopupResult, ConfigurationError, FragmentClient, UserNotFoundError
from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# Topup TON validation tests
+6 -6
View File
@@ -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.utils.wallet.info.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.info.WALLET_CLASSES") as mock_classes,
patch("pyfragment.utils.wallet.info.get_usdt_balance", AsyncMock(return_value=12.3456)),
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)),
):
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.utils.wallet.info.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.info.WALLET_CLASSES") as mock_classes,
patch("pyfragment.utils.wallet.info.get_usdt_balance", AsyncMock(return_value=0.0)),
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)),
):
mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
+2 -3
View File
@@ -5,9 +5,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from pyfragment import FragmentClient
from pyfragment.types import FragmentPageError
from pyfragment.utils.api import fragment_request
from pyfragment import FragmentClient, FragmentPageError
from pyfragment.core.transport import fragment_request
from tests.shared import FAKE_HASH, FAKE_RESPONSE
# client.call() mocked tests
+2 -3
View File
@@ -5,9 +5,8 @@ from unittest.mock import AsyncMock, patch
import pytest
_recharge_ads_mod = importlib.import_module("pyfragment.methods.recharge_ads")
from pyfragment import FragmentClient
from pyfragment.types import AdsRechargeResult, ConfigurationError
_recharge_ads_mod = importlib.import_module("pyfragment.domains.ads.recharge")
from pyfragment import AdsRechargeResult, ConfigurationError, FragmentClient
from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH
# recharge_ads validation tests
+1 -2
View File
@@ -4,8 +4,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from pyfragment import FragmentClient
from pyfragment.types import UsernamesResult
from pyfragment import FragmentClient, UsernamesResult
FAKE_HTML = """
<tr class="tm-row-selectable">
+1 -2
View File
@@ -4,8 +4,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from pyfragment import FragmentClient
from pyfragment.types import NumbersResult
from pyfragment import FragmentClient, NumbersResult
FAKE_HTML = """
<tr class="tm-row-selectable">
+1 -2
View File
@@ -4,8 +4,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from pyfragment import FragmentClient
from pyfragment.types import GiftsResult
from pyfragment import FragmentClient, GiftsResult
FAKE_GIFTS_HTML = """
<div class="tm-catalog-grid">
+3 -4
View File
@@ -4,9 +4,8 @@ from unittest.mock import MagicMock, patch
import pytest
from pyfragment.types import CookieError
from pyfragment.types.constants import REQUIRED_COOKIE_KEYS
from pyfragment.utils import get_cookies_from_browser
from pyfragment import CookieError, get_cookies_from_browser
from pyfragment.core.constants import REQUIRED_COOKIE_KEYS
FAKE_JAR = [
{"name": "stel_ssid", "value": "abc123", "domain": "fragment.com", "expires": "2027-04-03T20:52:16.375Z"},
@@ -23,7 +22,7 @@ def _mock_rookiepy(jar: list[dict[str, str]] | None = None) -> MagicMock:
return mock
PATCH = "pyfragment.utils.cookies.rookiepy"
PATCH = "pyfragment.core.cookies.rookiepy"
# unsupported browser tests
+1 -1
View File
@@ -1,6 +1,6 @@
"""Unit tests for init payment amount parsing."""
from pyfragment.utils.parser import parse_required_payment_amount
from pyfragment.domains.payments import parse_required_payment_amount
def test_parse_required_payment_amount_ton_uses_amount() -> None:
+4 -6
View File
@@ -4,12 +4,10 @@ from typing import cast
import pytest
import pyfragment.methods.giveaway_premium # noqa: F401
import pyfragment.methods.giveaway_stars # noqa: F401
import pyfragment.methods.purchase_premium # noqa: F401
import pyfragment.methods.purchase_stars # noqa: F401
import pyfragment.methods.recharge_ads # noqa: F401
import pyfragment.methods.topup_ton # noqa: F401
import pyfragment.domains.ads.recharge # noqa: F401
import pyfragment.domains.giveaways.giveaway # noqa: F401
import pyfragment.domains.purchases.purchase # noqa: F401
import pyfragment.domains.wallet.topup # noqa: F401
from pyfragment import FragmentClient
from tests.shared import VALID_API_KEY, VALID_COOKIES, VALID_SEED