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
2 changes: 1 addition & 1 deletion campus_python/auth/v1/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def update(
json_data["description"] = description
if redirect_uris is not None:
json_data["redirect_uris"] = redirect_uris
resp = self.client.put(self.make_path(), json=json_data)
resp = self.client.patch(self.make_path(), json=json_data)
# Raise error if status code is not 2XX or 3XX
resp.raise_for_status()
return campus.model.Client.from_resource(resp.json())
Expand Down
69 changes: 69 additions & 0 deletions tests/unit/test_auth_clients.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Contract tests for the auth clients resource (client issue #50).

The campus auth server registers client updates as PATCH-only
(campus/auth/routes/clients.py) and has no PUT routes at all, so
Clients.Client.update() must send PATCH — it previously sent PUT and
every client update failed with 405 Method Not Allowed.
"""

import unittest
from unittest.mock import Mock

import campus.model

from campus_python.auth.v1 import AuthRoot

# Client resource shape emitted by campus weekly
# (campus/auth/routes/clients.py responses).
CLIENT_RESOURCE = {
"id": "cid123",
"created_at": "2026-09-30T06:31:24.582763+00:00",
"name": "campus-cli",
"description": "CLI client",
"is_public": True,
"redirect_uris": ["urn:ietf:wg:oauth:2.0:oob"],
}


def make_auth() -> tuple[AuthRoot, Mock]:
"""Create an AuthRoot backed by a mock JSON client."""
client = Mock()
return AuthRoot(json_client=client), client


class TestClientsUpdatePatchVerb(unittest.TestCase):
"""Clients.Client.update() must send PATCH to the client endpoint."""

def setUp(self):
self.auth, self.client = make_auth()
response = Mock()
response.json.return_value = CLIENT_RESOURCE
self.client.patch.return_value = response

def test_update_sends_patch(self):
client = self.auth.clients["cid123"].update(
redirect_uris=["https://example.org/callback"]
)
self.client.patch.assert_called_once()
self.client.put.assert_not_called()
args, kwargs = self.client.patch.call_args
self.assertEqual(args[0], "/auth/v1/clients/cid123/")
self.assertEqual(
kwargs["json"],
{"redirect_uris": ["https://example.org/callback"]}
)
self.assertIsInstance(client, campus.model.Client)
self.assertEqual(client.id, "cid123")
self.assertEqual(client.name, "campus-cli")

def test_update_omits_unset_fields(self):
"""PATCH bodies must carry only the fields being updated."""
self.auth.clients["cid123"].update(name="new-name")
self.assertEqual(
self.client.patch.call_args.kwargs["json"],
{"name": "new-name"}
)


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