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
This commit is contained in:
bohd4nx
2026-03-20 20:16:43 +02:00
parent c5edfad06f
commit 3e14a01c92
22 changed files with 335 additions and 309 deletions
+7 -6
View File
@@ -10,6 +10,8 @@ jobs:
lint: lint:
name: Lint & Format name: Lint & Format
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v6.0.2
@@ -26,9 +28,14 @@ jobs:
- run: black --check . --target-version py312 - run: black --check . --target-version py312
- run: mypy pyfragment
test: test:
name: Tests name: Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
COOKIES_JSON: ${{ secrets.COOKIES_JSON }}
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v6.0.2
@@ -41,10 +48,4 @@ jobs:
- run: uv pip install --system ".[dev]" - 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 - run: pytest
+8
View File
@@ -11,6 +11,8 @@ jobs:
name: Version Check name: Version Check
if: github.event.workflow_run.conclusion == 'success' if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
outputs: outputs:
version: ${{ steps.version.outputs.value }} version: ${{ steps.version.outputs.value }}
is-new: ${{ steps.tag.outputs.is-new }} is-new: ${{ steps.tag.outputs.is-new }}
@@ -40,6 +42,8 @@ jobs:
needs: version-check needs: version-check
if: needs.version-check.outputs.is-new == 'true' if: needs.version-check.outputs.is-new == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps: steps:
- uses: actions/checkout@v6.0.2 - uses: actions/checkout@v6.0.2
@@ -61,6 +65,8 @@ jobs:
name: Publish to PyPI name: Publish to PyPI
needs: [version-check, build] needs: [version-check, build]
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
environment: environment:
name: pypi name: pypi
url: https://pypi.org/project/pyfragment/ url: https://pypi.org/project/pyfragment/
@@ -79,6 +85,8 @@ jobs:
name: GitHub Release name: GitHub Release
needs: [version-check, build] needs: [version-check, build]
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions: permissions:
contents: write contents: write
+67 -115
View File
@@ -22,36 +22,58 @@
--- ---
## ✨ Features ## Installation
- 💰 **TON Advertisement Topups** — Top up Telegram Ads balance (11,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 (501,000,000 Stars)
- 🔐 **Multi-wallet support** — V4R2 and V5R1 wallet contract versions
-**Async-first** — Built on `httpx` and `asyncio`
---
## 📦 Installation
```bash ```bash
pip install pyfragment 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 ```python
import asyncio 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( async with FragmentClient(
seed="word1 word2 ... word24", seed="word1 word2 ... word24", # 24-word TON wallet mnemonic
api_key="YOUR_TONAPI_KEY", api_key="YOUR_TONAPI_KEY", # from tonconsole.com
cookies={ cookies={
"stel_ssid": "...", "stel_ssid": "...",
"stel_dt": "...", "stel_dt": "...",
@@ -59,110 +81,40 @@ async def main():
"stel_ton_token": "...", "stel_ton_token": "...",
}, },
) as client: ) as client:
# Purchase 6 months of Telegram Premium try:
result = await client.purchase_premium("@username", months=6) # Purchase 6 months of Telegram Premium for a user
print(result.transaction_id) 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 # Purchase 500 Stars for a user (501 000 000)
result = await client.purchase_stars("@username", amount=500) result = await client.purchase_stars("@username", amount=500)
print(result.transaction_id) 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()) 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`: 501,000,000 |
| `topup_ton(username, amount, show_sender=True)` | `AdsTopupResult` | Top up Telegram Ads balance | `amount`: 11,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}")
```
--- ---
<div align="center"> <div align="center">
+8 -12
View File
@@ -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. Cookies can be passed as a dict or as a JSON string.
wallet_version defaults to "V5R1" — change to "V4R2" for older wallets. wallet_version defaults to "V5R1" — change to "V4R2" for older wallets.
@@ -9,7 +9,7 @@ import asyncio
from pyfragment import FragmentClient 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" API_KEY = "YOUR_TONAPI_KEY"
COOKIES = { COOKIES = {
"stel_ssid": "YOUR_STEL_SSID", "stel_ssid": "YOUR_STEL_SSID",
@@ -20,20 +20,16 @@ COOKIES = {
async def main() -> None: async def main() -> None:
client = FragmentClient( async with FragmentClient(
seed=SEED, seed=SEED,
api_key=API_KEY, api_key=API_KEY,
cookies=COOKIES, cookies=COOKIES,
wallet_version="V5R1", # or "V4R2" wallet_version="V5R1", # or "V4R2"
) ) as client:
wallet = await client.get_wallet()
wallet = await client.get_wallet() print(f"Address: {wallet.address}")
print(f"State: {wallet.state}")
print("FragmentClient initialized") print(f"Balance: {wallet.balance} TON")
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))
if __name__ == "__main__": if __name__ == "__main__":
+10 -15
View File
@@ -23,22 +23,17 @@ MONTHS = 3 # 3, 6, or 12
async def main() -> None: 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: print(f"{result.months} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}")
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))
if __name__ == "__main__": if __name__ == "__main__":
+10 -15
View File
@@ -23,22 +23,17 @@ AMOUNT = 500 # 501 000 000
async def main() -> None: 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: print(f"{result.stars} Stars successfully sent to {result.username} | tx: {result.transaction_id}")
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))
if __name__ == "__main__": if __name__ == "__main__":
+13 -18
View File
@@ -23,25 +23,20 @@ AMOUNT = 10 # TON, integer — 11 000 000 000
async def main() -> None: 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: print(f"{result.amount} TON successfully sent to {result.username} | tx: {result.transaction_id}")
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))
if __name__ == "__main__": if __name__ == "__main__":
+4 -3
View File
@@ -4,16 +4,14 @@ from pyfragment.methods.premium import purchase_premium
from pyfragment.methods.stars import purchase_stars from pyfragment.methods.stars import purchase_stars
from pyfragment.methods.ton import topup_ton from pyfragment.methods.ton import topup_ton
from pyfragment.types import ( from pyfragment.types import (
REQUIRED_COOKIE_KEYS,
SUPPORTED_WALLET_VERSIONS,
AdsTopupResult, AdsTopupResult,
ConfigurationError, ConfigurationError,
CookieError, CookieError,
PremiumResult, PremiumResult,
StarsResult, StarsResult,
WalletInfo, WalletInfo,
WalletVersion,
) )
from pyfragment.types.constants import DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS, SUPPORTED_WALLET_VERSIONS, WalletVersion
from pyfragment.utils.wallet import get_wallet_info 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. api_key: Tonapi API key — get one at https://tonconsole.com.
cookies: Fragment session cookies as a dict or JSON string. cookies: Fragment session cookies as a dict or JSON string.
wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default). wallet_version: Wallet contract version — ``"V4R2"`` or ``"V5R1"`` (default).
timeout: HTTP request timeout in seconds. Defaults to ``30.0``.
Raises: Raises:
ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid. ConfigurationError: If ``seed``, ``api_key``, or ``wallet_version`` are missing or invalid.
@@ -53,6 +52,7 @@ class FragmentClient:
api_key: str, api_key: str,
cookies: dict | str, cookies: dict | str,
wallet_version: str = "V5R1", wallet_version: str = "V5R1",
timeout: float = DEFAULT_TIMEOUT,
) -> None: ) -> None:
missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()] missing = [name for name, val in (("seed", seed), ("api_key", api_key)) if not val or not str(val).strip()]
if missing: if missing:
@@ -87,6 +87,7 @@ class FragmentClient:
self.api_key: str = api_key.strip() self.api_key: str = api_key.strip()
self.cookies: dict = cookies self.cookies: dict = cookies
self.wallet_version: WalletVersion = version # type: ignore[assignment] self.wallet_version: WalletVersion = version # type: ignore[assignment]
self.timeout: float = timeout
async def __aenter__(self) -> "FragmentClient": async def __aenter__(self) -> "FragmentClient":
return self return self
+3 -5
View File
@@ -5,9 +5,6 @@ from typing import TYPE_CHECKING
import httpx import httpx
from pyfragment.types import ( from pyfragment.types import (
BASE_HEADERS,
DEVICE,
PREMIUM_PAGE,
ConfigurationError, ConfigurationError,
FragmentAPIError, FragmentAPIError,
FragmentError, FragmentError,
@@ -15,6 +12,7 @@ from pyfragment.types import (
UnexpectedError, UnexpectedError,
UserNotFoundError, UserNotFoundError,
) )
from pyfragment.types.constants import BASE_HEADERS, DEVICE, PREMIUM_PAGE
from pyfragment.utils import ( from pyfragment.utils import (
execute_transaction_request, execute_transaction_request,
fragment_post, fragment_post,
@@ -94,10 +92,10 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int,
raise ConfigurationError(ConfigurationError.INVALID_MONTHS) raise ConfigurationError(ConfigurationError.INVALID_MONTHS)
try: 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) 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) recipient = await _search_recipient(session, fragment_hash, username, months)
req_id = await _init_request(session, fragment_hash, recipient, months) req_id = await _init_request(session, fragment_hash, recipient, months)
+3 -5
View File
@@ -4,9 +4,6 @@ from typing import TYPE_CHECKING
import httpx import httpx
from pyfragment.types import ( from pyfragment.types import (
BASE_HEADERS,
DEVICE,
STARS_PAGE,
ConfigurationError, ConfigurationError,
FragmentAPIError, FragmentAPIError,
FragmentError, FragmentError,
@@ -14,6 +11,7 @@ from pyfragment.types import (
UnexpectedError, UnexpectedError,
UserNotFoundError, UserNotFoundError,
) )
from pyfragment.types.constants import BASE_HEADERS, DEVICE, STARS_PAGE
from pyfragment.utils import ( from pyfragment.utils import (
execute_transaction_request, execute_transaction_request,
fragment_post, fragment_post,
@@ -81,10 +79,10 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s
raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT) raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT)
try: 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) 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) recipient = await _search_recipient(session, fragment_hash, username)
req_id = await _init_request(session, fragment_hash, recipient, amount) req_id = await _init_request(session, fragment_hash, recipient, amount)
+3 -5
View File
@@ -4,9 +4,6 @@ from typing import TYPE_CHECKING
import httpx import httpx
from pyfragment.types import ( from pyfragment.types import (
BASE_HEADERS,
DEVICE,
TON_PAGE,
AdsTopupResult, AdsTopupResult,
ConfigurationError, ConfigurationError,
FragmentAPIError, FragmentAPIError,
@@ -14,6 +11,7 @@ from pyfragment.types import (
UnexpectedError, UnexpectedError,
UserNotFoundError, UserNotFoundError,
) )
from pyfragment.types.constants import BASE_HEADERS, DEVICE, TON_PAGE
from pyfragment.utils import ( from pyfragment.utils import (
execute_transaction_request, execute_transaction_request,
fragment_post, fragment_post,
@@ -81,10 +79,10 @@ async def topup_ton(client: "FragmentClient", username: str, amount: int, show_s
raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT)
try: 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) 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) recipient = await _search_recipient(session, fragment_hash, username)
req_id = await _init_request(session, fragment_hash, recipient, amount) req_id = await _init_request(session, fragment_hash, recipient, amount)
-23
View File
@@ -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 ( from pyfragment.types.exceptions import (
ClientError, ClientError,
ConfigurationError, ConfigurationError,
@@ -28,17 +16,6 @@ from pyfragment.types.exceptions import (
from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult, WalletInfo from pyfragment.types.results import AdsTopupResult, PremiumResult, StarsResult, WalletInfo
__all__ = [ __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 # client exceptions
"ClientError", "ClientError",
"ConfigurationError", "ConfigurationError",
+3
View File
@@ -13,6 +13,9 @@ WALLET_CLASSES: dict[str, type] = {"V4R2": WalletV4R2, "V5R1": WalletV5R1}
# Minimum wallet balance required to cover TON network gas fees. # Minimum wallet balance required to cover TON network gas fees.
MIN_TON_BALANCE: float = 0.056 MIN_TON_BALANCE: float = 0.056
# Default HTTP request timeout in seconds.
DEFAULT_TIMEOUT: float = 30.0
# Required Fragment session cookie keys # Required Fragment session cookie keys
REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token")
+3
View File
@@ -16,6 +16,9 @@ class ConfigurationError(ClientError):
INVALID_MONTHS = "Invalid duration. Choose 3, 6, or 12 months." 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_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_TON_AMOUNT = "Amount must be an integer between 1 and 1 000 000 000 TON."
INVALID_USERNAME = (
"Invalid username '{username}'. Must be 532 characters: letters (AZ, az), digits (09), or underscores (_)."
)
class CookieError(ClientError): class CookieError(ClientError):
+4 -1
View File
@@ -4,12 +4,14 @@ from typing import Any
import httpx import httpx
from pyfragment.types import FragmentPageError, ParseError, VerificationError from pyfragment.types import FragmentPageError, ParseError, VerificationError
from pyfragment.types.constants import DEFAULT_TIMEOUT
async def get_fragment_hash( async def get_fragment_hash(
cookies: dict[str, Any], cookies: dict[str, Any],
headers: dict[str, str], headers: dict[str, str],
page_url: str, page_url: str,
timeout: float = DEFAULT_TIMEOUT,
) -> str: ) -> str:
"""Fetch the API hash from a Fragment page. """Fetch the API hash from a Fragment page.
@@ -21,6 +23,7 @@ async def get_fragment_hash(
cookies: Active Fragment session cookies. cookies: Active Fragment session cookies.
headers: Base headers for the relevant Fragment page. headers: Base headers for the relevant Fragment page.
page_url: URL of the Fragment page to fetch the hash from. page_url: URL of the Fragment page to fetch the hash from.
timeout: HTTP request timeout in seconds. Defaults to ``DEFAULT_TIMEOUT``.
Returns: Returns:
Lowercase hex hash string. 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) response = await session.get(page_url, headers=page_headers)
if response.status_code != 200: if response.status_code != 200:
+2 -1
View File
@@ -6,7 +6,8 @@ from tonutils.clients import TonapiClient
from tonutils.exceptions import ProviderResponseError from tonutils.exceptions import ProviderResponseError
from tonutils.types import NetworkGlobalID 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.types.results import WalletInfo
from pyfragment.utils.decoder import clean_decode from pyfragment.utils.decoder import clean_decode
+2
View File
@@ -34,6 +34,8 @@ dependencies = [
dev = [ dev = [
"pytest==9.0.2", "pytest==9.0.2",
"pytest-asyncio==1.3.0", "pytest-asyncio==1.3.0",
"pytest-mock",
"mypy",
"ruff", "ruff",
"black", "black",
] ]
+1 -1
View File
@@ -5,7 +5,7 @@ import re
import pytest 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 from pyfragment.utils import get_fragment_hash
+26 -62
View File
@@ -1,21 +1,21 @@
"""Unit tests for process_transaction() — balance checks before broadcast.""" """Unit tests for process_transaction() — balance checks before broadcast."""
from contextlib import contextmanager
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from pyfragment.types import WalletError from pyfragment.types import TransactionError, WalletError
from pyfragment.utils.wallet import process_transaction from pyfragment.utils.wallet import process_transaction
VALID_SEED = "abandon " * 23 + "about" VALID_SEED = "abandon " * 23 + "about"
# Minimal transaction payload: 0.5 TON = 500_000_000 nanotons
TRANSACTION_DATA = { TRANSACTION_DATA = {
"transaction": { "transaction": {
"messages": [ "messages": [
{ {
"address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a", "address": "0:852443f8599fe6a5da34fe43049ac4e0beb3071bb2bfb56635ea9421287c283a",
"amount": "500000000", "amount": "500000000", # 0.5 TON
"payload": "", "payload": "",
} }
] ]
@@ -23,9 +23,9 @@ TRANSACTION_DATA = {
} }
def _make_client(api_key: str = "test_key") -> MagicMock: def _make_client() -> MagicMock:
client = MagicMock() client = MagicMock()
client.api_key = api_key client.api_key = "test_key"
client.seed = VALID_SEED.split() client.seed = VALID_SEED.split()
client.wallet_version = "V5R1" client.wallet_version = "V5R1"
return client return client
@@ -39,89 +39,53 @@ def _make_wallet(balance_nanotons: int) -> MagicMock:
return wallet return wallet
@pytest.mark.asyncio @contextmanager
async def test_sufficient_balance_broadcasts() -> None: def _patch_wallet(wallet: MagicMock):
# 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)
with ( with (
patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi, patch("pyfragment.utils.wallet.TonapiClient") as mock_tonapi,
patch("pyfragment.utils.wallet.WALLET_CLASSES") as mock_classes, 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.__aenter__ = AsyncMock(return_value=MagicMock())
mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False) mock_tonapi.return_value.__aexit__ = AsyncMock(return_value=False)
mock_classes["V5R1"].from_mnemonic.return_value = (wallet, MagicMock(), None, None) 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" assert result == "abc123"
wallet.transfer.assert_called_once() wallet.transfer.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_insufficient_balance_raises_wallet_error() -> None: async def test_insufficient_balance_raises() -> None:
# wallet has 0.1 TON, needs 0.556 TON wallet = _make_wallet(balance_nanotons=100_000_000) # 0.1 TON, needs 0.556 TON
client = _make_client() with _patch_wallet(wallet):
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)
with pytest.raises(WalletError, match="required"): with pytest.raises(WalletError, match="required"):
await process_transaction(client, TRANSACTION_DATA) await process_transaction(_make_client(), TRANSACTION_DATA)
wallet.transfer.assert_not_called() wallet.transfer.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_exactly_minimum_balance_broadcasts() -> None: async def test_exact_minimum_balance_broadcasts() -> None:
# exactly amount + gas: 500_000_000 + 56_000_000 = 556_000_000 nanotons wallet = _make_wallet(balance_nanotons=556_000_000) # exactly 0.5 + 0.056 TON
client = _make_client() with _patch_wallet(wallet), patch("pyfragment.utils.wallet.clean_decode", return_value="50 Telegram Stars"):
wallet = _make_wallet(balance_nanotons=556_000_000) result = await process_transaction(_make_client(), TRANSACTION_DATA)
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)
assert result == "abc123" assert result == "abc123"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_one_nanoton_below_minimum_raises() -> None: async def test_one_nanoton_below_minimum_raises() -> None:
# 556_000_000 - 1 nanoton: just below threshold wallet = _make_wallet(balance_nanotons=555_999_999) # 1 nanoton below threshold
client = _make_client() with _patch_wallet(wallet):
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)
with pytest.raises(WalletError, match="required"): with pytest.raises(WalletError, match="required"):
await process_transaction(client, TRANSACTION_DATA) await process_transaction(_make_client(), TRANSACTION_DATA)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_invalid_payload_raises_transaction_error() -> None: async def test_invalid_payload_raises() -> None:
from pyfragment.types import TransactionError
client = _make_client()
with pytest.raises(TransactionError): with pytest.raises(TransactionError):
await process_transaction(client, {"transaction": {}}) await process_transaction(_make_client(), {"transaction": {}})
+6 -15
View File
@@ -21,45 +21,36 @@ def client() -> FragmentClient:
@pytest.mark.asyncio @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): with pytest.raises(ConfigurationError):
await client.purchase_premium("@user", months=5) await client.purchase_premium("@user", months=5)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_purchase_premium_valid_months(client: FragmentClient) -> None: async def test_purchase_stars_amount_too_low(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:
with pytest.raises(ConfigurationError): with pytest.raises(ConfigurationError):
await client.purchase_stars("@user", amount=49) await client.purchase_stars("@user", amount=49)
@pytest.mark.asyncio @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): with pytest.raises(ConfigurationError):
await client.purchase_stars("@user", amount=1_000_001) await client.purchase_stars("@user", amount=1_000_001)
@pytest.mark.asyncio @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): with pytest.raises(ConfigurationError):
await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type] await client.purchase_stars("@user", amount=100.5) # type: ignore[arg-type]
@pytest.mark.asyncio @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): with pytest.raises(ConfigurationError):
await client.topup_ton("@user", amount=0) await client.topup_ton("@user", amount=0)
@pytest.mark.asyncio @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): with pytest.raises(ConfigurationError):
await client.topup_ton("@user", amount=1_000_000_001) await client.topup_ton("@user", amount=1_000_000_001)
+146
View File
@@ -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)
+6 -7
View File
@@ -1,17 +1,16 @@
import json import json
from pathlib import Path import os
import pytest import pytest
@pytest.fixture @pytest.fixture
def cookies(): def cookies():
"""Load Fragment cookies from cookies.json; skip the test if unavailable.""" """Load Fragment cookies from COOKIES_JSON env var; skip if unavailable."""
cookies_path = Path(__file__).resolve().parents[1] / "cookies.json" raw = os.environ.get("COOKIES_JSON")
if not cookies_path.exists(): if not raw:
pytest.skip("cookies.json not found") pytest.skip("COOKIES_JSON env var not set")
try: try:
with cookies_path.open("r", encoding="utf-8") as f: return json.loads(raw)
return json.load(f)
except Exception as exc: except Exception as exc:
pytest.skip(f"Cookies unavailable — {exc}") pytest.skip(f"Cookies unavailable — {exc}")