From ae57dcea0daa2e3b95cb479bd73b2e7c4dd9b78d Mon Sep 17 00:00:00 2001 From: JS Ng Date: Sat, 3 Oct 2026 23:39:24 +0800 Subject: [PATCH] fix(api): Circles.list() unwraps the data envelope; Circles.new() sends tag/parents Circles.list() looked up a "circles" envelope key the API never sends; the list endpoints return {"data": [...]} (assignments, submissions, timetable all do). Unwrap "data" instead. Circles.new() accepted tag/parents but built the POST payload from name/description only, silently dropping them. Send tag and parents (now only when provided), and default description to "" to match the API, where description is optional (nyjc-computing/campus#763). Fixes #55. --- campus_python/api/v1/circles.py | 14 +++-- tests/unit/test_circles.py | 95 +++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_circles.py diff --git a/campus_python/api/v1/circles.py b/campus_python/api/v1/circles.py index 923ef7a..3302498 100644 --- a/campus_python/api/v1/circles.py +++ b/campus_python/api/v1/circles.py @@ -3,6 +3,8 @@ Campus API circles resource (v1). """ +from typing import Any + import campus.model from ...interface import Resource, ResourceCollection @@ -22,21 +24,25 @@ def list(self) -> "list[campus.model.Circle]": resp.raise_for_status() return [ campus.model.Circle.from_resource(item) - for item in resp.json()["circles"] + for item in resp.json()["data"] ] def new( self, *, name: str, - description: str, tag: str, + description: str = "", parents: dict[str, int] | None = None, ) -> campus.model.Circle: - resp = self.client.post(self.make_path(), json={ + payload: dict[str, Any] = { "name": name, + "tag": tag, "description": description, - }) + } + if parents is not None: + payload["parents"] = parents + resp = self.client.post(self.make_path(), json=payload) resp.raise_for_status() return campus.model.Circle.from_resource(resp.json()) diff --git a/tests/unit/test_circles.py b/tests/unit/test_circles.py new file mode 100644 index 0000000..2fa1501 --- /dev/null +++ b/tests/unit/test_circles.py @@ -0,0 +1,95 @@ +"""Unit tests for the circles collection client (issue #55). + +Circles.list() must unwrap the ``{"data": [...]}`` envelope the API +sends (it looked up a ``"circles"`` key the API never sends), and +Circles.new() must carry ``tag``/``parents`` in the POST payload +instead of silently dropping them. The expected shapes mirror the +campus-suite routes on branch ``weekly``. +""" + +import unittest +from unittest.mock import Mock + +import campus.model + +from campus_python.api.v1 import ApiRoot + + +def make_api() -> tuple[ApiRoot, Mock]: + """Create an ApiRoot backed by a mock JSON client.""" + client = Mock() + return ApiRoot(json_client=client), client + + +CIRCLE_RESOURCE = { + "id": "circle_ab12cd34", + "name": "Design Team", + "description": "Handles UI/UX", + "tag": "project", + "members": {}, + "sources": {}, +} + + +class TestCirclesList(unittest.TestCase): + """Circles.list() must unwrap the API's data envelope.""" + + def setUp(self): + self.api, self.client = make_api() + + def test_list_unwraps_data_envelope(self): + self.client.get.return_value.json.return_value = { + "data": [dict(CIRCLE_RESOURCE)] + } + circles = self.api.circles.list() + self.client.get.assert_called_once_with("/api/v1/circles/") + self.assertEqual(len(circles), 1) + self.assertIsInstance(circles[0], campus.model.Circle) + self.assertEqual(circles[0].name, "Design Team") + + def test_list_returns_empty_list_for_empty_envelope(self): + self.client.get.return_value.json.return_value = {"data": []} + self.assertEqual(self.api.circles.list(), []) + + +class TestCirclesNew(unittest.TestCase): + """Circles.new() must send tag/parents instead of dropping them.""" + + def setUp(self): + self.api, self.client = make_api() + + def test_new_sends_all_fields(self): + self.client.post.return_value.json.return_value = dict(CIRCLE_RESOURCE) + circle = self.api.circles.new( + name="Design Team", + tag="project", + description="Handles UI/UX", + parents={"circle_parent01": 15}, + ) + self.client.post.assert_called_once_with( + "/api/v1/circles/", + json={ + "name": "Design Team", + "tag": "project", + "description": "Handles UI/UX", + "parents": {"circle_parent01": 15}, + }, + ) + self.assertIsInstance(circle, campus.model.Circle) + self.assertEqual(circle.id, "circle_ab12cd34") + + def test_new_defaults_description_and_omits_parents(self): + self.client.post.return_value.json.return_value = dict(CIRCLE_RESOURCE) + self.api.circles.new(name="Design Team", tag="project") + self.client.post.assert_called_once_with( + "/api/v1/circles/", + json={ + "name": "Design Team", + "tag": "project", + "description": "", + }, + ) + + +if __name__ == "__main__": + unittest.main()