From 733d138fccb16008e1dccea5189cfaac3b863899 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Tue, 14 Apr 2026 00:12:22 +0300 Subject: [PATCH] feat: Enhance cookie handling and browser support - Added new exceptions in `exceptions.py` for unsupported browsers and cookie read failures. - Introduced `get_cookies_from_browser` function in `cookies.py` to extract session cookies from various browsers. - Modified tests to cover new cookie extraction functionality, including success and failure cases. - Refactored existing tests to use `patch.object` for mocking client calls. - Updated dependencies in `pyproject.toml` to include `rookiepy` for cookie extraction. --- .github/workflows/ci.yml | 2 +- pyfragment/__init__.py | 2 + pyfragment/methods/anonymous_number.py | 78 ++++++++---------- pyfragment/methods/giveaway_premium.py | 98 +++++++---------------- pyfragment/methods/giveaway_stars.py | 90 ++++++--------------- pyfragment/methods/purchase_premium.py | 102 ++++++------------------ pyfragment/methods/purchase_stars.py | 85 +++++--------------- pyfragment/methods/recharge_ads.py | 61 ++++---------- pyfragment/methods/search_gifts.py | 10 +-- pyfragment/methods/search_numbers.py | 10 +-- pyfragment/methods/search_usernames.py | 10 +-- pyfragment/methods/topup_ton.py | 87 ++++++-------------- pyfragment/types/constants.py | 24 +++++- pyfragment/types/exceptions.py | 8 ++ pyfragment/utils/__init__.py | 2 + pyfragment/utils/cookies.py | 45 +++++++++++ pyfragment/utils/decoder.py | 2 +- pyfragment/utils/wallet.py | 2 +- pyproject.toml | 5 +- tests/005_test_stars.py | 48 +++++------ tests/006_test_premium.py | 41 +++++----- tests/007_test_topup.py | 26 +++--- tests/010_test_number.py | 105 +++++++++---------------- tests/011_test_recharge_ads.py | 12 +-- tests/012_test_usernames.py | 56 ++++--------- tests/013_test_numbers.py | 56 ++++--------- tests/014_test_gifts.py | 78 +++++------------- tests/015_test_cookies.py | 99 +++++++++++++++++++++++ 28 files changed, 507 insertions(+), 737 deletions(-) create mode 100644 pyfragment/utils/cookies.py create mode 100644 tests/015_test_cookies.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dd09d8..04ee292 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: with: python-version: "3.12" - - uses: astral-sh/setup-uv@v7.6.0 + - uses: astral-sh/setup-uv@v8.0.0 - run: uv pip install --system ".[dev]" diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index 495897d..53073f7 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -34,12 +34,14 @@ from pyfragment.types import ( WalletError, WalletInfo, ) +from pyfragment.utils.cookies import get_cookies_from_browser __version__: str = version("pyfragment") __all__ = [ "__version__", "FragmentClient", + "get_cookies_from_browser", "AdsRechargeResult", "AdsTopupResult", "GiftsResult", diff --git a/pyfragment/methods/anonymous_number.py b/pyfragment/methods/anonymous_number.py index b8facfe..41c317b 100644 --- a/pyfragment/methods/anonymous_number.py +++ b/pyfragment/methods/anonymous_number.py @@ -1,18 +1,14 @@ import html from typing import TYPE_CHECKING -import httpx - from pyfragment.types import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError from pyfragment.types.constants import NUMBERS_PAGE from pyfragment.types.results import LoginCodeResult, TerminateSessionsResult -from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_login_code +from pyfragment.utils import parse_login_code if TYPE_CHECKING: from pyfragment.client import FragmentClient -HEADERS: dict[str, str] = make_headers(NUMBERS_PAGE) - def _strip_plus(number: str) -> str: return number.lstrip("+") if isinstance(number, str) else number @@ -35,14 +31,11 @@ async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResu """ try: clean = _strip_plus(number) - async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout) - result = await fragment_request( - session, - fragment_hash, - HEADERS, - {"number": clean, "lt": "0", "from_app": "1", "method": "updateLoginCodes"}, - ) + result = await client.call( + "updateLoginCodes", + {"number": clean, "lt": "0", "from_app": "1"}, + page_url=NUMBERS_PAGE, + ) if result.get("html"): code, active_sessions = parse_login_code(result["html"]) @@ -71,14 +64,11 @@ async def toggle_login_codes(client: "FragmentClient", number: str, can_receive: """ try: clean = _strip_plus(number) - async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout) - result = await fragment_request( - session, - fragment_hash, - HEADERS, - {"number": clean, "can_receive": 1 if can_receive else 0, "method": "toggleLoginCodes"}, - ) + result = await client.call( + "toggleLoginCodes", + {"number": clean, "can_receive": 1 if can_receive else 0}, + page_url=NUMBERS_PAGE, + ) if result.get("error"): raise FragmentAPIError(html.unescape(result["error"])) @@ -110,39 +100,33 @@ async def terminate_sessions(client: "FragmentClient", number: str) -> Terminate """ try: clean = _strip_plus(number) - async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout) - # Step 1: initiate — Fragment returns a confirmation hash. - confirmation = await fragment_request( - session, - fragment_hash, - HEADERS, - {"number": clean, "method": "terminatePhoneSessions"}, + confirmation = await client.call( + "terminatePhoneSessions", + {"number": clean}, + page_url=NUMBERS_PAGE, + ) + + if confirmation.get("error"): + raise AnonymousNumberError( + AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(confirmation["error"])) ) - if confirmation.get("error"): - raise AnonymousNumberError( - AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(confirmation["error"])) - ) + terminate_hash = confirmation.get("terminate_hash") + if not terminate_hash: + raise AnonymousNumberError(AnonymousNumberError.NOT_OWNED.format(number=number)) - terminate_hash = confirmation.get("terminate_hash") - if not terminate_hash: - raise AnonymousNumberError(AnonymousNumberError.NOT_OWNED.format(number=number)) + result = await client.call( + "terminatePhoneSessions", + {"number": clean, "terminate_hash": terminate_hash}, + page_url=NUMBERS_PAGE, + ) - # Step 2: confirm with the hash. - result = await fragment_request( - session, - fragment_hash, - HEADERS, - {"number": clean, "terminate_hash": terminate_hash, "method": "terminatePhoneSessions"}, + if result.get("error"): + raise AnonymousNumberError( + AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(result["error"])) ) - if result.get("error"): - raise AnonymousNumberError( - AnonymousNumberError.TERMINATE_FAILED.format(number=number, error=html.unescape(result["error"])) - ) - return TerminateSessionsResult(number=number, message=result.get("msg")) except FragmentError: diff --git a/pyfragment/methods/giveaway_premium.py b/pyfragment/methods/giveaway_premium.py index 6dfe462..d152b62 100644 --- a/pyfragment/methods/giveaway_premium.py +++ b/pyfragment/methods/giveaway_premium.py @@ -1,79 +1,21 @@ import json from typing import TYPE_CHECKING -import httpx - from pyfragment.types import ( ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, UserNotFoundError, + VerificationError, ) from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE from pyfragment.types.results import PremiumGiveawayResult -from pyfragment.utils import ( - execute_transaction_request, - fragment_request, - get_account_info, - get_fragment_hash, - make_headers, - process_transaction, -) +from pyfragment.utils import get_account_info, process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient -HEADERS: dict[str, str] = make_headers(PREMIUM_GIVEAWAY_PAGE) - - -async def _search_recipient( - session: httpx.AsyncClient, - fragment_hash: str, - channel: str, - winners: int, - months: int, -) -> str: - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "query": channel, - "quantity": winners, - "months": months, - "method": "searchPremiumGiveawayRecipient", - }, - ) - recipient = result.get("found", {}).get("recipient") - if not recipient: - raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) - return recipient - - -async def _init_request( - session: httpx.AsyncClient, - fragment_hash: str, - recipient: str, - winners: int, - months: int, -) -> str: - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "recipient": recipient, - "quantity": str(winners), - "months": str(months), - "method": "initGiveawayPremiumRequest", - }, - ) - req_id = result.get("req_id") - if not req_id: - raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway")) - return req_id - async def giveaway_premium( client: "FragmentClient", @@ -105,21 +47,37 @@ async def giveaway_premium( raise ConfigurationError(ConfigurationError.INVALID_MONTHS) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_GIVEAWAY_PAGE, client.timeout) + result = await client.call( + "searchPremiumGiveawayRecipient", + {"query": channel, "quantity": winners, "months": months}, + page_url=PREMIUM_GIVEAWAY_PAGE, + ) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) + + result = await client.call( + "initGiveawayPremiumRequest", + {"recipient": recipient, "quantity": str(winners), "months": str(months)}, + page_url=PREMIUM_GIVEAWAY_PAGE, + ) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium giveaway")) + account = await get_account_info(client) - - async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: - recipient = await _search_recipient(session, fragment_hash, channel, winners, months) - req_id = await _init_request(session, fragment_hash, recipient, winners, months) - - tx_data = { + transaction = await client.call( + "getGiveawayPremiumLink", + { "account": json.dumps(account), "device": DEVICE, "transaction": 1, "id": req_id, - "method": "getGiveawayPremiumLink", - } - transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + }, + page_url=PREMIUM_GIVEAWAY_PAGE, + ) + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) tx_hash = await process_transaction(client, transaction) return PremiumGiveawayResult( diff --git a/pyfragment/methods/giveaway_stars.py b/pyfragment/methods/giveaway_stars.py index 5eb4ef8..ab70340 100644 --- a/pyfragment/methods/giveaway_stars.py +++ b/pyfragment/methods/giveaway_stars.py @@ -1,75 +1,21 @@ import json from typing import TYPE_CHECKING -import httpx - from pyfragment.types import ( ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, UserNotFoundError, + VerificationError, ) from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE from pyfragment.types.results import StarsGiveawayResult -from pyfragment.utils import ( - execute_transaction_request, - fragment_request, - get_account_info, - get_fragment_hash, - make_headers, - process_transaction, -) +from pyfragment.utils import get_account_info, process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient -HEADERS: dict[str, str] = make_headers(STARS_GIVEAWAY_PAGE) - - -async def _search_recipient( - session: httpx.AsyncClient, - fragment_hash: str, - channel: str, -) -> str: - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "query": channel, - "method": "searchStarsGiveawayRecipient", - }, - ) - recipient = result.get("found", {}).get("recipient") - if not recipient: - raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) - return recipient - - -async def _init_request( - session: httpx.AsyncClient, - fragment_hash: str, - recipient: str, - winners: int, - amount: int, -) -> str: - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "recipient": recipient, - "quantity": str(winners), - "stars": str(amount), - "method": "initGiveawayStarsRequest", - }, - ) - req_id = result.get("req_id") - if not req_id: - raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway")) - return req_id - async def giveaway_stars( client: "FragmentClient", @@ -101,21 +47,33 @@ async def giveaway_stars( raise ConfigurationError(ConfigurationError.INVALID_STARS_PER_WINNER) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_GIVEAWAY_PAGE, client.timeout) + result = await client.call("searchStarsGiveawayRecipient", {"query": channel}, page_url=STARS_GIVEAWAY_PAGE) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=channel)) + + result = await client.call( + "initGiveawayStarsRequest", + {"recipient": recipient, "quantity": str(winners), "stars": str(amount)}, + page_url=STARS_GIVEAWAY_PAGE, + ) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars giveaway")) + account = await get_account_info(client) - - async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: - recipient = await _search_recipient(session, fragment_hash, channel) - req_id = await _init_request(session, fragment_hash, recipient, winners, amount) - - tx_data = { + transaction = await client.call( + "getGiveawayStarsLink", + { "account": json.dumps(account), "device": DEVICE, "transaction": 1, "id": req_id, - "method": "getGiveawayStarsLink", - } - transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + }, + page_url=STARS_GIVEAWAY_PAGE, + ) + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) tx_hash = await process_transaction(client, transaction) return StarsGiveawayResult( diff --git a/pyfragment/methods/purchase_premium.py b/pyfragment/methods/purchase_premium.py index 6e189e5..5b81ae4 100644 --- a/pyfragment/methods/purchase_premium.py +++ b/pyfragment/methods/purchase_premium.py @@ -2,8 +2,6 @@ import json import time from typing import TYPE_CHECKING -import httpx - from pyfragment.types import ( ConfigurationError, FragmentAPIError, @@ -11,77 +9,14 @@ from pyfragment.types import ( PremiumResult, UnexpectedError, UserNotFoundError, + VerificationError, ) from pyfragment.types.constants import DEVICE, PREMIUM_PAGE -from pyfragment.utils import ( - execute_transaction_request, - fragment_request, - get_account_info, - get_fragment_hash, - make_headers, - process_transaction, -) +from pyfragment.utils import get_account_info, process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient -HEADERS: dict[str, str] = make_headers(PREMIUM_PAGE) - - -async def _search_recipient( - session: httpx.AsyncClient, - fragment_hash: str, - username: str, - months: int, -) -> str: - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "query": username, - "months": months, - "method": "searchPremiumGiftRecipient", - }, - ) - recipient = result.get("found", {}).get("recipient") - if not recipient: - raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) - return recipient - - -async def _init_request( - session: httpx.AsyncClient, - fragment_hash: str, - recipient: str, - months: int, -) -> str: - await fragment_request( - session, - fragment_hash, - HEADERS, - { - "mode": "new", - "lv": "false", - "dh": str(int(time.time())), - "method": "updatePremiumState", - }, - ) - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "recipient": recipient, - "months": months, - "method": "initGiftPremiumRequest", - }, - ) - req_id = result.get("req_id") - if not req_id: - raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase")) - return req_id - async def purchase_premium(client: "FragmentClient", username: str, months: int, show_sender: bool = True) -> PremiumResult: """Gift Telegram Premium to a user. @@ -105,22 +40,35 @@ async def purchase_premium(client: "FragmentClient", username: str, months: int, raise ConfigurationError(ConfigurationError.INVALID_MONTHS) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, PREMIUM_PAGE, client.timeout) + result = await client.call("searchPremiumGiftRecipient", {"query": username, "months": months}, page_url=PREMIUM_PAGE) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) + + await client.call( + "updatePremiumState", + {"mode": "new", "lv": "false", "dh": str(int(time.time()))}, + page_url=PREMIUM_PAGE, + ) + result = await client.call("initGiftPremiumRequest", {"recipient": recipient, "months": months}, page_url=PREMIUM_PAGE) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Premium purchase")) + account = await get_account_info(client) - - async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: - recipient = await _search_recipient(session, fragment_hash, username, months) - req_id = await _init_request(session, fragment_hash, recipient, months) - - tx_data = { + transaction = await client.call( + "getGiftPremiumLink", + { "account": json.dumps(account), "device": DEVICE, "transaction": 1, "id": req_id, "show_sender": int(show_sender), - "method": "getGiftPremiumLink", - } - transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + }, + page_url=PREMIUM_PAGE, + ) + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) tx_hash = await process_transaction(client, transaction) return PremiumResult(transaction_id=tx_hash, username=username, amount=months) diff --git a/pyfragment/methods/purchase_stars.py b/pyfragment/methods/purchase_stars.py index 21db354..be71352 100644 --- a/pyfragment/methods/purchase_stars.py +++ b/pyfragment/methods/purchase_stars.py @@ -1,8 +1,6 @@ import json from typing import TYPE_CHECKING -import httpx - from pyfragment.types import ( ConfigurationError, FragmentAPIError, @@ -10,65 +8,14 @@ from pyfragment.types import ( StarsResult, UnexpectedError, UserNotFoundError, + VerificationError, ) from pyfragment.types.constants import DEVICE, STARS_PAGE -from pyfragment.utils import ( - execute_transaction_request, - fragment_request, - get_account_info, - get_fragment_hash, - make_headers, - process_transaction, -) +from pyfragment.utils import get_account_info, process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient -HEADERS: dict[str, str] = make_headers(STARS_PAGE) - - -async def _search_recipient( - session: httpx.AsyncClient, - fragment_hash: str, - username: str, -) -> str: - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "query": username, - "quantity": "", - "method": "searchStarsRecipient", - }, - ) - recipient = result.get("found", {}).get("recipient") - if not recipient: - raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) - return recipient - - -async def _init_request( - session: httpx.AsyncClient, - fragment_hash: str, - recipient: str, - amount: int, -) -> str: - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "recipient": recipient, - "quantity": amount, - "method": "initBuyStarsRequest", - }, - ) - req_id = result.get("req_id") - if not req_id: - raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase")) - return req_id - async def purchase_stars(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> StarsResult: """Send Telegram Stars to a user. @@ -92,22 +39,30 @@ async def purchase_stars(client: "FragmentClient", username: str, amount: int, s raise ConfigurationError(ConfigurationError.INVALID_STARS_AMOUNT) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, STARS_PAGE, client.timeout) + result = await client.call("searchStarsRecipient", {"query": username, "quantity": ""}, page_url=STARS_PAGE) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) + + result = await client.call("initBuyStarsRequest", {"recipient": recipient, "quantity": amount}, page_url=STARS_PAGE) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Stars purchase")) + account = await get_account_info(client) - - async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: - recipient = await _search_recipient(session, fragment_hash, username) - req_id = await _init_request(session, fragment_hash, recipient, amount) - - tx_data = { + transaction = await client.call( + "getBuyStarsLink", + { "account": json.dumps(account), "device": DEVICE, "transaction": 1, "id": req_id, "show_sender": int(show_sender), - "method": "getBuyStarsLink", - } - transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + }, + page_url=STARS_PAGE, + ) + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) tx_hash = await process_transaction(client, transaction) return StarsResult(transaction_id=tx_hash, username=username, amount=amount) diff --git a/pyfragment/methods/recharge_ads.py b/pyfragment/methods/recharge_ads.py index 7d27669..37bf3bf 100644 --- a/pyfragment/methods/recharge_ads.py +++ b/pyfragment/methods/recharge_ads.py @@ -1,52 +1,20 @@ import json from typing import TYPE_CHECKING -import httpx - from pyfragment.types import ( ConfigurationError, FragmentAPIError, FragmentError, UnexpectedError, + VerificationError, ) from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE from pyfragment.types.results import AdsRechargeResult -from pyfragment.utils import ( - execute_transaction_request, - fragment_request, - get_account_info, - get_fragment_hash, - make_headers, - process_transaction, -) +from pyfragment.utils import get_account_info, process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient -HEADERS: dict[str, str] = make_headers(ADS_TOPUP_PAGE) - - -async def _init_request( - session: httpx.AsyncClient, - fragment_hash: str, - account: str, - amount: int, -) -> str: - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "account": account, - "amount": amount, - "method": "initAdsRechargeRequest", - }, - ) - req_id = result.get("req_id") - if not req_id: - raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Ads recharge")) - return req_id - async def recharge_ads(client: "FragmentClient", account: str, amount: int) -> AdsRechargeResult: """Add funds to your own Telegram Ads account. @@ -69,21 +37,26 @@ async def recharge_ads(client: "FragmentClient", account: str, amount: int) -> A raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, ADS_TOPUP_PAGE, client.timeout) + await client.call("updateAdsState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE) + + result = await client.call("initAdsRechargeRequest", {"account": account, "amount": amount}, page_url=ADS_TOPUP_PAGE) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="Ads recharge")) + account_info = await get_account_info(client) - - async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: - await fragment_request(session, fragment_hash, HEADERS, {"method": "updateAdsState", "mode": "new"}) - req_id = await _init_request(session, fragment_hash, account, amount) - - tx_data = { + transaction = await client.call( + "getAdsRechargeLink", + { "account": json.dumps(account_info), "device": DEVICE, "transaction": 1, "id": req_id, - "method": "getAdsRechargeLink", - } - transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + }, + page_url=ADS_TOPUP_PAGE, + ) + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) tx_hash = await process_transaction(client, transaction) return AdsRechargeResult(transaction_id=tx_hash, amount=amount) diff --git a/pyfragment/methods/search_gifts.py b/pyfragment/methods/search_gifts.py index d1276b4..f871eb1 100644 --- a/pyfragment/methods/search_gifts.py +++ b/pyfragment/methods/search_gifts.py @@ -1,17 +1,13 @@ from typing import TYPE_CHECKING, Any -import httpx - from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError from pyfragment.types.constants import GIFTS_PAGE from pyfragment.types.results import GiftsResult -from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_gift_items +from pyfragment.utils import parse_gift_items if TYPE_CHECKING: from pyfragment.client import FragmentClient -HEADERS: dict[str, str] = make_headers(GIFTS_PAGE) - async def search_gifts( client: "FragmentClient", @@ -64,9 +60,7 @@ async def search_gifts( data["offset"] = offset try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, GIFTS_PAGE, client.timeout) - async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: - result = await fragment_request(session, fragment_hash, HEADERS, data) + result = await client.call("searchAuctions", data, page_url=GIFTS_PAGE) if result.get("error"): raise FragmentAPIError(result["error"]) diff --git a/pyfragment/methods/search_numbers.py b/pyfragment/methods/search_numbers.py index e6f459a..d8af150 100644 --- a/pyfragment/methods/search_numbers.py +++ b/pyfragment/methods/search_numbers.py @@ -1,17 +1,13 @@ from typing import TYPE_CHECKING, Any -import httpx - from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError from pyfragment.types.constants import NUMBERS_PAGE from pyfragment.types.results import NumbersResult -from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_auction_rows +from pyfragment.utils import parse_auction_rows if TYPE_CHECKING: from pyfragment.client import FragmentClient -HEADERS: dict[str, str] = make_headers(NUMBERS_PAGE) - async def search_numbers( client: "FragmentClient", @@ -49,9 +45,7 @@ async def search_numbers( data["offset_id"] = offset_id try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, NUMBERS_PAGE, client.timeout) - async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: - result = await fragment_request(session, fragment_hash, HEADERS, data) + result = await client.call("searchAuctions", data, page_url=NUMBERS_PAGE) if result.get("error"): raise FragmentAPIError(result["error"]) diff --git a/pyfragment/methods/search_usernames.py b/pyfragment/methods/search_usernames.py index 23af084..83d8872 100644 --- a/pyfragment/methods/search_usernames.py +++ b/pyfragment/methods/search_usernames.py @@ -1,17 +1,13 @@ from typing import TYPE_CHECKING, Any -import httpx - from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError from pyfragment.types.constants import FRAGMENT_BASE_URL from pyfragment.types.results import UsernamesResult -from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_auction_rows +from pyfragment.utils import parse_auction_rows if TYPE_CHECKING: from pyfragment.client import FragmentClient -HEADERS: dict[str, str] = make_headers(FRAGMENT_BASE_URL) - async def search_usernames( client: "FragmentClient", @@ -49,9 +45,7 @@ async def search_usernames( data["offset_id"] = offset_id try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, FRAGMENT_BASE_URL, client.timeout) - async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: - result = await fragment_request(session, fragment_hash, HEADERS, data) + result = await client.call("searchAuctions", data, page_url=FRAGMENT_BASE_URL) if result.get("error"): raise FragmentAPIError(result["error"]) diff --git a/pyfragment/methods/topup_ton.py b/pyfragment/methods/topup_ton.py index 117ea87..e503872 100644 --- a/pyfragment/methods/topup_ton.py +++ b/pyfragment/methods/topup_ton.py @@ -1,8 +1,6 @@ import json from typing import TYPE_CHECKING -import httpx - from pyfragment.types import ( AdsTopupResult, ConfigurationError, @@ -10,65 +8,14 @@ from pyfragment.types import ( FragmentError, UnexpectedError, UserNotFoundError, + VerificationError, ) from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE -from pyfragment.utils import ( - execute_transaction_request, - fragment_request, - get_account_info, - get_fragment_hash, - make_headers, - process_transaction, -) +from pyfragment.utils import get_account_info, process_transaction if TYPE_CHECKING: from pyfragment.client import FragmentClient -HEADERS: dict[str, str] = make_headers(ADS_TOPUP_PAGE) - - -async def _search_recipient( - session: httpx.AsyncClient, - fragment_hash: str, - username: str, -) -> str: - await fragment_request(session, fragment_hash, HEADERS, {"mode": "new", "method": "updateAdsTopupState"}) - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "query": username, - "method": "searchAdsTopupRecipient", - }, - ) - recipient = result.get("found", {}).get("recipient") - if not recipient: - raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) - return recipient - - -async def _init_request( - session: httpx.AsyncClient, - fragment_hash: str, - recipient: str, - amount: int, -) -> str: - result = await fragment_request( - session, - fragment_hash, - HEADERS, - { - "recipient": recipient, - "amount": amount, - "method": "initAdsTopupRequest", - }, - ) - req_id = result.get("req_id") - if not req_id: - raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup")) - return req_id - async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: """Topup ton to recipient's Telegram balance. @@ -92,22 +39,32 @@ async def topup_ton(client: "FragmentClient", username: str, amount: int, show_s raise ConfigurationError(ConfigurationError.INVALID_TON_AMOUNT) try: - fragment_hash = await get_fragment_hash(client.cookies, HEADERS, ADS_TOPUP_PAGE, client.timeout) + await client.call("updateAdsTopupState", {"mode": "new"}, page_url=ADS_TOPUP_PAGE) + + result = await client.call("searchAdsTopupRecipient", {"query": username}, page_url=ADS_TOPUP_PAGE) + recipient = result.get("found", {}).get("recipient") + if not recipient: + raise UserNotFoundError(UserNotFoundError.NOT_FOUND.format(username=username)) + + result = await client.call("initAdsTopupRequest", {"recipient": recipient, "amount": amount}, page_url=ADS_TOPUP_PAGE) + req_id = result.get("req_id") + if not req_id: + raise FragmentAPIError(FragmentAPIError.NO_REQUEST_ID.format(context="TON topup")) + account = await get_account_info(client) - - async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: - recipient = await _search_recipient(session, fragment_hash, username) - req_id = await _init_request(session, fragment_hash, recipient, amount) - - tx_data = { + transaction = await client.call( + "getAdsTopupLink", + { "account": json.dumps(account), "device": DEVICE, "transaction": 1, "id": req_id, "show_sender": int(show_sender), - "method": "getAdsTopupLink", - } - transaction = await execute_transaction_request(session, HEADERS, tx_data, fragment_hash) + }, + page_url=ADS_TOPUP_PAGE, + ) + if transaction.get("need_verify"): + raise VerificationError(VerificationError.KYC_REQUIRED) tx_hash = await process_transaction(client, transaction) return AdsTopupResult(transaction_id=tx_hash, username=username, amount=amount) diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index fee850a..115a73c 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -19,8 +19,9 @@ DEFAULT_TIMEOUT: float = 30.0 # Required Fragment session cookie keys REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") -# Fragment page URLs -FRAGMENT_BASE_URL: str = "https://fragment.com" +# Fragment domain and page URLs +FRAGMENT_DOMAIN: str = "fragment.com" # for rookiepy +FRAGMENT_BASE_URL: str = f"https://{FRAGMENT_DOMAIN}" STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy" STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway" PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift" @@ -29,6 +30,25 @@ ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers" GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts" +# Browsers supported by get_cookies_from_browser() +SUPPORTED_BROWSERS: frozenset[str] = frozenset( + { + "arc", + "brave", + "chrome", + "chromium", + "chromium_based", + "edge", + "firefox", + "firefox_based", + "librewolf", + "opera", + "opera_gx", + "safari", + "vivaldi", + } +) + # Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. DEVICE: str = json.dumps( { diff --git a/pyfragment/types/exceptions.py b/pyfragment/types/exceptions.py index 5207a94..3027d74 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -35,6 +35,14 @@ class CookieError(ClientError): "Fragment cookies are missing or empty for key(s): {keys}. " "Open fragment.com in your browser, log in, and copy fresh cookies." ) + UNSUPPORTED_BROWSER = "Unsupported browser: '{browser}'. Supported: {supported}." + BROWSER_READ_FAILED = ( + "Failed to read {browser} cookies: {exc}. " "Make sure {browser} is installed and you are logged in to {url}." + ) + MISSING_BROWSER_KEYS = ( + "Fragment cookies not found in {browser}: {keys}. " + "Make sure you are logged in to {url} and have connected your TON wallet in {browser}." + ) class FragmentAPIError(FragmentError): diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py index a52cf8b..843a2fa 100644 --- a/pyfragment/utils/__init__.py +++ b/pyfragment/utils/__init__.py @@ -1,3 +1,4 @@ +from pyfragment.utils.cookies import get_cookies_from_browser from pyfragment.utils.decoder import clean_decode from pyfragment.utils.html import parse_auction_rows, parse_gift_items, parse_login_code from pyfragment.utils.http import ( @@ -11,6 +12,7 @@ from pyfragment.utils.wallet import get_account_info, process_transaction __all__ = [ "clean_decode", + "get_cookies_from_browser", "parse_auction_rows", "parse_gift_items", "parse_login_code", diff --git a/pyfragment/utils/cookies.py b/pyfragment/utils/cookies.py new file mode 100644 index 0000000..77f2205 --- /dev/null +++ b/pyfragment/utils/cookies.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import rookiepy + +from pyfragment.types import CookieError +from pyfragment.types.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS + + +def get_cookies_from_browser(browser: str = "chrome") -> dict[str, str]: + """Extract Fragment session cookies directly from an installed browser. + + Reads the browser's on-disk cookie store (no extension required) and + returns the four cookies required by :class:`~pyfragment.FragmentClient`. + + Args: + browser: Browser name to read cookies from — case-insensitive. Supported values: + ``"chrome"`` (default), ``"firefox"``, ``"edge"``, ``"brave"``, ``"arc"``, + ``"opera"``, ``"opera_gx"``, ``"chromium"``, ``"chromium_based"``, + ``"firefox_based"``, ``"vivaldi"``, ``"librewolf"``, ``"safari"``. + + Returns: + A dict with the four required Fragment cookie keys: + ``stel_ssid``, ``stel_dt``, ``stel_token``, ``stel_ton_token``. + + Raises: + CookieError: If the browser is not supported, cookies cannot be read, + or required keys are missing. + """ + key = browser.lower() + if key not in SUPPORTED_BROWSERS: + supported = ", ".join(sorted(SUPPORTED_BROWSERS)) + raise CookieError(CookieError.UNSUPPORTED_BROWSER.format(browser=browser, supported=supported)) + + try: + jar: list[dict] = getattr(rookiepy, key)([FRAGMENT_DOMAIN]) + except Exception as exc: + raise CookieError(CookieError.BROWSER_READ_FAILED.format(browser=browser, exc=exc, url=FRAGMENT_BASE_URL)) from exc + + cookie_map: dict[str, str] = {c["name"]: c["value"] for c in jar if c.get("name") and c.get("value")} + + missing = [k for k in REQUIRED_COOKIE_KEYS if not str(cookie_map.get(k, "")).strip()] + if missing: + raise CookieError(CookieError.MISSING_BROWSER_KEYS.format(browser=browser, keys=missing, url=FRAGMENT_BASE_URL)) + + return {k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS} diff --git a/pyfragment/utils/decoder.py b/pyfragment/utils/decoder.py index 451a7b6..5acae41 100644 --- a/pyfragment/utils/decoder.py +++ b/pyfragment/utils/decoder.py @@ -1,6 +1,6 @@ import base64 -from pytoniq_core import Cell +from ton_core import Cell from pyfragment.types import ParseError diff --git a/pyfragment/utils/wallet.py b/pyfragment/utils/wallet.py index 6587786..7abd120 100644 --- a/pyfragment/utils/wallet.py +++ b/pyfragment/utils/wallet.py @@ -3,9 +3,9 @@ import base64 import ssl from typing import TYPE_CHECKING, Any +from ton_core import NetworkGlobalID from tonutils.clients import TonapiClient from tonutils.exceptions import ProviderResponseError -from tonutils.types import NetworkGlobalID from pyfragment.types import TransactionError, WalletError from pyfragment.types.constants import MIN_TON_BALANCE, WALLET_CLASSES diff --git a/pyproject.toml b/pyproject.toml index 4655514..18204a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,12 +27,13 @@ classifiers = [ ] dependencies = [ "httpx==0.28.1", - "tonutils[pytoniq]==2.0.5", + "rookiepy==0.5.6", + "tonutils==2.1.0", ] [project.optional-dependencies] dev = [ - "pytest==9.0.2", + "pytest==9.0.3", "pytest-asyncio==1.3.0", "pytest-mock", "mypy", diff --git a/tests/005_test_stars.py b/tests/005_test_stars.py index 0a87f94..9ec952e 100644 --- a/tests/005_test_stars.py +++ b/tests/005_test_stars.py @@ -6,7 +6,7 @@ import pytest from pyfragment import FragmentClient from pyfragment.types import ConfigurationError, StarsGiveawayResult, StarsResult, UserNotFoundError -from tests.shared import FAKE_ACCOUNT, FAKE_HASH, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH +from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Stars purchase validation tests @@ -35,13 +35,18 @@ async def test_purchase_stars_float_amount(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_purchase_stars_success(client: FragmentClient) -> None: with ( - patch("pyfragment.methods.purchase_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch( - "pyfragment.methods.purchase_stars.fragment_request", - AsyncMock(side_effect=[{"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}]), + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {"req_id": FAKE_REQ_ID}, + FAKE_TRANSACTION, + ] + ), ), - patch("pyfragment.methods.purchase_stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch("pyfragment.methods.purchase_stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.purchase_stars("@user", amount=500) @@ -54,11 +59,7 @@ async def test_purchase_stars_success(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_purchase_stars_user_not_found(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.purchase_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.purchase_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch("pyfragment.methods.purchase_stars.fragment_request", AsyncMock(return_value={"found": {}})), - ): + with patch.object(client, "call", AsyncMock(return_value={"found": {}})): with pytest.raises(UserNotFoundError): await client.purchase_stars("@ghost", amount=500) @@ -108,13 +109,18 @@ async def test_giveaway_stars_float_amount(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_giveaway_stars_success(client: FragmentClient) -> None: with ( - patch("pyfragment.methods.giveaway_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.giveaway_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch( - "pyfragment.methods.giveaway_stars.fragment_request", - AsyncMock(side_effect=[{"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}]), + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {"req_id": FAKE_REQ_ID}, + FAKE_TRANSACTION, + ] + ), ), - patch("pyfragment.methods.giveaway_stars.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.giveaway_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch("pyfragment.methods.giveaway_stars.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.giveaway_stars("@channel", winners=3, amount=1000) @@ -128,10 +134,6 @@ async def test_giveaway_stars_success(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_giveaway_stars_channel_not_found(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.giveaway_stars.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.giveaway_stars.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch("pyfragment.methods.giveaway_stars.fragment_request", AsyncMock(return_value={"found": {}})), - ): + with patch.object(client, "call", AsyncMock(return_value={"found": {}})): with pytest.raises(UserNotFoundError): await client.giveaway_stars("@ghost", winners=1, amount=500) diff --git a/tests/006_test_premium.py b/tests/006_test_premium.py index 0a0b11e..54283d3 100644 --- a/tests/006_test_premium.py +++ b/tests/006_test_premium.py @@ -6,7 +6,7 @@ import pytest from pyfragment import FragmentClient from pyfragment.types import ConfigurationError, PremiumGiveawayResult, PremiumResult, UserNotFoundError -from tests.shared import FAKE_ACCOUNT, FAKE_HASH, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH +from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Premium purchase validation tests @@ -29,19 +29,19 @@ async def test_purchase_premium_months_zero(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_purchase_premium_success(client: FragmentClient) -> None: with ( - patch("pyfragment.methods.purchase_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch( - "pyfragment.methods.purchase_premium.fragment_request", + patch.object( + client, + "call", AsyncMock( side_effect=[ {"found": {"recipient": FAKE_RECIPIENT}}, {}, # updatePremiumState {"req_id": FAKE_REQ_ID}, + FAKE_TRANSACTION, ] ), ), - patch("pyfragment.methods.purchase_premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch("pyfragment.methods.purchase_premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.purchase_premium("@user", months=3) @@ -54,11 +54,7 @@ async def test_purchase_premium_success(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_purchase_premium_user_not_found(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.purchase_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.purchase_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch("pyfragment.methods.purchase_premium.fragment_request", AsyncMock(return_value={"found": {}})), - ): + with patch.object(client, "call", AsyncMock(return_value={"found": {}})): with pytest.raises(UserNotFoundError): await client.purchase_premium("@ghost", months=3) @@ -96,13 +92,18 @@ async def test_giveaway_premium_invalid_months(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_giveaway_premium_success(client: FragmentClient) -> None: with ( - patch("pyfragment.methods.giveaway_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.giveaway_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch( - "pyfragment.methods.giveaway_premium.fragment_request", - AsyncMock(side_effect=[{"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}]), + patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {"found": {"recipient": FAKE_RECIPIENT}}, + {"req_id": FAKE_REQ_ID}, + FAKE_TRANSACTION, + ] + ), ), - patch("pyfragment.methods.giveaway_premium.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.giveaway_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch("pyfragment.methods.giveaway_premium.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.giveaway_premium("@channel", winners=10, months=3) @@ -116,10 +117,6 @@ async def test_giveaway_premium_success(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_giveaway_premium_channel_not_found(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.giveaway_premium.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.giveaway_premium.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch("pyfragment.methods.giveaway_premium.fragment_request", AsyncMock(return_value={"found": {}})), - ): + with patch.object(client, "call", AsyncMock(return_value={"found": {}})): with pytest.raises(UserNotFoundError): await client.giveaway_premium("@ghost", winners=1, months=3) diff --git a/tests/007_test_topup.py b/tests/007_test_topup.py index 2b491bc..67dbd7a 100644 --- a/tests/007_test_topup.py +++ b/tests/007_test_topup.py @@ -6,7 +6,7 @@ import pytest from pyfragment import FragmentClient from pyfragment.types import AdsTopupResult, ConfigurationError, UserNotFoundError -from tests.shared import FAKE_ACCOUNT, FAKE_HASH, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH +from tests.shared import FAKE_ACCOUNT, FAKE_RECIPIENT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # Topup TON validation tests @@ -35,19 +35,19 @@ async def test_topup_ton_float_amount(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_topup_ton_success(client: FragmentClient) -> None: with ( - patch("pyfragment.methods.topup_ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch( - "pyfragment.methods.topup_ton.fragment_request", + patch.object( + client, + "call", AsyncMock( side_effect=[ {}, # updateAdsTopupState {"found": {"recipient": FAKE_RECIPIENT}}, {"req_id": FAKE_REQ_ID}, + FAKE_TRANSACTION, ] ), ), - patch("pyfragment.methods.topup_ton.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch("pyfragment.methods.topup_ton.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.topup_ton("@user", amount=10) @@ -60,12 +60,14 @@ async def test_topup_ton_success(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_topup_ton_user_not_found(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.topup_ton.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.topup_ton.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch( - "pyfragment.methods.topup_ton.fragment_request", - AsyncMock(side_effect=[{}, {"found": {}}]), + with patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {}, # updateAdsTopupState + {"found": {}}, + ] ), ): with pytest.raises(UserNotFoundError): diff --git a/tests/010_test_number.py b/tests/010_test_number.py index bdcdb02..78c50eb 100644 --- a/tests/010_test_number.py +++ b/tests/010_test_number.py @@ -5,20 +5,14 @@ from unittest.mock import AsyncMock, patch import pytest from pyfragment import AnonymousNumberError, FragmentClient, LoginCodeResult, TerminateSessionsResult -from tests.shared import FAKE_HASH, FAKE_HTML_NO_CODE, FAKE_HTML_WITH_CODE, FAKE_TERMINATE_HASH +from tests.shared import FAKE_HTML_NO_CODE, FAKE_HTML_WITH_CODE, FAKE_TERMINATE_HASH -# get_login_code tests +# get_login_code mocked tests @pytest.mark.asyncio async def test_get_login_code_returns_code(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.anonymous_number.fragment_request", - AsyncMock(return_value={"html": FAKE_HTML_WITH_CODE}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"html": FAKE_HTML_WITH_CODE})): result = await client.get_login_code("+1234567890") assert isinstance(result, LoginCodeResult) @@ -29,13 +23,7 @@ async def test_get_login_code_returns_code(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_get_login_code_no_pending_code(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.anonymous_number.fragment_request", - AsyncMock(return_value={"html": FAKE_HTML_NO_CODE}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"html": FAKE_HTML_NO_CODE})): result = await client.get_login_code("1234567890") assert result.code is None @@ -44,31 +32,26 @@ async def test_get_login_code_no_pending_code(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_get_login_code_no_html_returns_none(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={})), - ): + with patch.object(client, "call", AsyncMock(return_value={})): result = await client.get_login_code("+1234567890") assert result.code is None assert result.active_sessions == 0 -# terminate_sessions tests +# terminate_sessions mocked tests @pytest.mark.asyncio async def test_terminate_sessions_success(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.anonymous_number.fragment_request", - AsyncMock( - side_effect=[ - {"terminate_hash": FAKE_TERMINATE_HASH}, # step 1: confirmation - {"msg": "All sessions terminated"}, # step 2: confirmed - ] - ), + with patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {"terminate_hash": FAKE_TERMINATE_HASH}, # step 1: confirmation + {"msg": "All sessions terminated"}, # step 2: confirmed + ] ), ): result = await client.terminate_sessions("+1234567890") @@ -80,84 +63,66 @@ async def test_terminate_sessions_success(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_terminate_sessions_not_owned_raises(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={})), - ): + with patch.object(client, "call", AsyncMock(return_value={})): with pytest.raises(AnonymousNumberError, match="not associated"): await client.terminate_sessions("+1234567890") @pytest.mark.asyncio async def test_terminate_sessions_api_error_raises(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.anonymous_number.fragment_request", - AsyncMock(return_value={"error": "SESSION_ALREADY_TERMINATED"}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"error": "SESSION_ALREADY_TERMINATED"})): with pytest.raises(AnonymousNumberError, match="SESSION_ALREADY_TERMINATED"): await client.terminate_sessions("+1234567890") @pytest.mark.asyncio async def test_terminate_sessions_confirm_error_raises(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.anonymous_number.fragment_request", - AsyncMock( - side_effect=[ - {"terminate_hash": FAKE_TERMINATE_HASH}, - {"error": "INTERNAL_ERROR"}, - ] - ), + with patch.object( + client, + "call", + AsyncMock( + side_effect=[ + {"terminate_hash": FAKE_TERMINATE_HASH}, + {"error": "INTERNAL_ERROR"}, + ] ), ): with pytest.raises(AnonymousNumberError, match="INTERNAL_ERROR"): await client.terminate_sessions("+1234567890") -# toggle_login_codes tests +# toggle_login_codes mocked tests @pytest.mark.asyncio async def test_toggle_login_codes_enable(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={"ok": True})) as mock_req, - ): + mock_call = AsyncMock(return_value={"ok": True}) + with patch.object(client, "call", mock_call): await client.toggle_login_codes("+1234567890", can_receive=True) - call_data = mock_req.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["can_receive"] == 1 - assert call_data["method"] == "toggleLoginCodes" @pytest.mark.asyncio async def test_toggle_login_codes_disable(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={"ok": True})) as mock_req, - ): + mock_call = AsyncMock(return_value={"ok": True}) + with patch.object(client, "call", mock_call): await client.toggle_login_codes("+1234567890", can_receive=False) - call_data = mock_req.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["can_receive"] == 0 -# strip_plus tests +# strip_plus mocked tests @pytest.mark.asyncio async def test_get_login_code_strips_plus(client: FragmentClient) -> None: """Number passed with '+' is stripped before the API call.""" - with ( - patch("pyfragment.methods.anonymous_number.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.anonymous_number.fragment_request", AsyncMock(return_value={})) as mock_req, - ): + mock_call = AsyncMock(return_value={}) + with patch.object(client, "call", mock_call): await client.get_login_code("+1234567890") - call_data = mock_req.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["number"] == "1234567890" diff --git a/tests/011_test_recharge_ads.py b/tests/011_test_recharge_ads.py index e1bf593..2c9300c 100644 --- a/tests/011_test_recharge_ads.py +++ b/tests/011_test_recharge_ads.py @@ -6,7 +6,7 @@ import pytest from pyfragment import FragmentClient from pyfragment.types import AdsRechargeResult, ConfigurationError -from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_HASH, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH +from tests.shared import FAKE_ACCOUNT, FAKE_ADS_ACCOUNT, FAKE_REQ_ID, FAKE_TRANSACTION, FAKE_TX_HASH # recharge_ads validation tests @@ -35,18 +35,18 @@ async def test_recharge_ads_float_amount(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_recharge_ads_success(client: FragmentClient) -> None: with ( - patch("pyfragment.methods.recharge_ads.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch("pyfragment.methods.recharge_ads.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), - patch( - "pyfragment.methods.recharge_ads.fragment_request", + patch.object( + client, + "call", AsyncMock( side_effect=[ {}, # updateAdsState {"req_id": FAKE_REQ_ID}, # initAdsRechargeRequest + FAKE_TRANSACTION, # getAdsRechargeLink ] ), ), - patch("pyfragment.methods.recharge_ads.execute_transaction_request", AsyncMock(return_value=FAKE_TRANSACTION)), + patch("pyfragment.methods.recharge_ads.get_account_info", AsyncMock(return_value=FAKE_ACCOUNT)), patch("pyfragment.methods.recharge_ads.process_transaction", AsyncMock(return_value=FAKE_TX_HASH)), ): result = await client.recharge_ads(FAKE_ADS_ACCOUNT, amount=10) diff --git a/tests/012_test_usernames.py b/tests/012_test_usernames.py index 97ef2fe..a7a0816 100644 --- a/tests/012_test_usernames.py +++ b/tests/012_test_usernames.py @@ -6,7 +6,6 @@ import pytest from pyfragment import FragmentClient from pyfragment.types import UsernamesResult -from tests.shared import FAKE_HASH FAKE_HTML = """ @@ -22,18 +21,12 @@ FAKE_HTML = """ """ -# search_usernames mocked tests +# search_usernames result parsing tests @pytest.mark.asyncio async def test_search_usernames_basic(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_usernames.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_HTML})): result = await client.search_usernames("coolname") assert isinstance(result, UsernamesResult) @@ -46,13 +39,7 @@ async def test_search_usernames_basic(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_search_usernames_empty_html(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_usernames.fragment_request", - AsyncMock(return_value={"ok": True}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"ok": True})): result = await client.search_usernames("zzz_no_results") assert isinstance(result, UsernamesResult) @@ -60,21 +47,18 @@ async def test_search_usernames_empty_html(client: FragmentClient) -> None: assert result.next_offset_id is None +# search_usernames parameter forwarding tests + + @pytest.mark.asyncio async def test_search_usernames_with_sort_and_filter(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_usernames.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), - ) as mock_request, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_usernames("durov", sort="price_desc", filter="auction") assert isinstance(result, UsernamesResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["type"] == "usernames" - assert call_data["method"] == "searchAuctions" assert call_data["sort"] == "price_desc" assert call_data["filter"] == "auction" assert call_data["query"] == "durov" @@ -82,33 +66,23 @@ async def test_search_usernames_with_sort_and_filter(client: FragmentClient) -> @pytest.mark.asyncio async def test_search_usernames_with_offset_id(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_usernames.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"}), - ) as mock_request, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"}) + with patch.object(client, "call", mock_call): result = await client.search_usernames("durov", offset_id="offset_10") assert isinstance(result, UsernamesResult) assert result.next_offset_id == "offset_99" - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["offset_id"] == "offset_10" @pytest.mark.asyncio async def test_search_usernames_default_query(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_usernames.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), - ) as mock_request, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_usernames() assert isinstance(result, UsernamesResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["query"] == "" assert call_data["type"] == "usernames" diff --git a/tests/013_test_numbers.py b/tests/013_test_numbers.py index daa1293..50b25d3 100644 --- a/tests/013_test_numbers.py +++ b/tests/013_test_numbers.py @@ -6,7 +6,6 @@ import pytest from pyfragment import FragmentClient from pyfragment.types import NumbersResult -from tests.shared import FAKE_HASH FAKE_HTML = """ @@ -22,18 +21,12 @@ FAKE_HTML = """ """ -# search_numbers mocked tests +# search_numbers result parsing tests @pytest.mark.asyncio async def test_search_numbers_basic(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_numbers.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_HTML})): result = await client.search_numbers("888") assert isinstance(result, NumbersResult) @@ -46,13 +39,7 @@ async def test_search_numbers_basic(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_search_numbers_empty_html(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_numbers.fragment_request", - AsyncMock(return_value={"ok": True}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"ok": True})): result = await client.search_numbers("zzz_no_results") assert isinstance(result, NumbersResult) @@ -60,21 +47,18 @@ async def test_search_numbers_empty_html(client: FragmentClient) -> None: assert result.next_offset_id is None +# search_numbers parameter forwarding tests + + @pytest.mark.asyncio async def test_search_numbers_with_sort_and_filter(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_numbers.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), - ) as mock_request, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_numbers("888", sort="price_asc", filter="sale") assert isinstance(result, NumbersResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["type"] == "numbers" - assert call_data["method"] == "searchAuctions" assert call_data["sort"] == "price_asc" assert call_data["filter"] == "sale" assert call_data["query"] == "888" @@ -82,33 +66,23 @@ async def test_search_numbers_with_sort_and_filter(client: FragmentClient) -> No @pytest.mark.asyncio async def test_search_numbers_with_offset_id(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_numbers.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_50"}), - ) as mock_request, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_50"}) + with patch.object(client, "call", mock_call): result = await client.search_numbers("888", offset_id="offset_50") assert isinstance(result, NumbersResult) assert result.next_offset_id == "offset_50" - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["offset_id"] == "offset_50" @pytest.mark.asyncio async def test_search_numbers_default_query(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_numbers.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), - ) as mock_request, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_numbers() assert isinstance(result, NumbersResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["query"] == "" assert call_data["type"] == "numbers" diff --git a/tests/014_test_gifts.py b/tests/014_test_gifts.py index fd6e72a..747dab5 100644 --- a/tests/014_test_gifts.py +++ b/tests/014_test_gifts.py @@ -6,7 +6,6 @@ import pytest from pyfragment import FragmentClient from pyfragment.types import GiftsResult -from tests.shared import FAKE_HASH FAKE_GIFTS_HTML = """
@@ -48,18 +47,12 @@ FAKE_GIFTS_HTML = """ """ -# search_gifts mocked tests +# search_gifts result parsing tests @pytest.mark.asyncio async def test_search_gifts_basic(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_gifts.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML})): result = await client.search_gifts() assert isinstance(result, GiftsResult) @@ -76,13 +69,7 @@ async def test_search_gifts_basic(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_search_gifts_empty(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_gifts.fragment_request", - AsyncMock(return_value={"ok": True}), - ), - ): + with patch.object(client, "call", AsyncMock(return_value={"ok": True})): result = await client.search_gifts(query="zzz_no_results") assert isinstance(result, GiftsResult) @@ -90,68 +77,50 @@ async def test_search_gifts_empty(client: FragmentClient) -> None: assert result.next_offset is None +# search_gifts parameter forwarding tests + + @pytest.mark.asyncio async def test_search_gifts_with_collection_and_sort(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_gifts.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), - ) as mock_request, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_gifts(collection="plushpepe", sort="price_desc", filter="sold") assert isinstance(result, GiftsResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["collection"] == "plushpepe" assert call_data["sort"] == "price_desc" assert call_data["filter"] == "sold" assert call_data["type"] == "gifts" - assert call_data["method"] == "searchAuctions" @pytest.mark.asyncio async def test_search_gifts_with_offset(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_gifts.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), - ) as mock_request, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_gifts(offset=60) assert isinstance(result, GiftsResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["offset"] == 60 @pytest.mark.asyncio async def test_search_gifts_with_view(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_gifts.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), - ) as mock_request, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_gifts(collection="artisanbrick", view="Model") assert isinstance(result, GiftsResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["view"] == "Model" assert call_data["collection"] == "artisanbrick" @pytest.mark.asyncio async def test_search_gifts_with_attr(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_gifts.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), - ) as mock_request, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_gifts( collection="artisanbrick", sort="listed", @@ -164,7 +133,7 @@ async def test_search_gifts_with_attr(client: FragmentClient) -> None: ) assert isinstance(result, GiftsResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] assert call_data["attr[Model]"] == ["Delicate Wash", "Foosball", "Chocolate"] assert call_data["attr[Backdrop]"] == ["Celtic Blue", "Carrot Juice", "Orange"] assert call_data["attr[Symbol]"] == ["Crystal Ball", "Tetsubin", "Acorn"] @@ -176,16 +145,11 @@ async def test_search_gifts_with_attr(client: FragmentClient) -> None: @pytest.mark.asyncio async def test_search_gifts_attr_not_in_data_when_none(client: FragmentClient) -> None: - with ( - patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), - patch( - "pyfragment.methods.search_gifts.fragment_request", - AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), - ) as mock_request, - ): + mock_call = AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}) + with patch.object(client, "call", mock_call): result = await client.search_gifts() assert isinstance(result, GiftsResult) - call_data = mock_request.call_args[0][3] + call_data = mock_call.call_args[0][1] assert "view" not in call_data assert not any(k.startswith("attr[") for k in call_data) diff --git a/tests/015_test_cookies.py b/tests/015_test_cookies.py new file mode 100644 index 0000000..bb45a36 --- /dev/null +++ b/tests/015_test_cookies.py @@ -0,0 +1,99 @@ +"""Unit tests for get_cookies_from_browser() — browser cookie extraction helper.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from pyfragment import get_cookies_from_browser +from pyfragment.types import CookieError +from pyfragment.types.constants import REQUIRED_COOKIE_KEYS + +FAKE_JAR = [ + {"name": "stel_ssid", "value": "abc123", "domain": "fragment.com"}, + {"name": "stel_dt", "value": "-120", "domain": "fragment.com"}, + {"name": "stel_token", "value": "tok_xyz", "domain": "fragment.com"}, + {"name": "stel_ton_token", "value": "ton_xyz", "domain": "fragment.com"}, + {"name": "unrelated", "value": "noise", "domain": "fragment.com"}, +] + + +def _mock_rookiepy(jar: list[dict] | None = None) -> MagicMock: + mock = MagicMock() + mock.chrome.return_value = jar if jar is not None else FAKE_JAR + return mock + + +# unsupported browser tests + + +def test_unsupported_browser_raises() -> None: + with pytest.raises(CookieError, match="Unsupported browser"): + get_cookies_from_browser("internet_explorer") + + +# successful extraction tests + + +def test_returns_required_keys_only() -> None: + with patch.dict("sys.modules", {"rookiepy": _mock_rookiepy()}): + result = get_cookies_from_browser("chrome") + + assert set(result.keys()) == set(REQUIRED_COOKIE_KEYS) + assert result["stel_ssid"] == "abc123" + assert result["stel_dt"] == "-120" + assert result["stel_token"] == "tok_xyz" + assert result["stel_ton_token"] == "ton_xyz" + assert "unrelated" not in result + + +def test_default_browser_is_chrome() -> None: + mock_rp = _mock_rookiepy() + with patch.dict("sys.modules", {"rookiepy": mock_rp}): + get_cookies_from_browser() + + mock_rp.chrome.assert_called_once_with(["fragment.com"]) + + +def test_browser_name_is_case_insensitive() -> None: + mock_rp = _mock_rookiepy() + with patch.dict("sys.modules", {"rookiepy": mock_rp}): + result = get_cookies_from_browser("Chrome") + + assert result["stel_ssid"] == "abc123" + + +# missing cookies tests + + +def test_missing_cookies_raises() -> None: + partial_jar = [ + {"name": "stel_ssid", "value": "abc123"}, + {"name": "stel_dt", "value": "-120"}, + # stel_token and stel_ton_token missing + ] + with patch.dict("sys.modules", {"rookiepy": _mock_rookiepy(partial_jar)}): + with pytest.raises(CookieError, match="Fragment cookies not found in chrome"): + get_cookies_from_browser("chrome") + + +def test_empty_cookie_value_treated_as_missing() -> None: + jar_with_empty = [ + {"name": "stel_ssid", "value": "abc123"}, + {"name": "stel_dt", "value": ""}, # empty — should be treated as missing + {"name": "stel_token", "value": "tok_xyz"}, + {"name": "stel_ton_token", "value": "ton_xyz"}, + ] + with patch.dict("sys.modules", {"rookiepy": _mock_rookiepy(jar_with_empty)}): + with pytest.raises(CookieError, match="Fragment cookies not found in chrome"): + get_cookies_from_browser("chrome") + + +# read failure tests + + +def test_browser_read_error_raises() -> None: + mock_rp = MagicMock() + mock_rp.chrome.side_effect = PermissionError("locked") + with patch.dict("sys.modules", {"rookiepy": mock_rp}): + with pytest.raises(CookieError, match="Failed to read chrome cookies"): + get_cookies_from_browser("chrome")