Merge pull request #1248 from trheyi/main

Add member profile retrieval functionality
This commit is contained in:
Max 2025-10-28 16:19:51 +08:00 committed by GitHub
commit 20b0fd96b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 295 additions and 0 deletions

View file

@ -2038,6 +2038,173 @@ func TestMemberUpdateRobot(t *testing.T) {
}
}
// TestMemberProfileGet tests the GET /user/teams/:team_id/members/:user_id/profile endpoint
func TestMemberProfileGet(t *testing.T) {
// Initialize test environment
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Register a test client for OAuth authentication
testClient := testutils.RegisterTestClient(t, "Member Profile Get Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, testClient.ClientID)
// Obtain access token with root permission
tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile system:root")
// Create a test team
createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Member Profile Get Test Team")
teamID := getTeamID(createdTeam)
// The creator is automatically a member, so we can use their user_id
userID := tokenInfo.UserID
// Update the member profile first to have test data
provider := testutils.GetUserProvider(t)
ctx := context.Background()
updateData := maps.MapStrAny{
"display_name": "Test Display Name",
"bio": "Test bio description",
"avatar": "https://example.com/test-avatar.png",
"email": "test-member@example.com",
}
err := provider.UpdateMember(ctx, teamID, userID, updateData)
assert.NoError(t, err, "Should update member profile for testing")
testCases := []struct {
name string
teamID string
userID string
headers map[string]string
expectCode int
expectMsg string
validateFn func(*testing.T, map[string]interface{}) // Optional validation function
}{
{
"get profile without authentication",
teamID,
userID,
map[string]string{},
401,
"should require authentication",
nil,
},
{
"get own profile successfully",
teamID,
userID,
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
200,
"should return own profile successfully",
func(t *testing.T, profile map[string]interface{}) {
// Verify profile structure
assert.Contains(t, profile, "user_id", "Should have user_id")
assert.Contains(t, profile, "team_id", "Should have team_id")
assert.Contains(t, profile, "display_name", "Should have display_name")
assert.Contains(t, profile, "bio", "Should have bio")
assert.Contains(t, profile, "avatar", "Should have avatar")
assert.Contains(t, profile, "email", "Should have email")
// Verify values
assert.Equal(t, userID, profile["user_id"], "Should have correct user_id")
assert.Equal(t, teamID, profile["team_id"], "Should have correct team_id")
assert.Equal(t, "Test Display Name", profile["display_name"], "Should have correct display_name")
assert.Equal(t, "Test bio description", profile["bio"], "Should have correct bio")
assert.Equal(t, "https://example.com/test-avatar.png", profile["avatar"], "Should have correct avatar")
assert.Equal(t, "test-member@example.com", profile["email"], "Should have correct email")
},
},
{
"get profile from non-existent team",
"non-existent-team-id",
userID,
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
404,
"should return not found for non-existent team",
nil,
},
{
"get profile for non-existent user",
teamID,
"non-existent-user-id",
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
404,
"should return not found for non-existent user",
nil,
},
{
"get profile with minimal data",
teamID,
userID,
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
200,
"should return profile even with minimal data",
func(t *testing.T, profile map[string]interface{}) {
// Should always have these fields, even if empty
assert.Contains(t, profile, "user_id", "Should have user_id field")
assert.Contains(t, profile, "team_id", "Should have team_id field")
assert.Contains(t, profile, "display_name", "Should have display_name field")
assert.Contains(t, profile, "bio", "Should have bio field")
assert.Contains(t, profile, "avatar", "Should have avatar field")
assert.Contains(t, profile, "email", "Should have email field")
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
requestURL := serverURL + baseURL + "/user/teams/" + tc.teamID + "/members/" + tc.userID + "/profile"
req, err := http.NewRequest("GET", requestURL, nil)
assert.NoError(t, err, "Should create HTTP request")
// Add headers
for key, value := range tc.headers {
req.Header.Set(key, value)
}
client := &http.Client{}
resp, err := client.Do(req)
assert.NoError(t, err, "HTTP request should succeed")
if resp != nil {
defer resp.Body.Close()
assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d for %s", tc.expectCode, tc.name)
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err, "Should read response body")
if resp.StatusCode == 200 {
// Parse response as profile object
var profile map[string]interface{}
err = json.Unmarshal(body, &profile)
assert.NoError(t, err, "Should parse JSON response")
// Run custom validation if provided
if tc.validateFn != nil {
tc.validateFn(t, profile)
}
}
t.Logf("Member profile get test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body))
}
})
}
}
// TestMemberProfileUpdate tests the PUT /user/teams/:team_id/members/:user_id/profile endpoint
func TestMemberProfileUpdate(t *testing.T) {
// Initialize test environment

View file

@ -575,6 +575,62 @@ func GinMemberUpdate(c *gin.Context) {
response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Member updated successfully"})
}
// GinMemberGetProfile handles GET /teams/:team_id/members/:member_id/profile - Get member profile
// Note: :member_id in the route actually contains user_id for profile retrieval
func GinMemberGetProfile(c *gin.Context) {
// Get authorized user info
authInfo := authorized.GetInfo(c)
if authInfo == nil || authInfo.UserID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidClient.Code,
ErrorDescription: "User not authenticated",
}
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
return
}
teamID := c.Param("id")
// For profile retrieval, :member_id parameter contains the user_id (not member_id)
memberUserID := c.Param("member_id")
if teamID == "" || memberUserID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Team ID and User ID are required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Call business logic
profile, err := memberGetProfile(c.Request.Context(), authInfo.UserID, teamID, memberUserID)
if err != nil {
log.Error("Failed to get member profile: %v", err)
// Check error type for appropriate response
if strings.Contains(err.Error(), "not found") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Member not found",
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
} else if strings.Contains(err.Error(), "access denied") {
errorResp := &response.ErrorResponse{
Code: response.ErrAccessDenied.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusForbidden, errorResp)
} else {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to get member profile",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
}
return
}
response.RespondWithSuccess(c, http.StatusOK, profile)
}
// GinMemberUpdateProfile handles PUT /teams/:team_id/members/:member_id/profile - Update member profile
// Note: :member_id in the route actually contains user_id for profile updates
func GinMemberUpdateProfile(c *gin.Context) {
@ -877,6 +933,38 @@ func ProcessMemberUpdate(process *process.Process) interface{} {
}
}
// ProcessMemberGetProfile user.member.profile.get Member profile get processor
// Args[0] string: team_id
// Args[1] string: user_id (not member_id)
// Return: map: Member profile data
func ProcessMemberGetProfile(process *process.Process) interface{} {
process.ValidateArgNums(2)
// Get user_id from session
requestUserID := GetUserIDFromSession(process)
teamID := process.ArgsString(0)
memberUserID := process.ArgsString(1)
if teamID == "" || memberUserID == "" {
exception.New("team_id and user_id are required", 400).Throw()
}
// Get context
ctx := process.Context
if ctx == nil {
ctx = context.Background()
}
// Call business logic
result, err := memberGetProfile(ctx, requestUserID, teamID, memberUserID)
if err != nil {
exception.New("failed to get member profile: %s", 500, err.Error()).Throw()
}
return result
}
// ProcessMemberUpdateProfile user.member.profile.update Member profile update processor
// Args[0] string: team_id
// Args[1] string: user_id (member's user_id)
@ -1256,6 +1344,44 @@ func memberUpdate(ctx context.Context, userID, teamID, memberID string, updateDa
return nil
}
// memberGetProfile handles the business logic for getting member profile information
func memberGetProfile(ctx context.Context, requestUserID, teamID, memberUserID string) (maps.MapStrAny, error) {
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
return nil, fmt.Errorf("failed to get user provider: %w", err)
}
// Check if member exists using team_id and user_id
member, err := provider.GetMember(ctx, teamID, memberUserID)
if err != nil {
return nil, fmt.Errorf("member not found: %w", err)
}
// Verify member exists
if member == nil {
return nil, fmt.Errorf("member not found in the specified team")
}
// Check if the requesting user is the member themselves
// Only members can view their own profile
if memberUserID != requestUserID {
return nil, fmt.Errorf("access denied: you can only view your own profile")
}
// Return member profile data (display_name, bio, avatar, email)
profileData := maps.MapStrAny{
"user_id": memberUserID,
"team_id": teamID,
"display_name": member["display_name"],
"bio": member["bio"],
"avatar": member["avatar"],
"email": member["email"],
}
return profileData, nil
}
// memberUpdateProfile handles the business logic for updating member profile information
func memberUpdateProfile(ctx context.Context, requestUserID, teamID, memberUserID string, req UpdateMemberProfileRequest) error {
// Get user provider instance

View file

@ -72,6 +72,7 @@ func init() {
"member.list": ProcessMemberList,
"member.get": ProcessMemberGet,
"member.update": ProcessMemberUpdate,
"member.profile.get": ProcessMemberGetProfile,
"member.profile.update": ProcessMemberUpdateProfile,
"member.delete": ProcessMemberDelete,
@ -152,6 +153,7 @@ func attachTeam(group *gin.RouterGroup, oauth types.OAuth) {
team.GET("/:id/members/check-robot-email", GinMemberCheckRobotEmail) // GET /api/user/teams/:id/members/check-robot-email?robot_email=xxx - Check if robot email exists globally
team.POST("/:id/members/robots", GinMemberCreateRobot) // POST /api/user/teams/:id/members/robots - Add robot member
team.PUT("/:id/members/robots/:member_id", GinMemberUpdateRobot) // PUT /api/user/teams/:id/members/robots/:member_id - Update robot member
team.GET("/:id/members/:member_id/profile", GinMemberGetProfile) // GET /api/user/teams/:id/members/:member_id/profile - Get member profile (display_name, bio, avatar, email)
team.PUT("/:id/members/:member_id/profile", GinMemberUpdateProfile) // PUT /api/user/teams/:id/members/:member_id/profile - Update member profile (display_name, bio, avatar, email)
team.GET("/:id/members/:member_id", GinMemberGet) // GET /api/user/teams/:id/members/:member_id - Get member details
team.PUT("/:id/members/:member_id", GinMemberUpdate) // PUT /api/user/teams/:id/members/:member_id - Update member (admin: role, status)