From c53c7555fbfa729640bb1fd8e6297f73da72f4e8 Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 10:37:33 +0200 Subject: [PATCH 1/2] fix(base): use /auth2/login endpoint and accessToken.token field Spond replaced /core/v1/login with /core/v1/auth2/login on 2026-05-13. The new response wraps the bearer token under accessToken.token (with an explicit expiration) instead of a flat loginToken string. Public API unchanged: self.token still holds the bearer string and auth_headers still produces the same Authorization header. Adds the first test coverage for login() and parses defensively so error-shaped responses ({"error": ...}, null accessToken, empty token) all surface as AuthenticationError with the raw response in the message. Closes #229 Co-Authored-By: Claude Opus 4.7 (1M context) --- spond/base.py | 16 ++++++++---- tests/test_spond.py | 59 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/spond/base.py b/spond/base.py index e3de081..cff6473 100644 --- a/spond/base.py +++ b/spond/base.py @@ -35,11 +35,17 @@ async def wrapper(self, *args, **kwargs): return wrapper async def login(self) -> None: - login_url = f"{self.api_url}login" + login_url = f"{self.api_url}auth2/login" data = {"email": self.username, "password": self.password} async with self.clientsession.post(login_url, json=data) as r: login_result = await r.json() - self.token = login_result.get("loginToken") - if self.token is None: - err_msg = f"Login failed. Response received: {login_result}" - raise AuthenticationError(err_msg) + self.token = self._extract_access_token(login_result) + + @staticmethod + def _extract_access_token(login_result: dict) -> str: + 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}") diff --git a/tests/test_spond.py b/tests/test_spond.py index f55a499..fdc41a4 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -7,6 +7,7 @@ import pytest +from spond import AuthenticationError from spond.base import _SpondBase from spond.spond import Spond @@ -340,3 +341,61 @@ async def test_get_posts__api_error_raises(self, mock_get, mock_token) -> None: with pytest.raises(ValueError, match="401"): await s.get_posts() + + +class TestLogin: + @pytest.mark.parametrize( + ("login_result", "expected"), + [ + ({"accessToken": {"token": "ABC", "expiration": "2026-05-14T12:00:00Z"}}, "ABC"), + ], + ) + def test_extract_access_token__happy_path(self, login_result, expected) -> None: + assert _SpondBase._extract_access_token(login_result) == expected + + @pytest.mark.parametrize( + "login_result", + [ + {"error": "Invalid credentials"}, + {"accessToken": None}, + {"accessToken": {}}, + {"accessToken": {"token": ""}}, + {"accessToken": {"token": None}}, + ], + ) + def test_extract_access_token__bad_shape_raises(self, login_result) -> None: + with pytest.raises(AuthenticationError): + _SpondBase._extract_access_token(login_result) + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_login__happy_path(self, mock_post) -> None: + mock_response = { + "accessToken": {"token": "ABC", "expiration": "2026-05-14T12:00:00Z"}, + "refreshToken": {"token": "REF", "expiration": "2026-08-11T12:00:00Z"}, + "passwordToken": {"token": "PWD", "expiration": "2026-05-13T13:00:00Z"}, + } + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=mock_response + ) + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + await s.login() + + mock_post.assert_called_once_with( + "https://api.spond.com/core/v1/auth2/login", + json={"email": MOCK_USERNAME, "password": MOCK_PASSWORD}, + ) + assert s.token == "ABC" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_login__error_response_raises(self, mock_post) -> None: + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={"error": "Invalid credentials"} + ) + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + with pytest.raises(AuthenticationError): + await s.login() + assert s.token is None From 0b979e47564e18541fce886c67e34641c8ce1c4a Mon Sep 17 00:00:00 2001 From: olen Date: Thu, 14 May 2026 10:41:14 +0200 Subject: [PATCH 2/2] style: reflow parametrize tuple for ruff format Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_spond.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_spond.py b/tests/test_spond.py index fdc41a4..c629b08 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -347,7 +347,10 @@ class TestLogin: @pytest.mark.parametrize( ("login_result", "expected"), [ - ({"accessToken": {"token": "ABC", "expiration": "2026-05-14T12:00:00Z"}}, "ABC"), + ( + {"accessToken": {"token": "ABC", "expiration": "2026-05-14T12:00:00Z"}}, + "ABC", + ), ], ) def test_extract_access_token__happy_path(self, login_result, expected) -> None: