diff --git a/spond/__init__.py b/spond/__init__.py index 9afec9a..47b2a47 100644 --- a/spond/__init__.py +++ b/spond/__init__.py @@ -11,6 +11,22 @@ class AuthenticationError(Exception): - """Error raised on Spond authentication failure.""" + """Raised when login to the Spond API fails. + + Typical causes: + + - Incorrect username/password. + - 2FA enabled on the account (the library does not currently support + Spond's TOTP flow — see #205). + - The account has hit Spond's login rate limit (`outOfLoginAttempts`). + - The Spond login API has changed shape and the response no longer + contains an `accessToken`. + + The exception message includes any of the response's whitelisted + diagnostic fields (`error`, `errorKey`, `errorCode`, `message`) so + most error cases are self-explanatory. Other response fields — such + as 2FA challenge tokens and (masked) `phoneNumber` — are intentionally + dropped from the message to avoid leaking them into application logs. + """ pass diff --git a/spond/base.py b/spond/base.py index 1d45685..872e4c3 100644 --- a/spond/base.py +++ b/spond/base.py @@ -1,3 +1,13 @@ +"""Shared base class for Spond API clients. + +`_SpondBase` is the abstract parent of both `spond.spond.Spond` (consumer API) +and `spond.club.SpondClub` (Spond Club finance API). It owns the credentials, +the underlying aiohttp `ClientSession`, the access token, and the lazy login +flow used by the `require_authentication` decorator. + +Not intended to be instantiated directly — use a subclass. +""" + from abc import ABC from collections.abc import Callable @@ -5,9 +15,35 @@ from spond import AuthenticationError +# Fields from a login response that are safe to surface in an +# `AuthenticationError` message. Anything outside this set (notably 2FA +# challenge tokens and `phoneNumber`) is dropped to avoid leaking +# sensitive data into application logs. +_SAFE_LOGIN_ERROR_FIELDS = ("error", "errorKey", "errorCode", "message") + class _SpondBase(ABC): + """Abstract base for Spond API clients. + + Subclasses provide the API base URL via the third constructor argument + and inherit lazy authentication, the `auth_headers` property, the + `require_authentication` decorator, and the `login()` flow. + """ + def __init__(self, username: str, password: str, api_url: str) -> None: + """Initialise credentials and open the aiohttp session. + + Parameters + ---------- + username : str + Spond account email address. + password : str + Spond account password. + api_url : str + Base URL for the API family this client targets (consumer or + club). Must end with a trailing slash so relative paths can be + concatenated. + """ self.username = username self.password = password self.api_url = api_url @@ -58,9 +94,36 @@ async def login(self) -> None: @staticmethod def _extract_access_token(login_result: dict) -> str: + """Pull the access-token string out of a `/auth2/login` response. + + The response shape is + `{"accessToken": {"token": "", "expiration": "..."}, ...}`. + This helper validates that shape and returns the bearer string used + for subsequent API calls. + + Parameters + ---------- + login_result : dict + Parsed JSON body from the login endpoint. + + Returns + ------- + str + The bearer-token string. + + Raises + ------ + AuthenticationError + The response is malformed or doesn't carry a usable token (e.g. + wrong credentials, account locked, 2FA required). + """ access = login_result.get("accessToken") if isinstance(access, dict): token = access.get("token") if isinstance(token, str) and token: return token - raise AuthenticationError(f"Login failed. Response received: {login_result}") + safe = { + k: login_result[k] for k in _SAFE_LOGIN_ERROR_FIELDS if k in login_result + } + diagnostic = safe or "(no recognised diagnostic fields in response)" + raise AuthenticationError(f"Login failed. {diagnostic}") diff --git a/spond/club.py b/spond/club.py index 5d2bdd0..b5c2121 100644 --- a/spond/club.py +++ b/spond/club.py @@ -1,3 +1,11 @@ +"""Client for the Spond Club finance API. + +Spond Club is the paid administration tier sold to clubs/teams alongside the +free consumer app. It exposes a separate API (`api.spond.com/club/v1/`) for +finance-flavoured data such as transactions/payments. Use the `SpondClub` +class for this API and `spond.spond.Spond` for everything else. +""" + from __future__ import annotations from typing import TYPE_CHECKING, ClassVar @@ -9,9 +17,45 @@ class SpondClub(_SpondBase): + """Async client for the Spond Club finance API. + + Authentication is shared with the consumer API — the same email/password + credentials work, but the user must belong to at least one Spond Club + organisation and the `club_id` passed to each method must be one they + have access to. The `club_id` here is distinct from the consumer-API + `groupId`. + + Example + ------- + ```python + import asyncio + from spond import club + + async def main(): + sc = club.SpondClub(username="me@example.invalid", password="secret") + txs = await sc.get_transactions(club_id="ABCD1234...", max_items=50) + for t in txs: + print(t["paidAt"], t["paymentName"], t["paidByName"]) + await sc.clientsession.close() + + asyncio.run(main()) + ``` + """ + _API_BASE_URL: ClassVar = "https://api.spond.com/club/v1/" def __init__(self, username: str, password: str) -> None: + """Construct a Spond Club client. + + Parameters + ---------- + username : str + Spond account email. Same credentials as the consumer API; the + account must have access to at least one Spond Club organisation + for the API calls to return data. + password : str + Spond account password. + """ super().__init__(username, password, self._API_BASE_URL) self.transactions: list[JSONDict] | None = None @@ -19,27 +63,44 @@ def __init__(self, username: str, password: str) -> None: async def get_transactions( self, club_id: str, skip: int | None = None, max_items: int = 100 ) -> list[JSONDict]: - """ - Retrieves a list of transactions/payments for a specified club. + """Retrieve transactions/payments for a Spond Club. + + Spond's transactions endpoint returns at most 25 records per request, + so this method paginates internally (via recursion on `skip`) until + either `max_items` is reached or the server returns an empty page. + + **Caching caveat**: results accumulate on `self.transactions` and + the cache is **not** keyed by `club_id` — calling this method again + with a different `club_id` on the same instance will append that + club's transactions to the same list, mixing the two. If you query + multiple clubs from one client, reset the cache between calls + (`sc.transactions = None`) or use a fresh `SpondClub` instance per + club. + + Each transaction dict typically includes at least `id`, `paidAt`, + `paymentName`, and `paidByName`. See `examples/transactions.py` for + a usage example. Parameters ---------- club_id : str - Identifier for the club. Note that this is different from the Group ID used - in the core API. + Identifier for the club. Note that this is **different** from the + `groupId` used elsewhere in the Spond API — find it in the URL + of the Spond Club web UI. skip : int, optional - This endpoint only returns 25 transactions at a time (page scrolling). - Therefore, we need to increment this `skip` param to grab the next - 25 etc. Defaults to None. It's better to keep `skip` at None - and specify `max_items` instead. This param is only here for the - recursion implementation. + Pagination cursor (number of records to skip). Normally left as + `None`; the method increments it itself on recursive calls. Only + override if you know what you're doing. max_items : int, optional - The maximum number of transactions to retrieve. Defaults to 100. + Stop fetching once at least this many transactions are + accumulated. Defaults to 100. The final list may be slightly + longer than `max_items` since the server returns pages of 25. Returns ------- list[JSONDict] - A list of transactions, each represented as a dictionary. + All transactions accumulated so far (across recursive page + fetches). Empty list if the club has no transactions. """ if self.transactions is None: self.transactions = [] diff --git a/spond/spond.py b/spond/spond.py index 9de2f48..5665888 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -1,4 +1,11 @@ #!/usr/bin/env python3 +"""Core Spond API client. + +This module contains the `Spond` class, the main entrypoint to the +[Spond](https://spond.com/) consumer API: account profile, groups, members, +events, posts, and chats. For the separate Spond Club finance API, see +`spond.club`. +""" from __future__ import annotations @@ -13,6 +20,47 @@ class Spond(_SpondBase): + """Async client for the Spond consumer API. + + Authentication happens lazily on the first API call (via the + `require_authentication` decorator inherited from `spond.base._SpondBase`); + you do not need to call `login()` explicitly. + + Several `get_*` methods cache their last response on the instance + (`self.groups`, `self.events`, `self.posts`, `self.messages`, + `self.profile`). This lets lookup helpers like `get_group(uid)` and + `get_person(user)` avoid re-fetching when called repeatedly. To force a + refresh, set the relevant attribute to `None` and call the `get_*` method + again, or call the underlying `get_*s()` method directly. + + Remember to close the underlying aiohttp session when finished: + + ```python + s = Spond(username="...", password="...") + try: + groups = await s.get_groups() + ... + finally: + await s.clientsession.close() + ``` + + Example + ------- + ```python + import asyncio + from spond import spond + + async def main(): + s = spond.Spond(username="me@example.invalid", password="secret") + groups = await s.get_groups() or [] + for g in groups: + print(g["name"]) + await s.clientsession.close() + + asyncio.run(main()) + ``` + """ + _API_BASE_URL: ClassVar = "https://api.spond.com/core/v1/" _DT_FORMAT: ClassVar = "%Y-%m-%dT00:00:00.000Z" _EVENT_TEMPLATE: ClassVar = _EVENT_TEMPLATE @@ -20,6 +68,22 @@ class Spond(_SpondBase): _GROUP: ClassVar = "group" def __init__(self, username: str, password: str) -> None: + """Construct a Spond client. + + The credentials are stored on the instance and used to obtain an access + token on the first authenticated call. An aiohttp `ClientSession` is + opened immediately; close it via `await s.clientsession.close()` + (where `s` is the constructed instance) when finished, to avoid + `Unclosed client session` warnings. + + Parameters + ---------- + username : str + Spond account email address. + password : str + Spond account password. For accounts with 2FA enabled, login will + currently fail — see #205. + """ super().__init__(username, password, self._API_BASE_URL) self._chat_url = None self._auth = None @@ -30,6 +94,15 @@ def __init__(self, username: str, password: str) -> None: self.profile: JSONDict | None = None async def _login_chat(self) -> None: + """Perform the secondary handshake with Spond's chat server. + + The chat API lives on a separate host and uses its own short-lived + token (`self._auth`) rather than the regular Bearer token used by the + core API. This method is called lazily by `get_messages`, + `send_message`, and `_continue_chat` on their first use; the resulting + `self._chat_url` and `self._auth` are cached for the lifetime of the + client. + """ api_chat_url = f"{self.api_url}chat" r = await self.clientsession.post(api_chat_url, headers=self.auth_headers) result = await r.json() @@ -38,14 +111,16 @@ async def _login_chat(self) -> None: @_SpondBase.require_authentication async def get_profile(self) -> JSONDict: - """ - Retrieves all information connected to the user's account, subject to authenticated user's access. + """Retrieve the authenticated user's profile. + + The profile dict includes at least the user's `id`, `firstName`, and + `lastName`, plus contact details and account preferences. The full + response is cached on `self.profile`. Returns ------- JSONDict - information connected to the user's account - + The profile object as returned by the Spond API. """ url = f"{self._API_BASE_URL}profile" async with self.clientsession.get(url, headers=self.auth_headers) as r: @@ -54,15 +129,19 @@ async def get_profile(self) -> JSONDict: @_SpondBase.require_authentication async def get_groups(self) -> list[JSONDict] | None: - """ - Retrieve all groups, subject to authenticated user's access. + """Retrieve every group the authenticated user is a member of. + + Each group dict includes a `members` list, with each member dict + containing `id`, `firstName`, `lastName`, and (for child profiles + managed by another account) a nested `guardians` list of the same + shape. The full response is cached on `self.groups` and reused by + `get_group(uid)` and `get_person(user)`. Returns ------- list[JSONDict] or None - A list of groups, each represented as a dictionary, or None if no groups - are available. - + A list of groups, each represented as a dictionary. `None` if the + account has no groups at all. """ url = f"{self.api_url}groups/" async with self.clientsession.get(url, headers=self.auth_headers) as r: @@ -70,9 +149,10 @@ async def get_groups(self) -> list[JSONDict] | None: return self.groups async def get_group(self, uid: str) -> JSONDict: - """ - Get a group by unique ID. - Subject to authenticated user's access. + """Look up a single group by its unique id. + + Searches the cached `self.groups` (populated by `get_groups()` on + first call). To force a refresh, set `self.groups = None` first. Parameters ---------- @@ -82,35 +162,45 @@ async def get_group(self, uid: str) -> JSONDict: Returns ------- JSONDict - Details of the group. + The group's details, with the same shape as elements returned by + `get_groups()`. Raises ------ - KeyError if no group is matched. - + KeyError + If no group with the given id is found (or the user has no groups). """ return await self._get_entity(self._GROUP, uid) @_SpondBase.require_authentication async def get_person(self, user: str) -> JSONDict: - """ - Get a member or guardian by matching various identifiers. - Subject to authenticated user's access. + """Look up a member or guardian by any of several identifiers. + + Searches every member of every cached group (and each member's + `guardians` list). The first match wins. The cache `self.groups` is + populated by `get_groups()` if empty. Parameters ---------- user : str - Identifier to match against member/guardian's id, email, full name, or - profile id. + Identifier to match against. Accepted forms: + + - the member's `id` + - the member's email (exact match) + - first and last name joined by a single space + (e.g. `"Ola Thoresen"`) + - the member's `profile.id` (different from `id` for child profiles) Returns ------- JSONDict - Member or guardian's details. + The first matching member or guardian dict. Shape matches the + entries in a group's `members` list from `get_groups()`. Raises ------ - KeyError if no person is matched. + KeyError + If no match is found across any group or guardian. """ if not self.groups: await self.get_groups() @@ -127,6 +217,23 @@ async def get_person(self, user: str) -> JSONDict: @staticmethod def _match_person(person: JSONDict, match_str: str) -> bool: + """Return True if `match_str` matches any of the person's identifiers. + + Used internally by `get_person` to scan group members and guardians. + See `get_person` for the list of accepted identifier forms. + + Parameters + ---------- + person : JSONDict + A member or guardian dict from a group's `members` list. + match_str : str + The identifier to test against. + + Returns + ------- + bool + True on first matching identifier; False otherwise. + """ return ( person["id"] == match_str or ("email" in person and person["email"]) == match_str @@ -193,22 +300,25 @@ async def get_posts( @_SpondBase.require_authentication async def get_messages(self, max_chats: int = 100) -> list[JSONDict] | None: - """ - Retrieve messages (chats). + """Retrieve recent chats (one-to-one and group conversations). + + "Chats" here refers to the in-app direct/group messaging feature, not + comments on events or posts. Uses Spond's separate chat-server host + and chat token (handled internally by `_login_chat`). + + The full response is cached on `self.messages`. Parameters ---------- max_chats : int, optional - Set a limit on the number of chats returned. - For performance reasons, defaults to 100. - Uses `max` API parameter. + Maximum number of chats to return. Defaults to 100 for performance. + Uses the `max` API parameter. Returns ------- list[JSONDict] or None - A list of chats, each represented as a dictionary, or None if no chats - are available. - + A list of chat objects ordered by most recent activity. `None` if + the account has no chats. """ if not self._auth: await self._login_chat() @@ -223,22 +333,22 @@ async def get_messages(self, max_chats: int = 100) -> list[JSONDict] | None: @_SpondBase.require_authentication async def _continue_chat(self, chat_id: str, text: str) -> JSONDict: - """ - Send a given text in an existing given chat. - Subject to authenticated user's access. + """Append a text message to an existing chat thread. + + Internal helper used by `send_message` when called with `chat_id`. + Performs the lazy chat-server login (`_login_chat`) on first use. Parameters ---------- chat_id : str - Identifier of the chat. - + Identifier of the existing chat to continue. text : str - The text to be sent to the chat. + Message body to send. Returns ------- JSONDict - Result of the sending. + The Spond API response for the send operation. """ if not self._auth: await self._login_chat() @@ -255,28 +365,49 @@ async def send_message( group_uid: str | None = None, chat_id: str | None = None, ) -> JSONDict: - """ - Start a new chat or continue an existing one. + """Send a chat message, either continuing an existing thread or + starting a new one. + + Two calling patterns: - If `chat_id`of an existing chat is specified, message continues that chat. - Otherwise, both `user` and `group_uid` must be specified, and the message starts a new chat. + - **Continue an existing chat**: pass `chat_id` (the recipient and + group context are inferred from the existing thread). `user` and + `group_uid` are ignored. + - **Start a new chat**: pass both `user` (the recipient) and + `group_uid` (the group context the chat belongs to). The user is + resolved via `get_person()` to find the underlying profile id. Parameters ---------- text : str - Message to send + Message body to send. user : str, optional - Identifier to match against member/guardian's id, email, full name, or - profile id. + Recipient identifier when starting a new chat. Accepts the same + forms as `get_person()`: member id, email, full name, or + profile id. Required when `chat_id` is not given. group_uid : str, optional - UID of the group. + UID of the group that scopes the new chat. Required when `chat_id` + is not given. chat_id : str, optional - Identifier of the chat. + Identifier of an existing chat to continue. When provided, + `user` and `group_uid` are not consulted. Returns ------- - dict - Result of the sending. + JSONDict + On success: the Spond API response for the send operation. + + Notes + ----- + Has two known bugs tracked in #238: + + - When `chat_id` is provided, the call to `_continue_chat()` is + missing `await`, so the returned value is a coroutine rather than + the API response. + - When `user` and `group_uid` are inconsistent, the method returns a + sentinel dict `{"error": "..."}` rather than raising, and an + unreachable branch returns `False` despite the `JSONDict` + annotation. """ if self._auth is None: await self._login_chat() @@ -316,15 +447,28 @@ async def get_events( min_start: datetime | None = None, max_events: int = 100, ) -> list[JSONDict] | None: - """ - Retrieve events. + """Retrieve events visible to the authenticated user. + + Filters can narrow by group/subgroup, by start/end timestamp window, + and by visibility (scheduled, hidden). The full response is cached on + `self.events`. + + Note: `get_event(uid)` is a wrapper around this method via the cache, + so it inherits these defaults — an event that doesn't appear in the + first `max_events` results or is excluded by `include_scheduled=False` + is unreachable through `get_event()` on current main. PR #236 changes + `get_event()` to fetch the singular `sponds/{uid}` endpoint directly, + removing that coupling; until it lands, pass appropriate filters here + when you need broader visibility. Parameters ---------- group_id : str, optional - Uses `groupId` API parameter. + Restrict to events belonging to this group. Uses `groupId` API + parameter. subgroup_id : str, optional - Uses `subgroupId` API parameter. + Restrict to events within this subgroup. Uses `subGroupId` API + parameter. include_scheduled : bool, optional Include scheduled events (events whose invitations are queued to be sent in the future). @@ -400,9 +544,13 @@ async def get_events( return self.events async def get_event(self, uid: str) -> JSONDict: - """ - Get an event by unique ID. - Subject to authenticated user's access. + """Look up a single event by its unique id. + + Currently routes through the cached events list (populated by + `get_events()`). Note this means events outside the `max_events=100` + default or with `scheduled=true` may not be findable — see #137 and + #138 (fix in PR #236 routes this through the singular `sponds/{uid}` + endpoint instead). Parameters ---------- @@ -412,31 +560,55 @@ async def get_event(self, uid: str) -> JSONDict: Returns ------- JSONDict - Details of the event. + The event's details, with the same shape as elements returned by + `get_events()`. Raises ------ - KeyError if no event is matched. - + KeyError + If no event with the given id is found in the cache. """ return await self._get_entity(self._EVENT, uid) @_SpondBase.require_authentication async def update_event(self, uid: str, updates: JSONDict) -> list[JSONDict] | None: - """ - Updates an existing event. + """Update an existing event by merging changes into the current state. + + The implementation fetches the event via `_get_entity()`, copies the + fields present in `_EVENT_TEMPLATE` from the existing event as the + base, then overlays any keys provided in `updates`. The merged event + is POSTed back to `sponds/{uid}`. Parameters ---------- uid : str - UID of the event. + UID of the event to update. updates : JSONDict - The changes. e.g. if you want to change the description -> {'description': "New Description with changes"} + Mapping of keys to new values. Only keys present in + `_EVENT_TEMPLATE` are honoured. Example: + + ```python + await s.update_event(uid, {"description": "New description"}) + ``` Returns ------- - json results of post command - + list[JSONDict] or None + Currently returns `self.events` (the cached events list, or + `None` if no `get_events()` call has populated it). This is a + bug — see Notes. + + Notes + ----- + Known bug #239: the return value should be the Spond API response + from the POST, not the cached events list. The API response *is* + captured on `self.events_update`, so the data is still accessible + as a workaround until #239 is fixed: + + ```python + await s.update_event(uid, {"description": "..."}) + result = s.events_update # the actual API response + ``` """ event = await self._get_entity(self._EVENT, uid) url = f"{self.api_url}sponds/{uid}" @@ -456,17 +628,30 @@ async def update_event(self, uid: str, updates: JSONDict) -> list[JSONDict] | No @_SpondBase.require_authentication async def get_event_attendance_xlsx(self, uid: str) -> bytes: - """get Excel attendance report for a single event. - Available via the web client. + """Download the attendance report for an event as XLSX bytes. + + Thin wrapper around Spond's own "Export attendance history" feature + in the web UI. The columns and format are determined by Spond, not by + this library — for example, the export does not include member ids + (see closed issue #227). For a customisable CSV alternative built + from `get_event()` data, see `examples/attendance.py`. Parameters ---------- uid : str - UID of the event. + UID of the event whose attendance report to fetch. Returns ------- - bytes: XLSX binary data + bytes + Raw XLSX file contents. Typically written directly to disk: + + ```python + import pathlib + + data = await s.get_event_attendance_xlsx(uid) + pathlib.Path(f"{uid}.xlsx").write_bytes(data) + ``` """ url = f"{self.api_url}sponds/{uid}/export" async with self.clientsession.get(url, headers=self.auth_headers) as r: @@ -474,23 +659,32 @@ async def get_event_attendance_xlsx(self, uid: str) -> bytes: @_SpondBase.require_authentication async def change_response(self, uid: str, user: str, payload: JSONDict) -> JSONDict: - """change a user's response for an event + """Update a single member's response (accept/decline) for an event. + + Useful for managing attendance on someone else's behalf (e.g. a coach + accepting on behalf of a player who can't reach the app). The caller + must have permission on the event. Parameters ---------- uid : str UID of the event. - user : str - UID of the user - + UID of the member whose response to change. Note: this is the + *member's* id (as seen in `group["members"][i]["id"]`), not the + authenticated user's id. payload : JSONDict - user response to event, e.g. {"accepted": "true"} + The response body. Common shapes: + + - `{"accepted": "true"}` — accept the invitation + - `{"accepted": "false"}` — decline (Spond may also accept a + `"declineMessage"` field with a reason) Returns ------- JSONDict - event["responses"] with updated info + The event's `responses` object with the updated id lists + (`acceptedIds`, `declinedIds`, `unansweredIds`, etc.). """ url = f"{self.api_url}sponds/{uid}/responses/{user}" async with self.clientsession.put( @@ -500,28 +694,33 @@ async def change_response(self, uid: str, user: str, payload: JSONDict) -> JSOND @_SpondBase.require_authentication async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: - """ - Get an event or group by unique ID. + """Internal lookup helper shared by `get_event` and `get_group`. - Subject to authenticated user's access. + Routes to the relevant cache (`self.events` or `self.groups`), + triggers a fetch via `get_events()` / `get_groups()` if the cache is + empty, then linearly scans for a matching `id`. The empty-cache case + is handled explicitly to avoid the `TypeError: 'NoneType' object is + not iterable` that previously occurred (see #136, fixed in #235). Parameters ---------- entity_type : str - self._EVENT or self._GROUP. + One of `self._EVENT` (`"event"`) or `self._GROUP` (`"group"`). uid : str - UID of the entity. + UID of the entity to find. Returns ------- JSONDict - Details of the entity. + The matching entity dict. Raises ------ - KeyError if no entity is matched. - NotImplementedError if no/unsupported entity type is specified. - + KeyError + No entity with that id was found (either because the relevant + cache is empty or because the id doesn't appear in it). + NotImplementedError + `entity_type` is something other than `"event"` or `"group"`. """ if entity_type == self._EVENT: if not self.events: diff --git a/tests/test_spond.py b/tests/test_spond.py index 61aa1c4..7592c30 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -400,6 +400,24 @@ def test_extract_access_token__bad_shape_raises(self, login_result) -> None: with pytest.raises(AuthenticationError): _SpondBase._extract_access_token(login_result) + def test_extract_access_token__error_message_drops_sensitive_fields( + self, + ) -> None: + """The exception message must not leak unwhitelisted fields from the + login response (e.g. a 2FA challenge `token` or `phoneNumber`).""" + login_result = { + "token": "TWO_FA_CHALLENGE_TOKEN_VALUE", + "phoneNumber": "****12", + "errorKey": "twoFactorRequired", + } + with pytest.raises(AuthenticationError) as exc_info: + _SpondBase._extract_access_token(login_result) + + message = str(exc_info.value) + assert "TWO_FA_CHALLENGE_TOKEN_VALUE" not in message + assert "phoneNumber" not in message + assert "twoFactorRequired" in message # whitelisted field surfaces + @pytest.mark.asyncio @patch("aiohttp.ClientSession.post") async def test_login__happy_path(self, mock_post) -> None: