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
19 changes: 18 additions & 1 deletion campus_python/auth/v1/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

This mirrors the style used in `clients.py` and `credentials.py` and
implements the minimal methods used by the Flask routes: list, new,
and per-user activate/delete/get.
and per-user activate/delete/get/update.
"""

import campus.model
Expand Down Expand Up @@ -53,3 +53,20 @@ def get(self) -> campus.model.User:
resp = self.client.get(self.make_path())
resp.raise_for_status()
return campus.model.User.from_resource(resp.json())

def update(self, *, name: str) -> campus.model.User:
"""Update the user's display name (campus-cli#42).

The server PATCH accepts name only: a user's id IS its
email (identity), and activation runs through the
/activate endpoint, so no other field is patchable.

Args:
name: New display name

Returns:
The updated User
"""
resp = self.client.patch(self.make_path(), json={"name": name})
resp.raise_for_status()
return campus.model.User.from_resource(resp.json())
62 changes: 62 additions & 0 deletions tests/unit/test_auth_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Contract tests for the auth users resource (campus-cli#42).

The campus auth server gates user management behind the users:*
management scopes and implements PATCH /users/{id} as rename-only
(a user's id IS its email), so Users.User.update() must send PATCH
with exactly {"name": ...} and no other field.
"""

import unittest
from unittest.mock import Mock

import campus.model

from campus_python.auth.v1 import AuthRoot

# User resource shape emitted by campus weekly
# (campus/auth/routes/users.py responses).
USER_RESOURCE = {
"id": "user@example.com",
"created_at": "2026-09-30T06:31:24.582763+00:00",
"email": "user@example.com",
"name": "Test User",
"activated_at": None,
}


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


class TestUsersUpdatePatchVerb(unittest.TestCase):
"""Users.User.update() must send PATCH carrying only the name."""

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

def test_update_sends_patch_with_name(self):
user = self.auth.users["user@example.com"].update(name="New Name")
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/users/user@example.com/")
self.assertEqual(kwargs["json"], {"name": "New Name"})
self.assertIsInstance(user, campus.model.User)
self.assertEqual(user.id, "user@example.com")
self.assertEqual(user.name, "Test User")

def test_update_returns_updated_user(self):
response = Mock()
response.json.return_value = {**USER_RESOURCE, "name": "Renamed"}
self.client.patch.return_value = response
user = self.auth.users["user@example.com"].update(name="Renamed")
self.assertEqual(user.name, "Renamed")


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