From 3e14a01c92904fe8823407ac10f2baa042226581 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Fri, 20 Mar 2026 20:16:43 +0200 Subject: [PATCH] refactor: harden client, clean tests, and fix timeouts - Security & validation: tighten hash regex, add HTTP timeouts to all requests, pass client.timeout through get_fragment_hash and AsyncClient - Constants: move all constants to types/constants.py; remove re-exports from types/__init__.py; add DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS - FragmentClient: add timeout param (default 30 s); async-context-manager support; remove WALLET_CLASSES from public API - Exceptions: remove dead INVALID_USERNAME constant (Fragment validates server-side); keep full hierarchy intact - Tests: add 006_test_methods_mock.py (6 mock tests for all 3 methods); DRY-refactor 004_test_balance.py (_patch_wallet context manager); clean up 005_test_methods.py (remove fragile network test, rename tests) - Examples: switch all 4 examples to async-with; align error messages; replace %-format with f-strings - README: rewrite usage section with single comprehensive async-with example covering all 3 methods and full exception hierarchy - CI: add mypy step to lint job; add pytest-mock and mypy to dev deps; set FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 on all jobs; fix COOKIES_JSON to job-level env var --- .github/workflows/ci.yml | 13 +-- .github/workflows/publish.yml | 8 ++ README.md | 182 ++++++++++++--------------------- examples/get_wallet.py | 20 ++-- examples/purchase_premium.py | 25 ++--- examples/purchase_stars.py | 25 ++--- examples/topup_ton.py | 31 +++--- pyfragment/client.py | 7 +- pyfragment/methods/premium.py | 8 +- pyfragment/methods/stars.py | 8 +- pyfragment/methods/ton.py | 8 +- pyfragment/types/__init__.py | 23 ----- pyfragment/types/constants.py | 3 + pyfragment/types/exceptions.py | 3 + pyfragment/utils/http.py | 5 +- pyfragment/utils/wallet.py | 3 +- pyproject.toml | 2 + tests/003_test_hash.py | 2 +- tests/004_test_balance.py | 88 +++++----------- tests/005_test_methods.py | 21 ++-- tests/006_test_methods_mock.py | 146 ++++++++++++++++++++++++++ tests/conftest.py | 13 ++- 22 files changed, 335 insertions(+), 309 deletions(-) create mode 100644 tests/006_test_methods_mock.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57e35cb..80e25d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,8 @@ jobs: lint: name: Lint & Format runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - uses: actions/checkout@v6.0.2 @@ -26,9 +28,14 @@ jobs: - run: black --check . --target-version py312 + - run: mypy pyfragment + test: name: Tests runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + COOKIES_JSON: ${{ secrets.COOKIES_JSON }} steps: - uses: actions/checkout@v6.0.2 @@ -41,10 +48,4 @@ jobs: - run: uv pip install --system ".[dev]" - - name: Write cookies.json - if: ${{ env.COOKIES_JSON != '' }} - run: echo "$COOKIES_JSON" > cookies.json - env: - COOKIES_JSON: ${{ secrets.COOKIES_JSON }} - - run: pytest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1b575e9..2f33c1f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,6 +11,8 @@ jobs: name: Version Check if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true outputs: version: ${{ steps.version.outputs.value }} is-new: ${{ steps.tag.outputs.is-new }} @@ -40,6 +42,8 @@ jobs: needs: version-check if: needs.version-check.outputs.is-new == 'true' runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - uses: actions/checkout@v6.0.2 @@ -61,6 +65,8 @@ jobs: name: Publish to PyPI needs: [version-check, build] runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true environment: name: pypi url: https://pypi.org/project/pyfragment/ @@ -79,6 +85,8 @@ jobs: name: GitHub Release needs: [version-check, build] runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true permissions: contents: write diff --git a/README.md b/README.md index ccb7c8b..6cd5668 100644 --- a/README.md +++ b/README.md @@ -22,36 +22,58 @@ --- -## ✨ Features - -- 💰 **TON Advertisement Topups** — Top up Telegram Ads balance (1–1,000,000,000 TON) -- 👑 **Telegram Premium** — Purchase Premium for any user (3, 6, or 12 months) -- ⭐ **Telegram Stars Purchases** — Purchase Stars for any Telegram user (50–1,000,000 Stars) -- 🔐 **Multi-wallet support** — V4R2 and V5R1 wallet contract versions -- ⚡ **Async-first** — Built on `httpx` and `asyncio` - ---- - -## 📦 Installation +## Installation ```bash pip install pyfragment ``` -Requires **Python 3.12+**. +Requires Python 3.12+. --- -## 🚀 Quick Start +## Configuration + +| Parameter | Type | Default | Description | +| ---------------- | ------------- | -------- | -------------------------------------------------------- | +| `seed` | `str` | — | 24-word TON wallet mnemonic | +| `api_key` | `str` | — | Tonapi key from [tonconsole.com](https://tonconsole.com) | +| `cookies` | `dict \| str` | — | Fragment session cookies | +| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` | +| `timeout` | `float` | `30.0` | HTTP request timeout in seconds | + +--- + +## Credentials + +**Fragment cookies** — log in to [fragment.com](https://fragment.com), install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm), and export these four keys: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass them as a `dict` or as a JSON string. Refresh when you get authentication errors. + +**Tonapi key** — generate at [tonconsole.com](https://tonconsole.com). + +**Seed phrase** — 24-word mnemonic from your TON wallet (Tonkeeper → Settings → Backup). Never share it. + +--- + +## Usage ```python import asyncio -from pyfragment import FragmentClient +from pyfragment import ( + FragmentClient, + FragmentError, # base — catches everything below + UserNotFoundError, # username doesn't exist on Fragment + WalletError, # insufficient balance or misconfiguration + CookieError, # cookies are missing or expired + TransactionError, # on-chain broadcast failed + ConfigurationError, # invalid argument (months, amount, etc.) + FragmentAPIError, # unexpected Fragment API response +) -async def main(): + +async def main() -> None: async with FragmentClient( - seed="word1 word2 ... word24", - api_key="YOUR_TONAPI_KEY", + seed="word1 word2 ... word24", # 24-word TON wallet mnemonic + api_key="YOUR_TONAPI_KEY", # from tonconsole.com cookies={ "stel_ssid": "...", "stel_dt": "...", @@ -59,110 +81,40 @@ async def main(): "stel_ton_token": "...", }, ) as client: - # Purchase 6 months of Telegram Premium - result = await client.purchase_premium("@username", months=6) - print(result.transaction_id) + try: + # Purchase 6 months of Telegram Premium for a user + result = await client.purchase_premium("@username", months=6) + print(f"{result.months} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") - # Purchase 500 Stars - result = await client.purchase_stars("@username", amount=500) - print(result.transaction_id) + # Purchase 500 Stars for a user (50–1 000 000) + result = await client.purchase_stars("@username", amount=500) + print(f"{result.stars} Stars successfully sent to {result.username} | tx: {result.transaction_id}") + + # Top up 10 TON to Telegram Ads balance + # wallet must hold at least amount + ~0.056 TON for gas + result = await client.topup_ton("@username", amount=10) + print(f"{result.amount} TON successfully sent to {result.username} | tx: {result.transaction_id}") + + except UserNotFoundError: + print(f"User was not found on fragment.com — check the username and try again.") + except WalletError as e: + print(f"Wallet error — insufficient balance or misconfiguration: {e}") + except CookieError: + print("Authentication failed — session cookies are missing or expired. Refresh them and retry.") + except TransactionError as e: + print(f"Transaction failed to broadcast on-chain: {e}") + except ConfigurationError as e: + print(f"Invalid argument: {e}") + except FragmentAPIError as e: + print(f"Unexpected response from Fragment API: {e}") + except FragmentError as e: + # catch-all for any other pyfragment error + print(f"Unexpected error: {e}") - # Top up 10 TON to Ads balance - result = await client.topup_ton("@username", amount=10) - print(result.transaction_id) asyncio.run(main()) ``` -See the [`examples/`](examples/) folder for ready-to-run scripts. - ---- - -## 🔧 Configuration - -### `FragmentClient` parameters - -| Parameter | Type | Required | Default | Description | -| ---------------- | ------------- | -------- | -------- | -------------------------------------------------------- | -| `seed` | `str` | ✅ | — | 24-word TON wallet mnemonic phrase | -| `api_key` | `str` | ✅ | — | Tonapi key from [tonconsole.com](https://tonconsole.com) | -| `cookies` | `dict \| str` | ✅ | — | Fragment session cookies (dict or JSON string) | -| `wallet_version` | `str` | ❌ | `"V5R1"` | Wallet contract version: `"V4R2"` or `"V5R1"` | - -### Methods - -> Usernames can be passed with or without `@`. - -| Method | Returns | Description | Limits | -| -------------------------------------------------- | ---------------- | ---------------------------------- | ------------------------- | -| `purchase_premium(username, months, show_sender=True)` | `PremiumResult` | Purchase Telegram Premium | `months`: 3, 6, or 12 | -| `purchase_stars(username, amount, show_sender=True)` | `StarsResult` | Purchase Telegram Stars | `amount`: 50–1,000,000 | -| `topup_ton(username, amount, show_sender=True)` | `AdsTopupResult` | Top up Telegram Ads balance | `amount`: 1–1,000,000,000 | -| `get_wallet()` | `WalletInfo` | Get wallet address, state, balance | — | - ---- - -## ⚙️ Getting Required Credentials - -### 🍪 Fragment.com Cookies - -**Prerequisites**: Log in to [fragment.com](https://fragment.com), connect your TON wallet. - -1. Install [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) -2. Open [fragment.com](https://fragment.com) while logged in -3. Click the extension → **Export** → **Header String** -4. Extract these four fields: - -```json -{ - "stel_ssid": "...", - "stel_dt": "...", - "stel_token": "...", - "stel_ton_token": "..." -} -``` - -> ⚠️ Cookies expire. Refresh them if you start getting `FragmentPageError` or auth errors. - -### 🔑 Tonapi Key - -1. Go to [tonconsole.com](https://tonconsole.com) -2. Register and generate a new API key -3. Pass it as `api_key` to `FragmentClient` - -### 🌱 Wallet Seed Phrase - -If you don't have a TON wallet, create one in [Tonkeeper](https://tonkeeper.com). -Go to **Settings → Backup** → copy the 24 words. - -> ⚠️ Never share your seed phrase. Store it offline. - -### 🔐 Wallet Version - -| Version | Use when | -| ------- | -------------------------------------------------------------- | -| `V5R1` | Default — Tonkeeper / MyTonWallet (wallets created after 2024) | -| `V4R2` | Older Tonkeeper or hardware wallets | - ---- - -## 🗂️ Error Handling - -All exceptions inherit from `FragmentError` — see [`pyfragment/types/exceptions.py`](pyfragment/types/exceptions.py) for the full list. - -```python -from pyfragment import FragmentClient, UserNotFoundError, ConfigurationError, WalletError - -try: - result = await client.purchase_stars("@unknown", amount=100) -except UserNotFoundError: - print("User not found on Fragment") -except WalletError as e: - print(f"Wallet issue: {e}") -except ConfigurationError as e: - print(f"Bad params: {e}") -``` - ---
diff --git a/examples/get_wallet.py b/examples/get_wallet.py index 936636e..84c8db4 100644 --- a/examples/get_wallet.py +++ b/examples/get_wallet.py @@ -1,5 +1,5 @@ """ -Example: initializing FragmentClient. +Example: fetch wallet address, state, and balance. Cookies can be passed as a dict or as a JSON string. wallet_version defaults to "V5R1" — change to "V4R2" for older wallets. @@ -9,7 +9,7 @@ import asyncio from pyfragment import FragmentClient -SEED = "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12 word13 word14 word15 word16 word17 word18 word19 word20 word21 word22 word23 word24" +SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" COOKIES = { "stel_ssid": "YOUR_STEL_SSID", @@ -20,20 +20,16 @@ COOKIES = { async def main() -> None: - client = FragmentClient( + async with FragmentClient( seed=SEED, api_key=API_KEY, cookies=COOKIES, wallet_version="V5R1", # or "V4R2" - ) - - wallet = await client.get_wallet() - - print("FragmentClient initialized") - print(" %-16s %s" % ("Wallet version:", client.wallet_version)) - print(" %-16s %s" % ("Address:", wallet.address)) - print(" %-16s %s" % ("State:", wallet.state)) - print(" %-16s %s TON" % ("Balance:", wallet.balance)) + ) as client: + wallet = await client.get_wallet() + print(f"Address: {wallet.address}") + print(f"State: {wallet.state}") + print(f"Balance: {wallet.balance} TON") if __name__ == "__main__": diff --git a/examples/purchase_premium.py b/examples/purchase_premium.py index d4366f2..d35f7c4 100644 --- a/examples/purchase_premium.py +++ b/examples/purchase_premium.py @@ -23,22 +23,17 @@ MONTHS = 3 # 3, 6, or 12 async def main() -> None: - client = FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + try: + result = await client.purchase_premium(USERNAME, months=MONTHS, show_sender=True) + except UserNotFoundError: + print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") + return + except ConfigurationError as e: + print(f"Invalid argument: {e}") + return - try: - result = await client.purchase_premium(USERNAME, months=MONTHS, show_sender=True) - except UserNotFoundError: - print(f"User {USERNAME!r} not found on Fragment.") - return - except ConfigurationError as e: - print(f"Invalid parameters: {e}") - return - - print("Premium purchased") - print(" %-14s %s" % ("Username:", result.username)) - print(" %-14s %s months" % ("Duration:", result.months)) - print(" %-14s %s" % ("Transaction:", result.transaction_id)) - print(" %-14s %s" % ("Timestamp:", result.timestamp)) + print(f"{result.months} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/examples/purchase_stars.py b/examples/purchase_stars.py index c0902a4..4f44ab0 100644 --- a/examples/purchase_stars.py +++ b/examples/purchase_stars.py @@ -23,22 +23,17 @@ AMOUNT = 500 # 50–1 000 000 async def main() -> None: - client = FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + try: + result = await client.purchase_stars(USERNAME, amount=AMOUNT, show_sender=True) + except UserNotFoundError: + print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") + return + except ConfigurationError as e: + print(f"Invalid argument: {e}") + return - try: - result = await client.purchase_stars(USERNAME, amount=AMOUNT, show_sender=True) - except UserNotFoundError: - print(f"User {USERNAME!r} not found on Fragment.") - return - except ConfigurationError as e: - print(f"Invalid parameters: {e}") - return - - print("Stars purchased") - print(" %-14s %s" % ("Username:", result.username)) - print(" %-14s %s" % ("Stars:", result.stars)) - print(" %-14s %s" % ("Transaction:", result.transaction_id)) - print(" %-14s %s" % ("Timestamp:", result.timestamp)) + print(f"{result.stars} Stars successfully sent to {result.username} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/examples/topup_ton.py b/examples/topup_ton.py index f362256..98c891d 100644 --- a/examples/topup_ton.py +++ b/examples/topup_ton.py @@ -23,25 +23,20 @@ AMOUNT = 10 # TON, integer — 1–1 000 000 000 async def main() -> None: - client = FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + try: + result = await client.topup_ton(USERNAME, amount=AMOUNT, show_sender=True) + except UserNotFoundError: + print(f"User {USERNAME} was not found on fragment.com — check the username and try again.") + return + except WalletError as e: + print(f"Wallet error — insufficient balance or misconfiguration: {e}") + return + except ConfigurationError as e: + print(f"Invalid argument: {e}") + return - try: - result = await client.topup_ton(USERNAME, amount=AMOUNT, show_sender=True) - except UserNotFoundError: - print(f"User {USERNAME!r} not found on Fragment.") - return - except WalletError as e: - print(f"Wallet error: {e}") - return - except ConfigurationError as e: - print(f"Invalid parameters: {e}") - return - - print("TON topped up") - print(" %-14s %s" % ("Username:", result.username)) - print(" %-14s %s TON" % ("Amount:", result.amount)) - print(" %-14s %s" % ("Transaction:", result.transaction_id)) - print(" %-14s %s" % ("Timestamp:", result.timestamp)) + print(f"{result.amount} TON successfully sent to {result.username} | tx: {result.transaction_id}") if __name__ == "__main__": diff --git a/pyfragment/client.py b/pyfragment/client.py index 09a62df..9436aed 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -4,16 +4,14 @@ from pyfragment.methods.premium import purchase_premium from pyfragment.methods.stars import purchase_stars from pyfragment.methods.ton import topup_ton from pyfragment.types import ( - REQUIRED_COOKIE_KEYS, - SUPPORTED_WALLET_VERSIONS, AdsTopupResult, ConfigurationError, CookieError, PremiumResult, StarsResult, WalletInfo, - WalletVersion, ) +from pyfragment.types.constants import DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS, SUPPORTED_WALLET_VERSIONS, WalletVersion from pyfragment.utils.wallet import get_wallet_info @@ -30,6 +28,7 @@ class FragmentClient: api_key: Tonapi API key — get one at https://tonconsole.com. cookies: Fragment session cookies as a dict or JSON string. wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default). + timeout: HTTP request timeout in seconds. Defaults to ``30.0``. Raises: ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid. @@ -53,6 +52,7 @@ class FragmentClient: api_key: str, cookies: dict | str, wallet_version: str = "V5R1", + timeout: float = DEFAULT_TIMEOUT, ) -> None: missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()] if missing: @@ -87,6 +87,7 @@ class FragmentClient: self.api_key: str = api_key.strip() self.cookies: dict = cookies self.wallet_version: WalletVersion = version # type: ignore[assignment] + self.timeout: float = timeout async def __aenter__(self) -> "FragmentClient": return self diff --git a/pyfragment/methods/premium.py b/pyfragment/methods/premium.py index 0c19c6b..598a787 100644 --- a/pyfragment/methods/premium.py +++ b/pyfragment/methods/premium.py @@ -5,9 +5,6 @@ from typing import TYPE_CHECKING import httpx from pyfragment.types import ( - BASE_HEADERS, - DEVICE, - PREMIUM_PAGE, ConfigurationError, FragmentAPIError, FragmentError, @@ -15,6 +12,7 @@ from pyfragment.types import ( UnexpectedError, UserNotFoundError, ) +from pyfragment.types.constants import BASE_HEADERS, DEVICE, PREMIUM_PAGE from pyfragment.utils import ( execute_transaction_request, fragment_post, @@ -94,10 +92,10 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int, raise ConfigurationError(ConfigurationError.INVALID_MONTHS) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_PAGE) + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_PAGE, client.timeout) account = await get_account_info(client) - async with httpx.AsyncClient(cookies=client.cookies) as session: + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: recipient = await _search_recipient(session, fragment_hash, username, months) req_id = await _init_request(session, fragment_hash, recipient, months) diff --git a/pyfragment/methods/stars.py b/pyfragment/methods/stars.py index bc58fa8..874593d 100644 --- a/pyfragment/methods/stars.py +++ b/pyfragment/methods/stars.py @@ -4,9 +4,6 @@ from typing import TYPE_CHECKING import httpx from pyfragment.types import ( - BASE_HEADERS, - DEVICE, - STARS_PAGE, ConfigurationError, FragmentAPIError, FragmentError, @@ -14,6 +11,7 @@ from pyfragment.types import ( UnexpectedError, UserNotFoundError, ) +from pyfragment.types.constants import BASE_HEADERS, DEVICE, STARS_PAGE from pyfragment.utils import ( execute_transaction_request, fragment_post, @@ -81,10 +79,10 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_PAGE) + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_PAGE, client.timeout) account = await get_account_info(client) - async with httpx.AsyncClient(cookies=client.cookies) as session: + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: recipient = await _search_recipient(session, fragment_hash, username) req_id = await _init_request(session, fragment_hash, recipient, amount) diff --git a/pyfragment/methods/ton.py b/pyfragment/methods/ton.py index ef5871b..44da5bb 100644 --- a/pyfragment/methods/ton.py +++ b/pyfragment/methods/ton.py @@ -4,9 +4,6 @@ from typing import TYPE_CHECKING import httpx from pyfragment.types import ( - BASE_HEADERS, - DEVICE, - TON_PAGE, AdsTopupResult, ConfigurationError, FragmentAPIError, @@ -14,6 +11,7 @@ from pyfragment.types import ( UnexpectedError, UserNotFoundError, ) +from pyfragment.types.constants import BASE_HEADERS, DEVICE, TON_PAGE from pyfragment.utils import ( execute_transaction_request, fragment_post, @@ -81,10 +79,10 @@ async def topup_ton(client: "FragmentClient", username: str, amount: int, show_s raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, TON_PAGE) + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, TON_PAGE, client.timeout) account = await get_account_info(client) - async with httpx.AsyncClient(cookies=client.cookies) as session: + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: recipient = await _search_recipient(session, fragment_hash, username) req_id = await _init_request(session, fragment_hash, recipient, amount) diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py index c254997..042f02b 100644 --- a/pyfragment/types/__init__.py +++ b/pyfragment/types/__init__.py @@ -1,15 +1,3 @@ -from pyfragment.types.constants import ( - BASE_HEADERS, - DEVICE, - MIN_TON_BALANCE, - PREMIUM_PAGE, - REQUIRED_COOKIE_KEYS, - STARS_PAGE, - SUPPORTED_WALLET_VERSIONS, - TON_PAGE, - WALLET_CLASSES, - WalletVersion, -) from pyfragment.types.exceptions import ( ClientError, ConfigurationError, @@ -28,17 +16,6 @@ from pyfragment.types.exceptions import ( from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult, WalletInfo __all__ = [ - # constants - "BASE_HEADERS", - "DEVICE", - "MIN_TON_BALANCE", - "PREMIUM_PAGE", - "REQUIRED_COOKIE_KEYS", - "STARS_PAGE", - "SUPPORTED_WALLET_VERSIONS", - "TON_PAGE", - "WALLET_CLASSES", - "WalletVersion", # client exceptions "ClientError", "ConfigurationError", diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index 2bb799d..74a8660 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -13,6 +13,9 @@ WALLET_CLASSES: dict[str, type] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1} # Minimum wallet balance required to cover TON network gas fees. MIN_TON_BALANCE: float = 0.056 +# Default HTTP request timeout in seconds. +DEFAULT_TIMEOUT: float = 30.0 + # Required Fragment session cookie keys REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py index 435a50c..1cc2283 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -16,6 +16,9 @@ class ConfigurationError(ClientError): INVALID_MONTHS = "Invalid duration. Choose 3, 6, or 12 months." INVALID_STARS_AMOUNT = "Amount must be an integer between 50 and 1 000 000 stars." INVALID_TON_AMOUNT = "Amount must be an integer between 1 and 1 000 000 000 TON." + INVALID_USERNAME = ( + "Invalid username '{username}'. Must be 5–32 characters: letters (A–Z, a–z), digits (0–9), or underscores (_)." + ) class CookieError(ClientError): diff --git a/pyfragment/utils/http.py b/pyfragment/utils/http.py index a1a5e1f..cbdb6e9 100644 --- a/pyfragment/utils/http.py +++ b/pyfragment/utils/http.py @@ -4,12 +4,14 @@ from typing import Any import httpx from pyfragment.types import FragmentPageError, ParseError, VerificationError +from pyfragment.types.constants import DEFAULT_TIMEOUT async def get_fragment_hash( cookies: dict[str, Any], headers: dict[str, str], page_url: str, + timeout: float = DEFAULT_TIMEOUT, ) -> str: """Fetch the API hash from a Fragment page. @@ -21,6 +23,7 @@ async def get_fragment_hash( cookies: Active Fragment session cookies. headers: Base headers for the relevant Fragment page. page_url: URL of the Fragment page to fetch the hash from. + timeout: HTTP request timeout in seconds. Defaults to ``DEFAULT_TIMEOUT``. Returns: Lowercase hex hash string. @@ -44,7 +47,7 @@ async def get_fragment_hash( } ) - async with httpx.AsyncClient(cookies=cookies) as session: + async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session: response = await session.get(page_url, headers=page_headers) if response.status_code != 200: diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py index 55680f2..42d2167 100644 --- a/pyfragment/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -6,7 +6,8 @@ from tonutils.clients import TonapiClient from tonutils.exceptions import ProviderResponseError from tonutils.types import NetworkGlobalID -from pyfragment.types import MIN_TON_BALANCE, WALLET_CLASSES, TransactionError, WalletError +from pyfragment.types import TransactionError, WalletError +from pyfragment.types.constants import MIN_TON_BALANCE, WALLET_CLASSES from pyfragment.types.results import WalletInfo from pyfragment.utils.decoder import clean_decode diff --git a/pyproject.toml b/pyproject.toml index 1f41d60..2dd39a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,8 @@ dependencies = [ dev = [ "pytest==9.0.2", "pytest-asyncio==1.3.0", + "pytest-mock", + "mypy", "ruff", "black", ] diff --git a/tests/003_test_hash.py b/tests/003_test_hash.py index 4bb6f18..bb883ba 100644 --- a/tests/003_test_hash.py +++ b/tests/003_test_hash.py @@ -5,7 +5,7 @@ import re import pytest -from pyfragment.types import BASE_HEADERS, STARS_PAGE +from pyfragment.types.constants import BASE_HEADERS, STARS_PAGE from pyfragment.utils import get_fragment_hash diff --git a/tests/004_test_balance.py b/tests/004_test_balance.py index e2023ee..a784300 100644 --- a/tests/004_test_balance.py +++ b/tests/004_test_balance.py @@ -1,21 +1,21 @@ """Unit tests for process_transaction() — balance checks before broadcast.""" +from contextlib import contextmanager from unittest.mock import AsyncMock, MagicMock, patch import pytest -from pyfragment.types import WalletError +from pyfragment.types import TransactionError, WalletError from pyfragment.utils.wallet import process_transaction VALID_SEED = "abandon " * 23 + "about" -# Minimal transaction payload: 0.5 TON = 500_000_000 nanotons TRANSACTION_DATA = { "transaction": { "messages": [ { "address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a", - "amount": "500000000", + "amount": "500000000", # 0.5 TON "payload": "", } ] @@ -23,9 +23,9 @@ TRANSACTION_DATA = { } -def _make_client(api_key: str = "test_key") -> MagicMock: +def _make_client() -> MagicMock: client = MagicMock() - client.api_key = api_key + client.api_key = "test_key" client.seed = VALID_SEED.split() client.wallet_version = "V5R1" return client @@ -39,89 +39,53 @@ def _make_wallet(balance_nanotons: int) -> MagicMock: return wallet -@pytest.mark.asyncio -async def test_sufficient_balance_broadcasts() -> None: - # 0.5 TON amount + 0.056 TON gas = 0.556 TON required; wallet has 1 TON - client = _make_client() - wallet = _make_wallet(balance_nanotons=1_000_000_000) - +@contextmanager +def _patch_wallet(wallet: MagicMock): with ( patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, - patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"), ): mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None) + yield - result = await process_transaction(client, TRANSACTION_DATA) +@pytest.mark.asyncio +async def test_sufficient_balance_broadcasts() -> None: + wallet = _make_wallet(balance_nanotons=1_000_000_000) # 1 TON, needs 0.556 TON + with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"): + result = await process_transaction(_make_client(), TRANSACTION_DATA) assert result == "abc123" wallet.transfer.assert_called_once() @pytest.mark.asyncio -async def test_insufficient_balance_raises_wallet_error() -> None: - # wallet has 0.1 TON, needs 0.556 TON - client = _make_client() - wallet = _make_wallet(balance_nanotons=100_000_000) - - with ( - patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, - patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, - ): - mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) - mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) - mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None) - +async def test_insufficient_balance_raises() -> None: + wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, needs 0.556 TON + with _patch_wallet(wallet): with pytest.raises(WalletError, match="required"): - await process_transaction(client, TRANSACTION_DATA) - + await process_transaction(_make_client(), TRANSACTION_DATA) wallet.transfer.assert_not_called() @pytest.mark.asyncio -async def test_exactly_minimum_balance_broadcasts() -> None: - # exactly amount + gas: 500_000_000 + 56_000_000 = 556_000_000 nanotons - client = _make_client() - wallet = _make_wallet(balance_nanotons=556_000_000) - - with ( - patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, - patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, - patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"), - ): - mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) - mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) - mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None) - - result = await process_transaction(client, TRANSACTION_DATA) - +async def test_exact_minimum_balance_broadcasts() -> None: + wallet = _make_wallet(balance_nanotons=556_000_000) # exactly 0.5 + 0.056 TON + with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"): + result = await process_transaction(_make_client(), TRANSACTION_DATA) assert result == "abc123" @pytest.mark.asyncio async def test_one_nanoton_below_minimum_raises() -> None: - # 556_000_000 - 1 nanoton: just below threshold - client = _make_client() - wallet = _make_wallet(balance_nanotons=555_999_999) - - with ( - patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, - patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, - ): - mock_tonapi.return_value.__aenter__ = AsyncMock(return_value=MagicMock()) - mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) - mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None) - + wallet = _make_wallet(balance_nanotons=555_999_999) # 1 nanoton below threshold + with _patch_wallet(wallet): with pytest.raises(WalletError, match="required"): - await process_transaction(client, TRANSACTION_DATA) + await process_transaction(_make_client(), TRANSACTION_DATA) @pytest.mark.asyncio -async def test_invalid_payload_raises_transaction_error() -> None: - from pyfragment.types import TransactionError - - client = _make_client() +async def test_invalid_payload_raises() -> None: with pytest.raises(TransactionError): - await process_transaction(client, {"transaction": {}}) + await process_transaction(_make_client(), {"transaction": {}}) diff --git a/tests/005_test_methods.py b/tests/005_test_methods.py index 5a7f019..563e023 100644 --- a/tests/005_test_methods.py +++ b/tests/005_test_methods.py @@ -21,45 +21,36 @@ def client() -> FragmentClient: @pytest.mark.asyncio -async def test_purchase_premium_invalid_months_raises(client: FragmentClient) -> None: +async def test_purchase_premium_invalid_months(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): await client.purchase_premium("@user", months=5) @pytest.mark.asyncio -async def test_purchase_premium_valid_months(client: FragmentClient) -> None: - """Validation passes for 3/6/12 — network error expected, not ConfigurationError.""" - for months in (3, 6, 12): - with pytest.raises(Exception) as exc_info: - await client.purchase_premium("@user", months=months) - assert not isinstance(exc_info.value, ConfigurationError) - - -@pytest.mark.asyncio -async def test_purchase_stars_amount_too_low_raises(client: FragmentClient) -> None: +async def test_purchase_stars_amount_too_low(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): await client.purchase_stars("@user", amount=49) @pytest.mark.asyncio -async def test_purchase_stars_amount_too_high_raises(client: FragmentClient) -> None: +async def test_purchase_stars_amount_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): await client.purchase_stars("@user", amount=1_000_001) @pytest.mark.asyncio -async def test_purchase_stars_float_raises(client: FragmentClient) -> None: +async def test_purchase_stars_float_amount(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type] @pytest.mark.asyncio -async def test_topup_ton_amount_zero_raises(client: FragmentClient) -> None: +async def test_topup_ton_amount_zero(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): await client.topup_ton("@user", amount=0) @pytest.mark.asyncio -async def test_topup_ton_amount_too_high_raises(client: FragmentClient) -> None: +async def test_topup_ton_amount_too_high(client: FragmentClient) -> None: with pytest.raises(ConfigurationError): await client.topup_ton("@user", amount=1_000_000_001) diff --git a/tests/006_test_methods_mock.py b/tests/006_test_methods_mock.py new file mode 100644 index 0000000..f86c69e --- /dev/null +++ b/tests/006_test_methods_mock.py @@ -0,0 +1,146 @@ +"""Tests for purchase methods with all network calls mocked.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import AdsTopupResult, PremiumResult, StarsResult, UserNotFoundError + +VALID_SEED = "abandon " * 23 + "about" +VALID_API_KEY = "A" * 68 +VALID_COOKIES = { + "stel_ssid": "x", + "stel_dt": "x", + "stel_token": "x", + "stel_ton_token": "x", +} +FAKE_HASH = "abc123" +FAKE_RECIPIENT = "recipient_token" +FAKE_REQ_ID = "req_42" +FAKE_TX_HASH = "deadbeef" * 8 +FAKE_ACCOUNT = {"address": "0:abc", "publicKey": "pub", "chain": "-239", "walletStateInit": "base64=="} +FAKE_TRANSACTION = {"transaction": {"messages": [{"address": "0:abc", "amount": "100000000", "payload": ""}]}} + + +@pytest.fixture +def client() -> FragmentClient: + return FragmentClient(seed=VALID_SEED, api_key=VALID_API_KEY, cookies=VALID_COOKIES) + + +@pytest.mark.asyncio +async def test_purchase_stars_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.stars.fragment_post", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, # searchStarsRecipient + {"req_id": FAKE_REQ_ID}, # initBuyStarsRequest + ] + ), + ), + patch("pyfragment.methods.stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.purchase_stars("testuser", amount=100) + + assert isinstance(result, StarsResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.username == "testuser" + assert result.stars == 100 + + +@pytest.mark.asyncio +async def test_purchase_stars_user_not_found(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.stars.fragment_post", AsyncMock(return_value={"found": {}})), + ): + with pytest.raises(UserNotFoundError): + await client.purchase_stars("ghost", amount=100) + + +@pytest.mark.asyncio +async def test_purchase_premium_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.premium.fragment_post", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, # searchPremiumGiftRecipient + {}, # updatePremiumState + {"req_id": FAKE_REQ_ID}, # initGiftPremiumRequest + ] + ), + ), + patch("pyfragment.methods.premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.purchase_premium("testuser", months=6) + + assert isinstance(result, PremiumResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.username == "testuser" + assert result.months == 6 + + +@pytest.mark.asyncio +async def test_purchase_premium_user_not_found(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch("pyfragment.methods.premium.fragment_post", AsyncMock(return_value={"found": {}})), + ): + with pytest.raises(UserNotFoundError): + await client.purchase_premium("ghost", months=3) + + +@pytest.mark.asyncio +async def test_topup_ton_success(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.ton.fragment_post", + AsyncMock( + side_effect=[ + {}, # updateAdsTopupState + {"found": {"recipient": FAKE_RECIPIENT}}, # searchAdsTopupRecipient + {"req_id": FAKE_REQ_ID}, # initAdsTopupRequest + ] + ), + ), + patch("pyfragment.methods.ton.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.ton.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), + ): + result = await client.topup_ton("testuser", amount=10) + + assert isinstance(result, AdsTopupResult) + assert result.transaction_id == FAKE_TX_HASH + assert result.username == "testuser" + assert result.amount == 10 + + +@pytest.mark.asyncio +async def test_topup_ton_user_not_found(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.methods.ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), + patch( + "pyfragment.methods.ton.fragment_post", + AsyncMock( + side_effect=[ + {}, # updateAdsTopupState + {"found": {}}, # searchAdsTopupRecipient → not found + ] + ), + ), + ): + with pytest.raises(UserNotFoundError): + await client.topup_ton("ghost", amount=10) diff --git a/tests/conftest.py b/tests/conftest.py index 9b60a88..68c5e75 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,17 +1,16 @@ import json -from pathlib import Path +import os import pytest @pytest.fixture def cookies(): - """Load Fragment cookies from cookies.json; skip the test if unavailable.""" - cookies_path = Path(__file__).resolve().parents[1] / "cookies.json" - if not cookies_path.exists(): - pytest.skip("cookies.json not found") + """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") try: - with cookies_path.open("r", encoding="utf-8") as f: - return json.load(f) + return json.loads(raw) except Exception as exc: pytest.skip(f"Cookies unavailable — {exc}")