diff --git a/campus_python/api/v1/assignments.py b/campus_python/api/v1/assignments.py index a174135..53525fc 100644 --- a/campus_python/api/v1/assignments.py +++ b/campus_python/api/v1/assignments.py @@ -76,6 +76,13 @@ def get(self) -> campus.model.Assignment: return campus.model.Assignment.from_resource(resp.json()) def update(self, **updates) -> None: + """Update this assignment with the given fields. + + The server rejects an empty PATCH body, so at least one + field must be provided. + """ + if not updates: + raise ValueError("At least one field must be provided for update") resp = self.client.patch(self.make_path(), json=updates) resp.raise_for_status() return None diff --git a/campus_python/api/v1/circles.py b/campus_python/api/v1/circles.py index 2eb243f..a50f197 100644 --- a/campus_python/api/v1/circles.py +++ b/campus_python/api/v1/circles.py @@ -64,8 +64,27 @@ def get(self) -> campus.model.Circle: resp.raise_for_status() return campus.model.Circle.from_resource(resp.json()) - def update(self, **updates) -> None: - resp = self.client.patch(self.make_path(), json=updates) + def update( + self, + *, + name: str, + description: "str | None" = None, + ) -> None: + """Update this circle. + + The server requires name on every circle PATCH and returns + 422 without it, so it is a required parameter here. + Description is unchanged when omitted. + + Args: + name: New circle name (must not collide with an + existing circle's name) + description: New description; omitted leaves it unchanged + """ + payload: dict[str, Any] = {"name": name} + if description is not None: + payload["description"] = description + resp = self.client.patch(self.make_path(), json=payload) resp.raise_for_status() return None @@ -94,3 +113,18 @@ def remove(self, member_id: str) -> None: ) resp.raise_for_status() return None + + def set(self, member_id: str, access_value: int) -> None: + """Create or update a member's access value (upsert). + + Unlike add()/remove(), which target the /members/add and + /members/remove action routes, this PATCHes the members + collection directly — the server's set semantics apply + (no error when the access value is unchanged). + """ + resp = self.client.patch( + self.make_path(end_slash=True), + json={"member_id": member_id, "access_value": access_value} + ) + resp.raise_for_status() + return None diff --git a/campus_python/api/v1/timetable.py b/campus_python/api/v1/timetable.py index 9f49bd2..2baecbe 100644 --- a/campus_python/api/v1/timetable.py +++ b/campus_python/api/v1/timetable.py @@ -101,11 +101,21 @@ def new(self, metadata: dict, data: dict) -> dict: Args: metadata: Metadata for the timetable (e.g., start_date, end_date) - data: Timetable data (e.g., entries) + data: Timetable data; must contain the key `lessongroups` + (not `lesson_groups` — the server rejects the request + with 400 otherwise) Returns: The created timetable resource """ + if "lessongroups" not in data: + raise ValueError( + "data must contain the key 'lessongroups'" + + ( + " ('lesson_groups' found instead — rename it)" + if "lesson_groups" in data else "" + ) + ) resp = self.client.post( self.make_path(), json={ diff --git a/tests/unit/test_request_guards.py b/tests/unit/test_request_guards.py new file mode 100644 index 0000000..d392de0 --- /dev/null +++ b/tests/unit/test_request_guards.py @@ -0,0 +1,115 @@ +"""Tests for client-side request guards mirroring server validation (#75). + +Three payloads the weekly server always rejects used to pass through +the client and fail with surprise 4xx responses: +- PATCH /circles// requires `name` +- PATCH /assignments// rejects an empty body +- POST /timetable/ requires the key `lessongroups` in `data` +The client now validates these locally (and exposes the members PATCH +upsert the server provides). +""" + +import unittest +from unittest.mock import Mock + +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 + + +class TestCirclesUpdateGuard(unittest.TestCase): + """Circle.update() must always send the server-required name.""" + + def setUp(self): + self.api, self.client = make_api() + + def test_update_requires_name(self): + with self.assertRaises(TypeError): + self.api.circles["cir-1"].update() + self.client.patch.assert_not_called() + + def test_update_sends_name(self): + self.api.circles["cir-1"].update(name="Design Team") + self.client.patch.assert_called_once_with( + "/api/v1/circles/cir-1/", + json={"name": "Design Team"}, + ) + + def test_update_sends_name_and_description(self): + self.api.circles["cir-1"].update( + name="Design Team", description="d" + ) + self.assertEqual( + self.client.patch.call_args.kwargs["json"], + {"name": "Design Team", "description": "d"}, + ) + + def test_members_set_patches_members_collection(self): + """members.set() upserts access via PATCH /members/.""" + self.api.circles["cir-1"].members.set(member_id="cir-2", access_value=7) + self.client.patch.assert_called_once_with( + "/api/v1/circles/cir-1/members/", + json={"member_id": "cir-2", "access_value": 7}, + ) + + +class TestAssignmentsUpdateGuard(unittest.TestCase): + """Assignment.update() must refuse the empty body the server 400s.""" + + def setUp(self): + self.api, self.client = make_api() + + def test_update_requires_at_least_one_field(self): + with self.assertRaises(ValueError): + self.api.assignments["asg-1"].update() + self.client.patch.assert_not_called() + + def test_update_sends_provided_fields(self): + self.api.assignments["asg-1"].update(title="New title") + self.client.patch.assert_called_once_with( + "/api/v1/assignments/asg-1/", + json={"title": "New title"}, + ) + + +class TestTimetableNewGuard(unittest.TestCase): + """Timetables.new() must require the lessongroups data key.""" + + def setUp(self): + self.api, self.client = make_api() + + def test_new_requires_lessongroups_key(self): + with self.assertRaises(ValueError): + self.api.timetable.new( + {"start_date": "2026-01-01", "end_date": "2026-02-01"}, + {"entries": []}, + ) + self.client.post.assert_not_called() + + def test_new_suggests_rename_for_lesson_groups(self): + with self.assertRaises(ValueError) as ctx: + self.api.timetable.new({}, {"lesson_groups": []}) + self.assertIn("rename", str(ctx.exception)) + self.client.post.assert_not_called() + + def test_new_passes_lessongroups_through(self): + self.client.post.return_value.json.return_value = {"data": {}} + data = {"lessongroups": [], "entries": []} + self.api.timetable.new( + {"start_date": "2026-01-01", "end_date": "2026-02-01"}, data + ) + self.client.post.assert_called_once_with( + "/api/v1/timetable/", + json={ + "metadata": {"start_date": "2026-01-01", "end_date": "2026-02-01"}, + "data": data, + }, + ) + + +if __name__ == "__main__": + unittest.main()