From c1d4a7864cc12f12de72171f8e125cf3c0b2b7b3 Mon Sep 17 00:00:00 2001 From: JS Ng Date: Wed, 30 Sep 2026 22:32:32 +0800 Subject: [PATCH] fix(auth): send PATCH for client updates (issue #50) Clients.update() sent PUT, but the campus auth server registers client update as PATCH-only and has no PUT routes, so every client update failed with 405 Method Not Allowed. Switches to PATCH and pins the verb and partial-body contract with unit tests. --- campus_python/auth/v1/clients.py | 2 +- tests/unit/test_auth_clients.py | 69 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_auth_clients.py diff --git a/campus_python/auth/v1/clients.py b/campus_python/auth/v1/clients.py index b2918f8..1659768 100644 --- a/campus_python/auth/v1/clients.py +++ b/campus_python/auth/v1/clients.py @@ -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()) diff --git a/tests/unit/test_auth_clients.py b/tests/unit/test_auth_clients.py new file mode 100644 index 0000000..156ad0e --- /dev/null +++ b/tests/unit/test_auth_clients.py @@ -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()