Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions campus_python/api/v1/assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 36 additions & 2 deletions campus_python/api/v1/circles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
12 changes: 11 additions & 1 deletion campus_python/api/v1/timetable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down
115 changes: 115 additions & 0 deletions tests/unit/test_request_guards.py
Original file line number Diff line number Diff line change
@@ -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/<id>/ requires `name`
- PATCH /assignments/<id>/ 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()
Loading