diff --git a/openapi/tests/user/member_test.go b/openapi/tests/user/member_test.go index eb215d3c..79dd596e 100644 --- a/openapi/tests/user/member_test.go +++ b/openapi/tests/user/member_test.go @@ -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 diff --git a/openapi/user/member.go b/openapi/user/member.go index ebab442d..8bbc7e6f 100644 --- a/openapi/user/member.go +++ b/openapi/user/member.go @@ -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 diff --git a/openapi/user/user.go b/openapi/user/user.go index 50144eab..cfb60776 100644 --- a/openapi/user/user.go +++ b/openapi/user/user.go @@ -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)