diff --git a/examples/manual_test_functions.py b/examples/manual_test_functions.py index 962c7de..2ae0384 100644 --- a/examples/manual_test_functions.py +++ b/examples/manual_test_functions.py @@ -58,9 +58,14 @@ async def main() -> None: for i, post in enumerate(posts): print(f"[{i}] {_post_summary(post)}") + # Single-event fetch (uses the singular `sponds/{uid}` endpoint) + print("\nGetting the first event by id...") + e = events[0] + event = await s.get_event(e["id"]) + print(_event_summary(event)) + # Attendance export print("\nGetting attendance report for the first event...") - e = events[0] data = await s.get_event_attendance_xlsx(e["id"]) with tempfile.NamedTemporaryFile( mode="wb", diff --git a/spond/spond.py b/spond/spond.py index 5570406..5957ab8 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -704,10 +704,11 @@ async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: `entity_type` is something other than `"event"` or `"group"`. """ if entity_type == self._EVENT: - if not self.events: - await self.get_events() - entities = self.events - elif entity_type == self._GROUP: + # Direct fetch by uid, so this is not constrained by + # `get_events()`'s default `max_events` cap or its + # `include_scheduled=False` filter. + return await self._fetch_event_by_uid(uid) + if entity_type == self._GROUP: if not self.groups: await self.get_groups() entities = self.groups @@ -723,3 +724,23 @@ async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: if entity["id"] == uid: return entity raise KeyError(errmsg) + + async def _fetch_event_by_uid(self, uid: str) -> JSONDict: + """Fetch a single event from the singular endpoint. + + `includeComments=true` makes the response shape match a list-endpoint + element (the singular endpoint otherwise omits the `comments` field). + """ + url = f"{self.api_url}sponds/{uid}" + params = {"includeComments": "true"} + async with self.clientsession.get( + url, headers=self.auth_headers, params=params + ) as r: + if r.status == 404: + raise KeyError(f"No event with id='{uid}'.") + if not r.ok: + error_details = await r.text() + raise ValueError( + f"Request failed with status {r.status}: {error_details}" + ) + return await r.json() diff --git a/tests/test_spond.py b/tests/test_spond.py index 8c99583..5ee0a29 100644 --- a/tests/test_spond.py +++ b/tests/test_spond.py @@ -42,75 +42,73 @@ def mock_payload() -> JSONDict: class TestEventMethods: - @pytest.fixture - def mock_events(self) -> list[JSONDict]: - """Mock a minimal list of events.""" - return [ - { - "id": "ID1", - "name": "Event One", - }, - { - "id": "ID2", - "name": "Event Two", - }, - ] + MOCK_EVENT: JSONDict = { + "id": "ID1", + "heading": "Event One", + } @pytest.mark.asyncio - async def test_get_event__happy_path( - self, mock_events: list[JSONDict], mock_token - ) -> None: - """Test that a valid `id` returns the matching event.""" + @patch("aiohttp.ClientSession.get") + async def test_get_event__happy_path(self, mock_get, mock_token) -> None: + """`get_event()` fetches the singular `sponds/{uid}` endpoint + (not the list endpoint with filters), so it is not constrained + by `get_events()`'s default `max_events` cap or + `include_scheduled=False` filter.""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.events = mock_events s.token = mock_token - g = await s.get_event("ID1") - assert g == { - "id": "ID1", - "name": "Event One", - } - - @pytest.mark.asyncio - async def test_get_event__no_match_raises_exception( - self, mock_events: list[JSONDict], mock_token - ) -> None: - """Test that a non-matched `id` raises KeyError.""" + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self.MOCK_EVENT + ) - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.events = mock_events - s.token = mock_token + event = await s.get_event("ID1") - with pytest.raises(KeyError): - await s.get_event("ID3") + mock_get.assert_called_once_with( + "https://api.spond.com/core/v1/sponds/ID1", + headers={ + "content-type": "application/json", + "Authorization": f"Bearer {mock_token}", + }, + params={"includeComments": "true"}, + ) + assert event == self.MOCK_EVENT @pytest.mark.asyncio - async def test_get_event__blank_id_match_raises_exception( - self, mock_events: list[JSONDict], mock_token + @patch("aiohttp.ClientSession.get") + async def test_get_event__not_found_raises_keyerror( + self, mock_get, mock_token ) -> None: - """Test that a blank `id` raises KeyError.""" + """A 404 from the singular endpoint surfaces as KeyError.""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.events = mock_events s.token = mock_token + mock_get.return_value.__aenter__.return_value.ok = False + mock_get.return_value.__aenter__.return_value.status = 404 + with pytest.raises(KeyError): - await s.get_event("") + await s.get_event("UNKNOWN") @pytest.mark.asyncio - async def test_get_event__no_events_available_raises_keyerror( - self, mock_token + @patch("aiohttp.ClientSession.get") + async def test_get_event__api_error_raises_valueerror( + self, mock_get, mock_token ) -> None: - """`get_events()` is documented to return None when no events exist; - `get_event()` should surface this as KeyError, not TypeError.""" + """Other non-2xx responses surface as ValueError with the status.""" s = Spond(MOCK_USERNAME, MOCK_PASSWORD) s.token = mock_token - s.events = None - s.get_events = AsyncMock() # leaves self.events as None - with pytest.raises(KeyError): + mock_get.return_value.__aenter__.return_value.ok = False + mock_get.return_value.__aenter__.return_value.status = 500 + mock_get.return_value.__aenter__.return_value.text = AsyncMock( + return_value="Internal Server Error" + ) + + with pytest.raises(ValueError, match="500"): await s.get_event("ID1") @pytest.mark.asyncio