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
4 changes: 2 additions & 2 deletions campus_python/api/v1/assignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def list(self, *, created_by: "str | None" = None) -> "list[campus.model.Assignm
if created_by:
params["created_by"] = created_by

resp = self.client.get(self.make_path(), params=params)
resp = self.client.get(self.make_path(), query=params)
# Raise error if status code is not 2XX or 3XX
resp.raise_for_status()
return [
Expand Down Expand Up @@ -63,7 +63,7 @@ class Assignment(Resource):
@property
def links(self) -> "Assignments.Assignment.Links":
"""Get the links resource for this assignment."""
return Assignments.Assignment.Links(parent=self)
return Assignments.Assignment.Links("links", parent=self)

def delete(self) -> None:
resp = self.client.delete(self.make_path())
Expand Down
2 changes: 1 addition & 1 deletion campus_python/api/v1/circles.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class Circle(Resource):
@property
def members(self) -> "Circles.Circle.CircleMembers":
"""Get the members resource for this circle."""
return Circles.Circle.CircleMembers(parent=self)
return Circles.Circle.CircleMembers("members", parent=self)

def delete(self) -> None:
resp = self.client.delete(self.make_path())
Expand Down
4 changes: 2 additions & 2 deletions campus_python/api/v1/submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,12 @@ class Submission(Resource):
@property
def responses(self) -> "Submissions.Submission.Responses":
"""Get the responses resource for this submission."""
return Submissions.Submission.Responses(parent=self)
return Submissions.Submission.Responses("responses", parent=self)

@property
def feedback(self) -> "Submissions.Submission.Feedback":
"""Get the feedback resource for this submission."""
return Submissions.Submission.Feedback(parent=self)
return Submissions.Submission.Feedback("feedback", parent=self)

def delete(self) -> None:
"""Delete this submission."""
Expand Down
8 changes: 4 additions & 4 deletions campus_python/api/v1/timetable.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,12 @@ class Timetable(Resource):
@property
def entries(self) -> "Timetables.Timetable.Entries":
"""Get the entries resource for this timetable."""
return Timetables.Timetable.Entries(parent=self)
return Timetables.Timetable.Entries("entries", parent=self)

@property
def metadata(self) -> "Timetables.Timetable.Metadata":
"""Get the metadata resource for this timetable."""
return Timetables.Timetable.Metadata(parent=self)
return Timetables.Timetable.Metadata("metadata", parent=self)

def get(self) -> campus.model.Timetable:
"""Get the metadata for this timetable."""
Expand All @@ -160,7 +160,7 @@ class Entries(Resource):

def list(self) -> "list[campus.model.TimetableEntry]":
"""Return a list of all entries for this timetable."""
resp = self.client.get(self.make_path(end_slash=True))
resp = self.client.get(self.make_path())
resp.raise_for_status()
return [
campus.model.TimetableEntry.from_resource(item)
Expand All @@ -176,7 +176,7 @@ def get(self) -> campus.model.TimetableMetadata:

This does not include timetable entries.
"""
resp = self.client.get(self.make_path(end_slash=True))
resp = self.client.get(self.make_path())
resp.raise_for_status()
return campus.model.Timetable.from_resource(resp.json())

Expand Down
158 changes: 158 additions & 0 deletions tests/unit/test_nested_resources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Regression tests for nested resource paths (issue #38).

Nested resources must be constructed with their path part so that their
paths extend the parent resource's path instead of collapsing onto it.

The expected request paths mirror the Campus API routes defined in the
campus-suite repository (branch: weekly):
- POST /api/v1/submissions/<id>/responses
- POST /api/v1/submissions/<id>/feedback
- POST /api/v1/submissions/<id>/submit
- POST /api/v1/assignments/<id>/links
- GET /api/v1/timetable/<id>/entries
- GET /api/v1/timetable/<id>/metadata
- GET /api/v1/circles/<id>/members
"""

import unittest
from unittest.mock import Mock, patch

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


class TestNestedResourcePaths(unittest.TestCase):
"""Nested resource paths must include their own path part."""

def setUp(self):
self.api, _ = make_api()

def test_submission_responses_path(self):
responses = self.api.submissions["sub-1"].responses
self.assertEqual(
responses.make_path(), "/api/v1/submissions/sub-1/responses"
)

def test_submission_feedback_path(self):
feedback = self.api.submissions["sub-1"].feedback
self.assertEqual(
feedback.make_path(), "/api/v1/submissions/sub-1/feedback"
)

def test_assignment_links_path(self):
links = self.api.assignments["asg-1"].links
self.assertEqual(links.make_path(), "/api/v1/assignments/asg-1/links")

def test_timetable_entries_path(self):
entries = self.api.timetable["tt-1"].entries
self.assertEqual(entries.make_path(), "/api/v1/timetable/tt-1/entries")

def test_timetable_metadata_path(self):
metadata = self.api.timetable["tt-1"].metadata
self.assertEqual(metadata.make_path(), "/api/v1/timetable/tt-1/metadata")

def test_circle_members_path(self):
members = self.api.circles["cir-1"].members
self.assertEqual(members.make_path(), "/api/v1/circles/cir-1/members")

def test_nested_paths_extend_parent_path(self):
"""A nested resource path must not collapse onto its parent's path."""
submission = self.api.submissions["sub-1"]
for child in (submission.responses, submission.feedback):
self.assertTrue(child.make_path().startswith(submission.make_path()))
self.assertNotEqual(child.make_path(), submission.make_path())

assignment = self.api.assignments["asg-1"]
self.assertTrue(assignment.links.make_path().startswith(assignment.make_path()))
self.assertNotEqual(assignment.links.make_path(), assignment.make_path())

timetable = self.api.timetable["tt-1"]
for child in (timetable.entries, timetable.metadata):
self.assertTrue(child.make_path().startswith(timetable.make_path()))
self.assertNotEqual(child.make_path(), timetable.make_path())

circle = self.api.circles["cir-1"]
self.assertTrue(circle.members.make_path().startswith(circle.make_path()))
self.assertNotEqual(circle.members.make_path(), circle.make_path())


class TestNestedResourceRequests(unittest.TestCase):
"""Nested resource methods must request their own API endpoints."""

def setUp(self):
self.api, self.client = make_api()

def test_responses_add_posts_to_responses_endpoint(self):
self.api.submissions["sub-1"].responses.add(
question_id="q-1", response_text="answer"
)
self.client.post.assert_called_once_with(
"/api/v1/submissions/sub-1/responses",
json={"question_id": "q-1", "response_text": "answer"},
)

def test_feedback_add_posts_to_feedback_endpoint(self):
self.api.submissions["sub-1"].feedback.add(
question_id="q-1", feedback_text="good"
)
self.client.post.assert_called_once_with(
"/api/v1/submissions/sub-1/feedback",
json={"question_id": "q-1", "feedback_text": "good"},
)

def test_submit_posts_to_submit_endpoint(self):
self.api.submissions["sub-1"].submit()
self.client.post.assert_called_once_with(
"/api/v1/submissions/sub-1/submit"
)

def test_links_add_posts_to_links_endpoint(self):
self.api.assignments["asg-1"].links.add(
course_id="course-1", coursework_id="cw-1"
)
self.client.post.assert_called_once_with(
"/api/v1/assignments/asg-1/links",
json={"course_id": "course-1", "coursework_id": "cw-1"},
)

def test_entries_list_gets_entries_endpoint(self):
self.client.get.return_value.json.return_value = {"entries": []}
self.api.timetable["tt-1"].entries.list()
self.client.get.assert_called_once_with(
"/api/v1/timetable/tt-1/entries"
)

def test_metadata_get_gets_metadata_endpoint(self):
with patch.object(
campus.model.Timetable, "from_resource", return_value=Mock()
):
self.api.timetable["tt-1"].metadata.get()
self.client.get.assert_called_once_with(
"/api/v1/timetable/tt-1/metadata"
)

def test_members_list_gets_members_endpoint(self):
self.client.get.return_value.json.return_value = {"members": {}}
self.api.circles["cir-1"].members.list()
self.client.get.assert_called_once_with(
"/api/v1/circles/cir-1/members"
)

def test_assignments_list_passes_query_argument(self):
"""Assignments.list must pass filters via the client's query argument."""
self.client.get.return_value.json.return_value = {"data": []}
self.api.assignments.list(created_by="teacher-1")
self.client.get.assert_called_once_with(
"/api/v1/assignments/", query={"created_by": "teacher-1"}
)


if __name__ == "__main__":
unittest.main()
Loading