@@ -853,6 +853,261 @@ def test_put_accepts_valid_full_course_key_scope(self, _mock_exists, _mock_assig
853853 self .assertEqual (len (response .data ["completed" ]), 1 )
854854
855855
856+ @ddt
857+ class TestTeamMembersAPIView (ViewTestMixin ):
858+ """
859+ Test suite for TeamMembersAPIView.
860+
861+ Setup summary (from ViewTestMixin.setUpClass):
862+ lib:Org1:LIB1 → admin_1 (library_admin), regular_1 (library_user), regular_2 (library_user) [3 users]
863+ lib:Org2:LIB2 → admin_2 (library_user), regular_3 (library_user), regular_4 (library_user) [3 users]
864+ lib:Org3:LIB3 → admin_3 (library_admin), regular_5 (library_admin), regular_6 (library_author),
865+ regular_7 (library_contributor), regular_8 (library_user) [5 users]
866+
867+ Total unique users with assignments: 11
868+ (admin_1..3 are staff/superuser; regular_1..8 are plain users)
869+
870+ Visibility via filter_allowed_assignments:
871+ - Staff/superuser: sees all 11 users (is_admin_or_superuser_check grants VIEW_LIBRARY on lib scopes)
872+ - regular_1 (library_user in Org1:LIB1): VIEW_LIBRARY granted → sees Org1 members (3)
873+ - regular_3 (library_user in Org2:LIB2): VIEW_LIBRARY granted → sees Org2 members (3)
874+ - regular_9 (no assignments): sees 0 users
875+ """
876+
877+ def setUp (self ):
878+ """Set up test fixtures."""
879+ super ().setUp ()
880+ self .url = reverse ("openedx_authz:user-list" )
881+ self .get_user_map_patcher = patch (
882+ "openedx_authz.rest_api.utils.get_user_map" ,
883+ side_effect = get_user_map_without_profile ,
884+ )
885+ self .get_user_map_patcher .start ()
886+ self .addCleanup (self .get_user_map_patcher .stop )
887+
888+ # ------------------------------------------------------------------ #
889+ # Visibility: calling user only sees assignments it has view access to #
890+ # ------------------------------------------------------------------ #
891+
892+ @data (
893+ # Staff/superuser sees all users across all scopes
894+ ("admin_1" , 11 ),
895+ # regular_1 has LIBRARY_USER in lib:Org1:LIB1 (VIEW_LIBRARY granted) → sees only Org1 members
896+ ("regular_1" , 3 ),
897+ # regular_3 has LIBRARY_USER in lib:Org2:LIB2 → sees only Org2 members
898+ ("regular_3" , 3 ),
899+ # regular_9 has no assignments → sees nothing
900+ ("regular_9" , 0 ),
901+ )
902+ @unpack
903+ def test_visibility_limited_to_accessible_scopes (self , username : str , expected_count : int ):
904+ """Calling user only sees assignments for scopes it has view access to.
905+
906+ Expected result:
907+ - Staff/superuser sees all users across all scopes.
908+ - Regular users only see members of scopes they can view.
909+ - Users with no assignments see no results.
910+ """
911+ user = User .objects .get (username = username )
912+ self .client .force_authenticate (user = user )
913+
914+ response = self .client .get (self .url )
915+
916+ self .assertEqual (response .status_code , status .HTTP_200_OK )
917+ self .assertEqual (response .data ["count" ], expected_count )
918+
919+ def test_unauthenticated_returns_401 (self ):
920+ """Unauthenticated requests are rejected.
921+
922+ Expected result:
923+ - Returns 401 UNAUTHORIZED.
924+ """
925+ self .client .force_authenticate (user = None )
926+
927+ response = self .client .get (self .url )
928+
929+ self .assertEqual (response .status_code , status .HTTP_401_UNAUTHORIZED )
930+
931+ # ------------------------------------------------------------------ #
932+ # Filter by scopes #
933+ # ------------------------------------------------------------------ #
934+
935+ @data (
936+ # Single scope
937+ ("lib:Org1:LIB1" , 3 ),
938+ ("lib:Org2:LIB2" , 3 ),
939+ ("lib:Org3:LIB3" , 5 ),
940+ # Multiple scopes (users are unique per scope, no overlap)
941+ ("lib:Org1:LIB1,lib:Org2:LIB2" , 6 ),
942+ ("lib:Org1:LIB1,lib:Org3:LIB3" , 8 ),
943+ ("lib:Org1:LIB1,lib:Org2:LIB2,lib:Org3:LIB3" , 11 ),
944+ # Non-existent scope returns no results
945+ ("lib:Org99:NOLIB" , 0 ),
946+ )
947+ @unpack
948+ def test_filter_by_scopes (self , scopes : str , expected_count : int ):
949+ """Results are filtered to the requested scopes.
950+
951+ Expected result:
952+ - Only users with assignments in the given scope(s) are returned.
953+ - Multiple scopes are OR-combined.
954+ """
955+ response = self .client .get (self .url , {"scopes" : scopes })
956+
957+ self .assertEqual (response .status_code , status .HTTP_200_OK )
958+ self .assertEqual (response .data ["count" ], expected_count )
959+
960+ # ------------------------------------------------------------------ #
961+ # Filter by orgs #
962+ # ------------------------------------------------------------------ #
963+
964+ @data (
965+ # Single org
966+ ("Org1" , 3 ),
967+ ("Org2" , 3 ),
968+ ("Org3" , 5 ),
969+ # Multiple orgs
970+ ("Org1,Org2" , 6 ),
971+ ("Org1,Org3" , 8 ),
972+ ("Org1,Org2,Org3" , 11 ),
973+ # Non-existent org returns no results
974+ ("OrgX" , 0 ),
975+ )
976+ @unpack
977+ def test_filter_by_orgs (self , orgs : str , expected_count : int ):
978+ """Results are filtered to the requested orgs.
979+
980+ Expected result:
981+ - Only users with assignments in the given org(s) are returned.
982+ - Multiple orgs are OR-combined.
983+ """
984+ response = self .client .get (self .url , {"orgs" : orgs })
985+
986+ self .assertEqual (response .status_code , status .HTTP_200_OK )
987+ self .assertEqual (response .data ["count" ], expected_count )
988+
989+ # ------------------------------------------------------------------ #
990+ # Search (username, full_name, email) #
991+ # ------------------------------------------------------------------ #
992+
993+ @data (
994+ # Exact username match
995+ ("admin_1" , 1 ),
996+ # Partial username match
997+ ("admin" , 3 ),
998+ ("regular" , 8 ),
999+ # Email match
1000+ 1001+ ("@example.com" , 11 ),
1002+ # No match
1003+ ("nonexistent" , 0 ),
1004+ )
1005+ @unpack
1006+ def test_search (self , search : str , expected_count : int ):
1007+ """Search filters by username, full_name, or email (case-insensitive).
1008+
1009+ Expected result:
1010+ - Returns only users whose username, full_name, or email contains the search term.
1011+ """
1012+ response = self .client .get (self .url , {"search" : search })
1013+
1014+ self .assertEqual (response .status_code , status .HTTP_200_OK )
1015+ self .assertEqual (response .data ["count" ], expected_count )
1016+
1017+ # ------------------------------------------------------------------ #
1018+ # Sorting #
1019+ # ------------------------------------------------------------------ #
1020+
1021+ @data (
1022+ ("username" , "asc" ),
1023+ ("username" , "desc" ),
1024+ ("email" , "asc" ),
1025+ ("email" , "desc" ),
1026+ ("full_name" , "asc" ),
1027+ ("full_name" , "desc" ),
1028+ )
1029+ @unpack
1030+ def test_sorting (self , sort_by : str , order : str ):
1031+ """Results can be sorted by username, full_name, or email in asc/desc order.
1032+
1033+ Expected result:
1034+ - Returns 200 OK.
1035+ - Results are ordered according to the requested field and direction.
1036+ """
1037+ response = self .client .get (self .url , {"sort_by" : sort_by , "order" : order })
1038+
1039+ self .assertEqual (response .status_code , status .HTTP_200_OK )
1040+ values = [item [sort_by ] for item in response .data ["results" ]]
1041+ expected = sorted (values , key = lambda v : (v or "" ).lower (), reverse = (order == "desc" ))
1042+ self .assertEqual (values , expected )
1043+
1044+ @data (
1045+ {"sort_by" : "invalid" },
1046+ {"order" : "ascending" },
1047+ {"order" : "descending" },
1048+ )
1049+ def test_sorting_invalid_params (self , query_params : dict ):
1050+ """Invalid sort_by or order values return 400.
1051+
1052+ Expected result:
1053+ - Returns 400 BAD REQUEST.
1054+ """
1055+ response = self .client .get (self .url , query_params )
1056+
1057+ self .assertEqual (response .status_code , status .HTTP_400_BAD_REQUEST )
1058+
1059+ # ------------------------------------------------------------------ #
1060+ # Pagination #
1061+ # ------------------------------------------------------------------ #
1062+
1063+ @data (
1064+ ({"page" : 1 , "page_size" : 5 }, 5 , True ),
1065+ ({"page" : 2 , "page_size" : 5 }, 5 , True ),
1066+ ({"page" : 3 , "page_size" : 5 }, 1 , False ),
1067+ ({"page" : 1 , "page_size" : 11 }, 11 , False ),
1068+ ({"page" : 1 , "page_size" : 6 }, 6 , True ),
1069+ )
1070+ @unpack
1071+ def test_pagination (self , query_params : dict , expected_page_count : int , has_next : bool ):
1072+ """Results are paginated correctly.
1073+
1074+ Expected result:
1075+ - Returns 200 OK.
1076+ - Page contains the expected number of items.
1077+ - `next` link is present only when more pages exist.
1078+ """
1079+ response = self .client .get (self .url , query_params )
1080+
1081+ self .assertEqual (response .status_code , status .HTTP_200_OK )
1082+ self .assertEqual (response .data ["count" ], 11 )
1083+ self .assertEqual (len (response .data ["results" ]), expected_page_count )
1084+ if has_next :
1085+ self .assertIsNotNone (response .data ["next" ])
1086+ else :
1087+ self .assertIsNone (response .data ["next" ])
1088+
1089+ # ------------------------------------------------------------------ #
1090+ # Response shape #
1091+ # ------------------------------------------------------------------ #
1092+
1093+ def test_response_shape (self ):
1094+ """Each result item contains the expected fields.
1095+
1096+ Expected result:
1097+ - Returns 200 OK.
1098+ - Each item has username, full_name, email, and assignation_count.
1099+ """
1100+ response = self .client .get (self .url , {"scopes" : "lib:Org1:LIB1" })
1101+
1102+ self .assertEqual (response .status_code , status .HTTP_200_OK )
1103+ for item in response .data ["results" ]:
1104+ self .assertIn ("username" , item )
1105+ self .assertIn ("full_name" , item )
1106+ self .assertIn ("email" , item )
1107+ self .assertIn ("assignation_count" , item )
1108+ self .assertEqual (item ["assignation_count" ], 1 )
1109+
1110+
8561111@ddt
8571112class TestRoleListView (ViewTestMixin ):
8581113 """Test suite for RoleListView."""
0 commit comments