mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-25 06:14:29 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -22,36 +22,58 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## Installation
|
||||||
|
|
||||||
- 💰 **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
|
|
||||||
|
|
||||||
```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 (50–1 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`: 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}")
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|||||||
+8
-12
@@ -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__":
|
||||||
|
|||||||
@@ -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
@@ -23,22 +23,17 @@ AMOUNT = 500 # 50–1 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
@@ -23,25 +23,20 @@ AMOUNT = 10 # TON, integer — 1–1 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,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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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 5–32 characters: letters (A–Z, a–z), digits (0–9), or underscores (_)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CookieError(ClientError):
|
class CookieError(ClientError):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
@@ -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": {}})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user