From 5188341f6644bdbf45964da0e86d3d7910ecd52c Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Tue, 3 Mar 2026 21:02:22 +0100 Subject: [PATCH 1/2] feat: add get_posts() method for retrieving group wall posts Add a new get_posts() method to the Spond class that retrieves posts from group walls via the /core/v1/posts API endpoint. Posts are announcements/messages posted to group walls, distinct from chat messages (get_messages) and events (get_events). Supports filtering by group_id, setting max_posts limit, and toggling comment inclusion. Closes Olen/Spond#67 --- spond/spond.py | 58 +++++++++++++++++++ tests/test_spond.py | 132 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) diff --git a/spond/spond.py b/spond/spond.py index 37a8499..a2e0f19 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -25,6 +25,7 @@ def __init__(self, username: str, password: str) -> None: self._auth = None self.groups: list[JSONDict] | None = None self.events: list[JSONDict] | None = None + self.posts: list[JSONDict] | None = None self.messages: list[JSONDict] | None = None self.profile: JSONDict | None = None @@ -133,6 +134,63 @@ def _match_person(person: JSONDict, match_str: str) -> bool: or ("profile" in person and person["profile"]["id"] == match_str) ) + @_SpondBase.require_authentication + async def get_posts( + self, + group_id: str | None = None, + max_posts: int = 20, + include_comments: bool = True, + ) -> list[JSONDict] | None: + """ + Retrieve posts from group walls. + + Posts are announcements/messages posted to group walls, as opposed to + chat messages or events. + + Parameters + ---------- + group_id : str, optional + Filter by group. Uses `groupId` API parameter. + max_posts : int, optional + Set a limit on the number of posts returned. + For performance reasons, defaults to 20. + Uses `max` API parameter. + include_comments : bool, optional + Include comments on posts. + Defaults to True. + Uses `includeComments` API parameter. + + Returns + ------- + list[JSONDict] or None + A list of posts, each represented as a dictionary, or None if no + posts are available. + + Raises + ------ + ValueError + Raised when the request to the API fails. + """ + url = f"{self.api_url}posts" + params: dict[str, str] = { + "type": "PLAIN", + "max": str(max_posts), + "includeComments": str(include_comments).lower(), + } + if group_id: + params["groupId"] = group_id + + async with self.clientsession.get( + url, headers=self.auth_headers, params=params + ) as r: + if not r.ok: + error_details = await r.text() + raise ValueError( + f"Request failed with status {r.status}: {error_details}" + ) + self.posts = await r.json() + return self.posts + @_SpondBase.require_authentication async def get_messages(self, max_chats: int = 100) -> list[JSONDict] | None: """ diff --git a/tests/test_spond.py b/tests/test_spond.py index fb37c73..fcfca1e 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -212,3 +212,135 @@ async def test_get_export(self, mock_get, mock_token) -> None: }, ) assert data == mock_binary + + +class TestPostMethods: + MOCK_POSTS: list[JSONDict] = [ + { + "id": "POST1", + "type": "PLAIN", + "groupId": "GID1", + "title": "Post One", + "body": "Body of post one", + "timestamp": "2026-03-03T19:20:00.270Z", + "comments": [], + }, + { + "id": "POST2", + "type": "PLAIN", + "groupId": "GID2", + "title": "Post Two", + "body": "Body of post two", + "timestamp": "2026-02-20T19:21:20.447Z", + "comments": [{"id": "C1", "text": "A comment"}], + }, + ] + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__happy_path(self, mock_get, mock_token) -> None: + """Test that get_posts returns posts from the API.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self.MOCK_POSTS + ) + + posts = await s.get_posts() + + mock_url = "https://api.spond.com/core/v1/posts" + mock_get.assert_called_once_with( + mock_url, + headers={ + "content-type": "application/json", + "Authorization": f"Bearer {mock_token}", + }, + params={ + "type": "PLAIN", + "max": "20", + "includeComments": "true", + }, + ) + assert posts == self.MOCK_POSTS + assert s.posts == self.MOCK_POSTS + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__with_group_id(self, mock_get, mock_token) -> None: + """Test that group_id is passed as a query parameter.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=[self.MOCK_POSTS[0]] + ) + + posts = await s.get_posts(group_id="GID1") + + mock_get.assert_called_once_with( + "https://api.spond.com/core/v1/posts", + headers={ + "content-type": "application/json", + "Authorization": f"Bearer {mock_token}", + }, + params={ + "type": "PLAIN", + "max": "20", + "includeComments": "true", + "groupId": "GID1", + }, + ) + assert len(posts) == 1 + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__custom_max(self, mock_get, mock_token) -> None: + """Test that max_posts parameter is respected.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=[] + ) + + await s.get_posts(max_posts=5) + + call_params = mock_get.call_args[1]["params"] + assert call_params["max"] == "5" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__no_comments(self, mock_get, mock_token) -> None: + """Test that include_comments=False is passed correctly.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=[] + ) + + await s.get_posts(include_comments=False) + + call_params = mock_get.call_args[1]["params"] + assert call_params["includeComments"] == "false" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__api_error_raises(self, mock_get, mock_token) -> None: + """Test that a failed API response raises ValueError.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = False + mock_get.return_value.__aenter__.return_value.status = 401 + mock_get.return_value.__aenter__.return_value.text = AsyncMock( + return_value="Unauthorized" + ) + + with pytest.raises(ValueError, match="401"): + await s.get_posts() From 9d3b6d5f81d166b9f05be3e2739eb79a9e2ccbb4 Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Sun, 15 Mar 2026 00:54:24 +0100 Subject: [PATCH 2/2] refactor: simplify mock response setup in TestPostMethods --- tests/test_spond.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/test_spond.py b/tests/test_spond.py index fcfca1e..88bc89f 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -303,9 +303,7 @@ async def test_get_posts__custom_max(self, mock_get, mock_token) -> None: s.token = mock_token mock_get.return_value.__aenter__.return_value.ok = True - mock_get.return_value.__aenter__.return_value.json = AsyncMock( - return_value=[] - ) + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) await s.get_posts(max_posts=5) @@ -320,9 +318,7 @@ async def test_get_posts__no_comments(self, mock_get, mock_token) -> None: s.token = mock_token mock_get.return_value.__aenter__.return_value.ok = True - mock_get.return_value.__aenter__.return_value.json = AsyncMock( - return_value=[] - ) + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) await s.get_posts(include_comments=False)