From 6e4d4da740395b0eb835c88034fa18af2cf34ad8 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sat, 21 Mar 2026 17:43:58 +0200 Subject: [PATCH] feat: add anonymous number management methods; implement get_login_code, toggle_login_codes, and terminate_sessions with associated results and error handling --- CHANGELOG.md | 23 +++- examples/anonymous_number.py | 43 ++++++ pyfragment/__init__.py | 6 + pyfragment/client.py | 38 ++++++ pyfragment/methods/__init__.py | 12 +- pyfragment/methods/anonymous_number.py | 151 +++++++++++++++++++++ pyfragment/types/__init__.py | 6 + pyfragment/types/constants.py | 1 + pyfragment/types/exceptions.py | 8 ++ pyfragment/types/results.py | 35 ++++- pyfragment/utils/__init__.py | 2 + pyfragment/utils/html.py | 23 ++++ tests/010_test_number.py | 179 +++++++++++++++++++++++++ 13 files changed, 521 insertions(+), 6 deletions(-) create mode 100644 examples/anonymous_number.py create mode 100644 pyfragment/methods/anonymous_number.py create mode 100644 pyfragment/utils/html.py create mode 100644 tests/010_test_number.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b491b..7e0980c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,13 +10,28 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI ## [Unreleased] ### Added -- `giveaway_stars(channel, winners, amount)` — run a Telegram Stars giveaway for a channel (1–5 winners, 500–1 000 000 stars each) -- `giveaway_premium(channel, winners, months)` — run a Telegram Premium giveaway for a channel (1–24 000 winners, 3/6/12 months each) + +**Giveaways** +- `giveaway_stars(channel, winners, amount)` — run a Telegram Stars giveaway for a channel (1–5 winners, 500–1 000 000 stars each); raises `UserNotFoundError` if the channel is not found on Fragment, `ConfigurationError` if `winners` or `amount` are out of range +- `giveaway_premium(channel, winners, months)` — run a Telegram Premium giveaway for a channel (1–24 000 winners, 3/6/12 months each); raises `UserNotFoundError` if the channel is not found on Fragment, `ConfigurationError` if `winners` or `months` are invalid - `StarsGiveawayResult` and `PremiumGiveawayResult` result types -- `STARS_GIVEAWAY_PAGE` and `PREMIUM_GIVEAWAY_PAGE` URL constants + +**Raw API access** - `FragmentClient.call(method, data, *, page_url)` — send a raw request to any Fragment API method without waiting for a library update - `FRAGMENT_BASE_URL` constant — single source of truth for the Fragment base URL used across all page constants and headers -- `examples/call.py` — usage example for `client.call()` + +**Anonymous number management** +- `get_login_code(number)` — fetch the current pending login code for an anonymous number +- `toggle_login_codes(number, can_receive)` — enable or disable login code delivery for an anonymous number +- `terminate_sessions(number)` — terminate all active Telegram sessions for an anonymous number (two-step flow handled internally) +- `LoginCodeResult` and `TerminateSessionsResult` result types +- `AnonymousNumberError` exception — raised when a number is not owned or session termination fails + +**Examples** +- `examples/giveaway_stars.py` — Stars giveaway with `UserNotFoundError` / `ConfigurationError` handling +- `examples/giveaway_premium.py` — Premium giveaway with `UserNotFoundError` / `ConfigurationError` handling +- `examples/call.py` — raw API call via `client.call()` +- `examples/anonymous_number.py` — login code fetch and session termination with `AnonymousNumberError` handling ### Changed - All result types (`PremiumResult`, `StarsResult`, `StarsGiveawayResult`, `PremiumGiveawayResult`) now use a single unified `amount` field instead of `months`, `stars` — consistent API across every method diff --git a/examples/anonymous_number.py b/examples/anonymous_number.py new file mode 100644 index 0000000..3355b22 --- /dev/null +++ b/examples/anonymous_number.py @@ -0,0 +1,43 @@ +""" +Example: manage an anonymous Telegram number — read login code and terminate sessions. + +Use get_login_code() to fetch the current pending login code for your number. +Use toggle_login_codes() to enable or disable receiving codes. +Use terminate_sessions() to forcefully end all active Telegram sessions. +""" + +import asyncio + +from pyfragment import AnonymousNumberError, FragmentClient + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +NUMBER = "+88888888888" + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + # Fetch the latest login code + result = await client.get_login_code(NUMBER) + if result.code: + print(f"Login code for {result.number}: {result.code} ({result.active_sessions} active session(s))") + else: + print(f"No pending login code for {result.number} ({result.active_sessions} active session(s))") + + # Terminate all active sessions + try: + terminated = await client.terminate_sessions(NUMBER) + print(f"Sessions terminated for {terminated.number}" + (f": {terminated.message}" if terminated.message else "")) + except AnonymousNumberError as e: + print(f"Could not terminate sessions: {e}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index a3d92e8..6fa5225 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -8,18 +8,21 @@ from importlib.metadata import version from pyfragment.client import FragmentClient from pyfragment.types import ( AdsTopupResult, + AnonymousNumberError, ClientError, ConfigurationError, CookieError, FragmentAPIError, FragmentError, FragmentPageError, + LoginCodeResult, OperationError, ParseError, PremiumGiveawayResult, PremiumResult, StarsGiveawayResult, StarsResult, + TerminateSessionsResult, TransactionError, UnexpectedError, UserNotFoundError, @@ -34,10 +37,12 @@ __all__ = [ "__version__", "FragmentClient", "AdsTopupResult", + "LoginCodeResult", "PremiumGiveawayResult", "PremiumResult", "StarsGiveawayResult", "StarsResult", + "TerminateSessionsResult", "WalletInfo", "ClientError", "ConfigurationError", @@ -45,6 +50,7 @@ __all__ = [ "FragmentAPIError", "FragmentError", "FragmentPageError", + "AnonymousNumberError", "OperationError", "ParseError", "TransactionError", diff --git a/pyfragment/client.py b/pyfragment/client.py index 4e544a1..d900568 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -3,6 +3,7 @@ from typing import Any, cast import httpx +from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes from pyfragment.methods.giveaway_premium import giveaway_premium from pyfragment.methods.giveaway_stars import giveaway_stars from pyfragment.methods.purchase_premium import purchase_premium @@ -12,8 +13,10 @@ from pyfragment.types import ( AdsTopupResult, ConfigurationError, CookieError, + LoginCodeResult, PremiumResult, StarsResult, + TerminateSessionsResult, WalletInfo, ) from pyfragment.types.constants import ( @@ -197,6 +200,41 @@ class FragmentClient: """ return await giveaway_premium(self, channel, winners, months) + async def get_login_code(self, number: str) -> LoginCodeResult: + """Fetch the current pending login code for an anonymous number. + + Args: + number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``). + + Returns: + :class:`LoginCodeResult` with ``number``, ``code`` (``None`` if none pending), + and ``active_sessions`` count. + """ + return await get_login_code(self, number) + + async def toggle_login_codes(self, number: str, can_receive: bool) -> None: + """Enable or disable login code delivery for an anonymous number. + + Args: + number: Phone number with or without leading ``+``. + can_receive: ``True`` to allow receiving codes, ``False`` to block them. + """ + return await toggle_login_codes(self, number, can_receive) + + async def terminate_sessions(self, number: str) -> TerminateSessionsResult: + """Terminate all active Telegram sessions for an anonymous number. + + Args: + number: Phone number with or without leading ``+``. + + Returns: + :class:`TerminateSessionsResult` with ``number`` and ``message``. + + Raises: + AnonymousNumberError: If the number is not owned by this account or has no active sessions. + """ + return await terminate_sessions(self, number) + async def call( self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL ) -> dict[str, Any]: diff --git a/pyfragment/methods/__init__.py b/pyfragment/methods/__init__.py index 21ddeee..71af4f5 100644 --- a/pyfragment/methods/__init__.py +++ b/pyfragment/methods/__init__.py @@ -1,7 +1,17 @@ +from pyfragment.methods.anonymous_number import get_login_code, terminate_sessions, toggle_login_codes from pyfragment.methods.giveaway_premium import giveaway_premium from pyfragment.methods.giveaway_stars import giveaway_stars from pyfragment.methods.purchase_premium import purchase_premium from pyfragment.methods.purchase_stars import purchase_stars from pyfragment.methods.topup_ton import topup_ton -__all__ = ["giveaway_premium", "giveaway_stars", "purchase_premium", "purchase_stars", "topup_ton"] +__all__ = [ + "get_login_code", + "giveaway_premium", + "giveaway_stars", + "purchase_premium", + "purchase_stars", + "terminate_sessions", + "toggle_login_codes", + "topup_ton", +] diff --git a/pyfragment/methods/anonymous_number.py b/pyfragment/methods/anonymous_number.py new file mode 100644 index 0000000..b8facfe --- /dev/null +++ b/pyfragment/methods/anonymous_number.py @@ -0,0 +1,151 @@ +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 + +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 + + +async def get_login_code(client: "FragmentClient", number: str) -> LoginCodeResult: + """Fetch the current pending login code for an anonymous number. + + Args: + client: Authenticated :class:`FragmentClient` instance. + number: Phone number with or without leading ``+`` (e.g. ``"+1234567890"``). + + Returns: + :class:`LoginCodeResult` with ``number``, ``code`` (``None`` if no pending code), + and ``active_sessions`` count. + + Raises: + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + 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"}, + ) + + if result.get("html"): + code, active_sessions = parse_login_code(result["html"]) + else: + code, active_sessions = None, 0 + + return LoginCodeResult(number=number, code=code, active_sessions=active_sessions) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc + + +async def toggle_login_codes(client: "FragmentClient", number: str, can_receive: bool) -> None: + """Enable or disable login code delivery for an anonymous number. + + Args: + client: Authenticated :class:`FragmentClient` instance. + number: Phone number with or without leading ``+``. + can_receive: ``True`` to allow receiving codes, ``False`` to block them. + + Raises: + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + 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"}, + ) + + if result.get("error"): + raise FragmentAPIError(html.unescape(result["error"])) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc + + +async def terminate_sessions(client: "FragmentClient", number: str) -> TerminateSessionsResult: + """Terminate all active Telegram sessions for an anonymous number. + + This is a two-step operation: Fragment first returns a confirmation hash, + which is then submitted to confirm the termination. + + Args: + client: Authenticated :class:`FragmentClient` instance. + number: Phone number with or without leading ``+``. + + Returns: + :class:`TerminateSessionsResult` with ``number`` and ``message``. + + Raises: + AnonymousNumberError: If the number is not owned by this account or has no active sessions, + or if Fragment returns an error during termination. + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + 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"}, + ) + + 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)) + + # 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"])) + ) + + return TerminateSessionsResult(number=number, message=result.get("msg")) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py index 5de01d6..395d68f 100644 --- a/pyfragment/types/__init__.py +++ b/pyfragment/types/__init__.py @@ -1,4 +1,5 @@ from pyfragment.types.exceptions import ( + AnonymousNumberError, ClientError, ConfigurationError, CookieError, @@ -15,10 +16,12 @@ from pyfragment.types.exceptions import ( ) from pyfragment.types.results import ( AdsTopupResult, + LoginCodeResult, PremiumGiveawayResult, PremiumResult, StarsGiveawayResult, StarsResult, + TerminateSessionsResult, WalletInfo, ) @@ -31,6 +34,7 @@ __all__ = [ "FragmentAPIError", "FragmentError", "FragmentPageError", + "AnonymousNumberError", "OperationError", "ParseError", "TransactionError", @@ -40,9 +44,11 @@ __all__ = [ "WalletError", # result types "AdsTopupResult", + "LoginCodeResult", "PremiumGiveawayResult", "PremiumResult", "StarsGiveawayResult", "StarsResult", + "TerminateSessionsResult", "WalletInfo", ] diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index b5f64fe..86c9f12 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -26,6 +26,7 @@ STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway" PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift" PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway" TON_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" +NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers" # 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 3c5f390..5207a94 100644 --- a/pyfragment/types/exceptions.py +++ b/pyfragment/types/exceptions.py @@ -67,6 +67,13 @@ class UserNotFoundError(FragmentAPIError): ) +class AnonymousNumberError(FragmentAPIError): + """Raised for Fragment anonymous number API failures.""" + + NOT_OWNED = "Number '{number}' is not associated with your Fragment account or has no active sessions to terminate." + TERMINATE_FAILED = "Failed to terminate sessions for '{number}': {error}" + + class TransactionError(FragmentAPIError): """Raised when a TON transaction fails to build or broadcast.""" @@ -133,6 +140,7 @@ __all__ = [ "CookieError", "FragmentAPIError", "FragmentPageError", + "AnonymousNumberError", "UserNotFoundError", "TransactionError", "ParseError", diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py index 1cf129f..a91d7a7 100644 --- a/pyfragment/types/results.py +++ b/pyfragment/types/results.py @@ -1,6 +1,15 @@ from dataclasses import dataclass -__all__ = ["AdsTopupResult", "PremiumGiveawayResult", "PremiumResult", "StarsGiveawayResult", "StarsResult", "WalletInfo"] +__all__ = [ + "AdsTopupResult", + "LoginCodeResult", + "PremiumGiveawayResult", + "PremiumResult", + "StarsGiveawayResult", + "StarsResult", + "TerminateSessionsResult", + "WalletInfo", +] @dataclass @@ -81,3 +90,27 @@ class PremiumGiveawayResult: f"PremiumGiveawayResult(channel='{self.channel}', winners={self.winners}, " f"amount={self.amount} months per winner, tx='{self.transaction_id}')" ) + + +@dataclass +class LoginCodeResult: + """Result of :meth:`FragmentClient.get_login_code`.""" + + number: str + code: str | None + active_sessions: int + + def __repr__(self) -> str: + code_str = f"'{self.code}'" if self.code else "None" + return f"LoginCodeResult(number='{self.number}', code={code_str}, active_sessions={self.active_sessions})" + + +@dataclass +class TerminateSessionsResult: + """Result of :meth:`FragmentClient.terminate_sessions`.""" + + number: str + message: str | None + + def __repr__(self) -> str: + return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})" diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py index 406d506..4c092bb 100644 --- a/pyfragment/utils/__init__.py +++ b/pyfragment/utils/__init__.py @@ -1,4 +1,5 @@ from pyfragment.utils.decoder import clean_decode +from pyfragment.utils.html import parse_login_code from pyfragment.utils.http import ( execute_transaction_request, fragment_request, @@ -10,6 +11,7 @@ from pyfragment.utils.wallet import get_account_info, process_transaction __all__ = [ "clean_decode", + "parse_login_code", "execute_transaction_request", "fragment_request", "get_account_info", diff --git a/pyfragment/utils/html.py b/pyfragment/utils/html.py new file mode 100644 index 0000000..30004f4 --- /dev/null +++ b/pyfragment/utils/html.py @@ -0,0 +1,23 @@ +import re + +# Matches the login code inside a table-cell-value element. +CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<') +# Counts active session rows in the HTML table. +ROW_RE = re.compile(r"]") + + +def parse_login_code(html: str) -> tuple[str | None, int]: + """Extract the pending login code and active session count from a Fragment numbers page HTML snippet. + + Args: + html: Raw HTML string returned by the Fragment API. + + Returns: + A tuple of ``(code, active_sessions)`` where ``code`` is ``None`` if no + pending code is present, and ``active_sessions`` is the number of ```` + rows found (each row represents one active session). + """ + match = CODE_RE.search(html) + code = match.group(1).strip() if match else None + active_sessions = len(ROW_RE.findall(html)) + return code, active_sessions diff --git a/tests/010_test_number.py b/tests/010_test_number.py new file mode 100644 index 0000000..4138b4f --- /dev/null +++ b/tests/010_test_number.py @@ -0,0 +1,179 @@ +"""Unit tests for number methods — get_login_code, toggle_login_codes, terminate_sessions.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import AnonymousNumberError, FragmentClient, LoginCodeResult, TerminateSessionsResult +from tests.shared import FAKE_HASH + +FAKE_HTML_WITH_CODE = """ + + + + + + + +
12345
session data
+""" + +FAKE_HTML_NO_CODE = "
no code here
" + +FAKE_TERMINATE_HASH = "terminate_hash_abc123" + + +# get_login_code 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}), + ), + ): + result = await client.get_login_code("+1234567890") + + assert isinstance(result, LoginCodeResult) + assert result.number == "+1234567890" + assert result.code == "12345" + assert result.active_sessions == 2 # 2 in the HTML + + +@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}), + ), + ): + result = await client.get_login_code("1234567890") + + assert result.code is None + assert result.active_sessions == 1 + + +@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={})), + ): + result = await client.get_login_code("+1234567890") + + assert result.code is None + assert result.active_sessions == 0 + + +# terminate_sessions 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 + ] + ), + ), + ): + result = await client.terminate_sessions("+1234567890") + + assert isinstance(result, TerminateSessionsResult) + assert result.number == "+1234567890" + assert result.message == "All sessions terminated" + + +@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 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 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 pytest.raises(AnonymousNumberError, match="INTERNAL_ERROR"): + await client.terminate_sessions("+1234567890") + + +# toggle_login_codes 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, + ): + await client.toggle_login_codes("+1234567890", can_receive=True) + + call_data = mock_req.call_args[0][3] + 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, + ): + await client.toggle_login_codes("+1234567890", can_receive=False) + + call_data = mock_req.call_args[0][3] + assert call_data["can_receive"] == 0 + + +# strip_plus 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, + ): + await client.get_login_code("+1234567890") + + call_data = mock_req.call_args[0][3] + assert call_data["number"] == "1234567890"