Implement robot member update functionality with comprehensive validation

- Added the `UpdateRobotMember` method to handle updates for robot members, including validation for member existence, type checks, and email uniqueness.
- Introduced a new `UpdateRobotMemberRequest` structure to facilitate updates via the API, allowing for modifications to various fields such as `robot_email`, `status`, and `autonomous_mode`.
- Enhanced the `GinMemberUpdateRobot` handler to process update requests, ensuring proper authentication and error handling for various scenarios.
- Expanded test coverage with new test cases for the robot member update functionality, validating successful updates, error conditions, and edge cases.
- Refactored existing member management logic to integrate the new update capabilities, improving overall system robustness and user experience.
This commit is contained in:
Max 2025-10-27 12:05:11 +08:00
parent 7de562f80d
commit 1e01ed768e
7 changed files with 902 additions and 0 deletions

View file

@ -386,6 +386,83 @@ func (u *DefaultUser) CreateRobotMember(ctx context.Context, teamID string, robo
return u.CreateMember(ctx, memberData) return u.CreateMember(ctx, memberData)
} }
// UpdateRobotMember updates a robot member by member_id
func (u *DefaultUser) UpdateRobotMember(ctx context.Context, memberID string, robotData maps.MapStrAny) error {
// First, verify the member exists and is a robot
existingMember, err := u.GetMemberByMemberID(ctx, memberID)
if err != nil {
return fmt.Errorf("failed to get member: %w", err)
}
// Verify this is a robot member
memberType, exists := existingMember["member_type"]
if !exists || memberType != "robot" {
return fmt.Errorf("member %s is not a robot member", memberID)
}
// Check if robot_email already exists globally (if updating robot_email)
if robotEmail, exists := robotData["robot_email"]; exists && robotEmail != nil && robotEmail != "" {
robotEmailStr := fmt.Sprintf("%v", robotEmail)
// Only check uniqueness if the email is actually changing
currentEmail, _ := existingMember["robot_email"]
if currentEmail != robotEmailStr {
m := model.Select(u.memberModel)
existingMembers, err := m.Get(model.QueryParam{
Select: []interface{}{"id", "member_id"},
Wheres: []model.QueryWhere{
{Column: "robot_email", Value: robotEmailStr},
},
Limit: 1,
})
if err != nil {
return fmt.Errorf("failed to check robot_email uniqueness: %w", err)
}
if len(existingMembers) > 0 {
// Check if it's not the same member
existingMemberID, _ := existingMembers[0]["member_id"]
if existingMemberID != memberID {
return fmt.Errorf("robot_email %s already exists", robotEmailStr)
}
}
}
}
memberData := maps.MapStrAny{}
// Copy shared profile fields (used by both users and robots)
profileFields := []string{
"display_name", "bio", "avatar", "email",
}
for _, field := range profileFields {
if value, exists := robotData[field]; exists {
memberData[field] = value
}
}
// Copy robot-specific fields
robotFields := []string{
"role_id", "system_prompt", "manager_id", "robot_email", "authorized_senders", "email_filter_rules",
"robot_config", "agents", "mcp_servers",
"language_model", "cost_limit", "autonomous_mode", "robot_status",
"notes", "metadata", "status",
"__yao_updated_by", "__yao_team_id", "__yao_tenant_id",
}
for _, field := range robotFields {
if value, exists := robotData[field]; exists {
memberData[field] = value
}
}
// Skip update if no valid fields to update
if len(memberData) == 0 {
return nil
}
return u.UpdateMemberByMemberID(ctx, memberID, memberData)
}
// AddMember adds a user to a team (invitation-based) // AddMember adds a user to a team (invitation-based)
func (u *DefaultUser) AddMember(ctx context.Context, teamID string, userID string, roleID string, invitedBy string) (string, error) { func (u *DefaultUser) AddMember(ctx context.Context, teamID string, userID string, roleID string, invitedBy string) (string, error) {
// Check if member already exists // Check if member already exists

View file

@ -1240,6 +1240,233 @@ func TestMemberExistsByRobotEmail(t *testing.T) {
}) })
} }
func TestUpdateRobotMember(t *testing.T) {
prepare(t)
defer clean()
ctx := context.Background()
// Use UUID to ensure unique identifiers
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
// Create test user (team owner)
ownerUser := createTestUser(ctx, t, "owner"+testUUID)
regularUser := createTestUser(ctx, t, "user"+testUUID)
// Create test team
teamMap := maps.MapStrAny{
"name": "Update Robot Test Team " + testUUID,
"display_name": "Update Robot Test " + testUUID,
"description": "A test team for robot update testing",
"owner_id": ownerUser,
"status": "active",
}
teamID, err := testProvider.CreateTeam(ctx, teamMap)
assert.NoError(t, err)
var robotMemberID string
var regularMemberID string
// Create a robot member
t.Run("Setup_CreateRobotMember", func(t *testing.T) {
robotData := maps.MapStrAny{
"display_name": "TestBot" + testUUID,
"bio": "Original bio",
"role_id": "bot",
"autonomous_mode": false,
"robot_status": "idle",
"system_prompt": "Original prompt",
"language_model": "gpt-3.5-turbo",
"cost_limit": 50.00,
"robot_email": "testbot" + testUUID + "@robot.example.com",
}
memberID, err := testProvider.CreateRobotMember(ctx, teamID, robotData)
assert.NoError(t, err)
assert.NotEmpty(t, memberID)
robotMemberID = memberID
})
// Create a regular user member for testing
t.Run("Setup_CreateRegularMember", func(t *testing.T) {
memberData := maps.MapStrAny{
"team_id": teamID,
"user_id": regularUser,
"member_type": "user",
"role_id": "user",
"status": "active",
}
memberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
assert.NotEmpty(t, memberID)
regularMemberID = memberID
})
// Test successful update of robot member
t.Run("UpdateRobotMember_Success", func(t *testing.T) {
updateData := maps.MapStrAny{
"display_name": "UpdatedBot" + testUUID,
"bio": "Updated bio",
"autonomous_mode": true,
"robot_status": "working",
"system_prompt": "Updated prompt",
"language_model": "gpt-4",
"cost_limit": 100.00,
}
err := testProvider.UpdateRobotMember(ctx, robotMemberID, updateData)
assert.NoError(t, err)
// Verify updates
member, err := testProvider.GetMemberDetailByMemberID(ctx, robotMemberID)
assert.NoError(t, err)
assert.Equal(t, "UpdatedBot"+testUUID, member["display_name"])
assert.Equal(t, "Updated bio", member["bio"])
assert.Equal(t, "working", member["robot_status"])
assert.Equal(t, "Updated prompt", member["system_prompt"])
assert.Equal(t, "gpt-4", member["language_model"])
})
// Test updating robot_email
t.Run("UpdateRobotMember_UpdateEmail", func(t *testing.T) {
newEmail := "updated-testbot" + testUUID + "@robot.example.com"
updateData := maps.MapStrAny{
"robot_email": newEmail,
}
err := testProvider.UpdateRobotMember(ctx, robotMemberID, updateData)
assert.NoError(t, err)
// Verify update
member, err := testProvider.GetMemberDetailByMemberID(ctx, robotMemberID)
assert.NoError(t, err)
assert.Equal(t, newEmail, member["robot_email"])
})
// Test updating robot configuration fields
t.Run("UpdateRobotMember_UpdateConfiguration", func(t *testing.T) {
updateData := maps.MapStrAny{
"authorized_senders": []string{
"admin@example.com",
"manager@example.com",
},
"email_filter_rules": []string{
".*@example\\.com$",
".*@test\\.com$",
},
"robot_config": map[string]interface{}{
"max_tokens": 2000,
"temperature": 0.7,
},
"agents": []string{
"agent1",
"agent2",
},
"mcp_servers": []string{
"mcp://server1",
"mcp://server2",
},
}
err := testProvider.UpdateRobotMember(ctx, robotMemberID, updateData)
assert.NoError(t, err)
// Verify update
member, err := testProvider.GetMemberDetailByMemberID(ctx, robotMemberID)
assert.NoError(t, err)
assert.NotNil(t, member["authorized_senders"])
assert.NotNil(t, member["email_filter_rules"])
assert.NotNil(t, member["robot_config"])
})
// Test error when trying to update non-robot member
t.Run("UpdateRobotMember_NotRobotMember", func(t *testing.T) {
updateData := maps.MapStrAny{
"display_name": "Should Fail",
}
err := testProvider.UpdateRobotMember(ctx, regularMemberID, updateData)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not a robot member")
})
// Test error when member_id doesn't exist
t.Run("UpdateRobotMember_NotFound", func(t *testing.T) {
updateData := maps.MapStrAny{
"display_name": "Should Fail",
}
err := testProvider.UpdateRobotMember(ctx, "non-existent-member-id", updateData)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to get member")
})
// Test robot_email uniqueness validation during update
t.Run("UpdateRobotMember_EmailUniqueness", func(t *testing.T) {
// Create another robot with a different email
anotherRobotData := maps.MapStrAny{
"display_name": "AnotherBot" + testUUID,
"role_id": "bot",
"robot_email": "anotherbot" + testUUID + "@robot.example.com",
}
_, err := testProvider.CreateRobotMember(ctx, teamID, anotherRobotData)
assert.NoError(t, err)
// Try to update the first robot's email to match the second robot's email
updateData := maps.MapStrAny{
"robot_email": "anotherbot" + testUUID + "@robot.example.com",
}
err = testProvider.UpdateRobotMember(ctx, robotMemberID, updateData)
assert.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
})
// Test updating with same email (should succeed - no actual change)
t.Run("UpdateRobotMember_SameEmail", func(t *testing.T) {
// Get current email
member, err := testProvider.GetMemberDetailByMemberID(ctx, robotMemberID)
assert.NoError(t, err)
currentEmail := member["robot_email"]
// Update with same email
updateData := maps.MapStrAny{
"robot_email": currentEmail,
}
err = testProvider.UpdateRobotMember(ctx, robotMemberID, updateData)
assert.NoError(t, err)
})
// Test update with empty data (should not error)
t.Run("UpdateRobotMember_EmptyData", func(t *testing.T) {
err := testProvider.UpdateRobotMember(ctx, robotMemberID, maps.MapStrAny{})
assert.NoError(t, err) // Should not error, just do nothing
})
// Test updating status
t.Run("UpdateRobotMember_UpdateStatus", func(t *testing.T) {
updateData := maps.MapStrAny{
"status": "inactive",
}
err := testProvider.UpdateRobotMember(ctx, robotMemberID, updateData)
assert.NoError(t, err)
// Verify update
member, err := testProvider.GetMemberByMemberID(ctx, robotMemberID)
assert.NoError(t, err)
assert.Equal(t, "inactive", member["status"])
// Restore to active
err = testProvider.UpdateRobotMember(ctx, robotMemberID, maps.MapStrAny{"status": "active"})
assert.NoError(t, err)
})
}
func TestRobotEmailUniqueness(t *testing.T) { func TestRobotEmailUniqueness(t *testing.T) {
prepare(t) prepare(t)
defer clean() defer clean()

View file

@ -317,6 +317,7 @@ type UserProvider interface {
// Robot Member Operations // Robot Member Operations
CreateRobotMember(ctx context.Context, teamID string, robotData maps.MapStrAny) (string, error) CreateRobotMember(ctx context.Context, teamID string, robotData maps.MapStrAny) (string, error)
UpdateRobotMember(ctx context.Context, memberID string, robotData maps.MapStrAny) error
UpdateRobotActivity(ctx context.Context, memberID int64, robotStatus string) error UpdateRobotActivity(ctx context.Context, memberID int64, robotStatus string) error
GetActiveRobotMembers(ctx context.Context) ([]maps.MapStr, error) GetActiveRobotMembers(ctx context.Context) ([]maps.MapStr, error)

View file

@ -1555,4 +1555,416 @@ func TestMemberCheckRobotEmail(t *testing.T) {
} }
} }
// TestMemberUpdateRobot tests the PUT /user/teams/:team_id/members/robots/:member_id endpoint
func TestMemberUpdateRobot(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, "Robot Member Update Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, testClient.ClientID)
// Obtain access token with root permissions (required for robot operations)
tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile")
// Use UUID to ensure unique test data
testUUID := strings.ReplaceAll(uuid.New().String(), "-", "")[:8]
// Create a test team
createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Robot Update Test Team "+testUUID)
teamID := getTeamID(createdTeam)
// Helper function to create a robot member for testing
createTestRobot := func(suffix string) (string, string) {
robotEmail := fmt.Sprintf("test-robot-%s-%s@robot.test.com", testUUID, suffix)
robotBody := map[string]interface{}{
"name": "Test Robot " + suffix,
"robot_email": robotEmail,
"email": fmt.Sprintf("display-%s-%s@test.com", testUUID, suffix),
"role": "member",
"prompt": "Original prompt for " + suffix,
"llm": "gpt-3.5-turbo",
"autonomous_mode": "disabled",
"cost_limit": 50.0,
}
robotBodyBytes, _ := json.Marshal(robotBody)
robotReq, _ := http.NewRequest("POST", serverURL+baseURL+"/user/teams/"+teamID+"/members/robots", bytes.NewBuffer(robotBodyBytes))
robotReq.Header.Set("Content-Type", "application/json")
robotReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{}
robotResp, err := client.Do(robotReq)
assert.NoError(t, err)
if robotResp != nil {
defer robotResp.Body.Close()
assert.Equal(t, 201, robotResp.StatusCode, "Should create robot member successfully")
body, _ := io.ReadAll(robotResp.Body)
var response map[string]interface{}
json.Unmarshal(body, &response)
return toString(response["member_id"]), robotEmail
}
return "", ""
}
testCases := []struct {
name string
setupFunc func() (string, string) // Returns (memberID, originalRobotEmail)
body map[string]interface{}
headers map[string]string
expectCode int
expectMsg string
validateFn func(*testing.T, string) // Optional validation function with memberID
}{
{
"update robot without authentication",
func() (string, string) { return createTestRobot("1") },
map[string]interface{}{
"name": "Updated Name",
},
map[string]string{},
401,
"should require authentication",
nil,
},
{
"update robot with all fields",
func() (string, string) { return createTestRobot("2") },
map[string]interface{}{
"name": "Updated Robot Full",
"email": fmt.Sprintf("updated-display-%s@test.com", testUUID),
"robot_email": fmt.Sprintf("updated-robot-%s@robot.test.com", testUUID),
"bio": "Updated comprehensive description",
"role": "admin",
"report_to": tokenInfo.UserID,
"prompt": "Updated system prompt",
"llm": "gpt-4",
"agents": []string{"agent1", "agent2"},
"mcp_tools": []string{"tool1", "tool2"},
"authorized_senders": []string{"admin@test.com"},
"email_filter_rules": []string{".*@test\\.com$"},
"autonomous_mode": "enabled",
"cost_limit": 100.0,
"status": "active",
"robot_status": "working",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
200,
"should update robot with all fields successfully",
func(t *testing.T, memberID string) {
// Verify the update
getMemberURL := serverURL + baseURL + "/user/teams/" + teamID + "/members/" + memberID
getReq, _ := http.NewRequest("GET", getMemberURL, nil)
getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{}
getResp, err := client.Do(getReq)
assert.NoError(t, err)
if getResp != nil {
defer getResp.Body.Close()
if getResp.StatusCode == 200 {
var member map[string]interface{}
body, _ := io.ReadAll(getResp.Body)
json.Unmarshal(body, &member)
assert.Equal(t, "Updated Robot Full", member["display_name"])
assert.Equal(t, "Updated system prompt", member["system_prompt"])
assert.Equal(t, "gpt-4", member["language_model"])
}
}
},
},
{
"update robot with partial fields",
func() (string, string) { return createTestRobot("3") },
map[string]interface{}{
"name": "Partially Updated Robot",
"prompt": "Partially updated prompt",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
200,
"should update robot with partial fields",
func(t *testing.T, memberID string) {
getMemberURL := serverURL + baseURL + "/user/teams/" + teamID + "/members/" + memberID
getReq, _ := http.NewRequest("GET", getMemberURL, nil)
getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{}
getResp, err := client.Do(getReq)
assert.NoError(t, err)
if getResp != nil {
defer getResp.Body.Close()
if getResp.StatusCode == 200 {
var member map[string]interface{}
body, _ := io.ReadAll(getResp.Body)
json.Unmarshal(body, &member)
assert.Equal(t, "Partially Updated Robot", member["display_name"])
assert.Equal(t, "Partially updated prompt", member["system_prompt"])
// Original fields should remain
assert.Equal(t, "gpt-3.5-turbo", member["language_model"])
}
}
},
},
{
"update robot_email to new unique email",
func() (string, string) { return createTestRobot("4") },
map[string]interface{}{
"robot_email": fmt.Sprintf("new-unique-%s@robot.test.com", testUUID),
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
200,
"should update robot_email to new unique email",
func(t *testing.T, memberID string) {
getMemberURL := serverURL + baseURL + "/user/teams/" + teamID + "/members/" + memberID
getReq, _ := http.NewRequest("GET", getMemberURL, nil)
getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{}
getResp, err := client.Do(getReq)
assert.NoError(t, err)
if getResp != nil {
defer getResp.Body.Close()
if getResp.StatusCode == 200 {
var member map[string]interface{}
body, _ := io.ReadAll(getResp.Body)
json.Unmarshal(body, &member)
assert.Equal(t, fmt.Sprintf("new-unique-%s@robot.test.com", testUUID), member["robot_email"])
}
}
},
},
{
"update robot_email to duplicate email",
func() (string, string) {
// Create two robots
memberID1, email1 := createTestRobot("5a")
_, _ = createTestRobot("5b")
return memberID1, email1
},
map[string]interface{}{
"robot_email": fmt.Sprintf("test-robot-%s-5b@robot.test.com", testUUID),
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
409,
"should reject duplicate robot_email",
nil,
},
{
"update autonomous_mode variations",
func() (string, string) { return createTestRobot("6") },
map[string]interface{}{
"autonomous_mode": "1",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
200,
"should handle autonomous_mode=1",
func(t *testing.T, memberID string) {
getMemberURL := serverURL + baseURL + "/user/teams/" + teamID + "/members/" + memberID
getReq, _ := http.NewRequest("GET", getMemberURL, nil)
getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{}
getResp, err := client.Do(getReq)
assert.NoError(t, err)
if getResp != nil {
defer getResp.Body.Close()
if getResp.StatusCode == 200 {
var member map[string]interface{}
body, _ := io.ReadAll(getResp.Body)
json.Unmarshal(body, &member)
// autonomous_mode should be enabled
autonomousMode := member["autonomous_mode"]
assert.True(t, autonomousMode == true || autonomousMode == float64(1) || autonomousMode == int64(1))
}
}
},
},
{
"update robot status",
func() (string, string) { return createTestRobot("7") },
map[string]interface{}{
"status": "inactive",
"robot_status": "error",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
200,
"should update robot status fields",
func(t *testing.T, memberID string) {
getMemberURL := serverURL + baseURL + "/user/teams/" + teamID + "/members/" + memberID
getReq, _ := http.NewRequest("GET", getMemberURL, nil)
getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{}
getResp, err := client.Do(getReq)
assert.NoError(t, err)
if getResp != nil {
defer getResp.Body.Close()
if getResp.StatusCode == 200 {
var member map[string]interface{}
body, _ := io.ReadAll(getResp.Body)
json.Unmarshal(body, &member)
assert.Equal(t, "inactive", member["status"])
assert.Equal(t, "error", member["robot_status"])
}
}
},
},
{
"update array fields",
func() (string, string) { return createTestRobot("8") },
map[string]interface{}{
"agents": []string{"new-agent1", "new-agent2", "new-agent3"},
"mcp_tools": []string{"new-tool1"},
"authorized_senders": []string{"sender1@test.com", "sender2@test.com"},
"email_filter_rules": []string{".*@allowed\\.com$"},
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
200,
"should update array fields",
nil,
},
{
"update non-existent robot",
func() (string, string) { return "non-existent-member-id", "" },
map[string]interface{}{
"name": "Should Fail",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
404,
"should return not found for non-existent robot",
nil,
},
{
"update regular user member as robot",
func() (string, string) {
// Create a regular user member instead of robot
memberID := createTestMember(t, serverURL, baseURL, teamID, tokenInfo.AccessToken, "regular-user-"+testUUID)
return memberID, ""
},
map[string]interface{}{
"name": "Should Fail",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
400,
"should reject updating non-robot member",
nil,
},
{
"update robot in non-existent team",
func() (string, string) { return createTestRobot("10") },
map[string]interface{}{
"name": "Should Fail",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
404,
"should return not found for non-existent team",
nil,
},
{
"update robot with invalid JSON",
func() (string, string) { return createTestRobot("11") },
nil, // Will send invalid JSON
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
400,
"should handle invalid JSON",
nil,
},
{
"update robot with empty body",
func() (string, string) { return createTestRobot("12") },
map[string]interface{}{},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
200,
"should handle empty update (no-op)",
nil,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
memberID, _ := tc.setupFunc()
// Use non-existent team ID for the specific test case
targetTeamID := teamID
if tc.name == "update robot in non-existent team" {
targetTeamID = "non-existent-team-id"
}
requestURL := serverURL + baseURL + "/user/teams/" + targetTeamID + "/members/robots/" + memberID
var req *http.Request
var err error
if tc.body == nil {
// Send invalid JSON for invalid JSON test case
req, err = http.NewRequest("PUT", requestURL, bytes.NewBufferString("invalid json"))
} else {
bodyBytes, _ := json.Marshal(tc.body)
req, err = http.NewRequest("PUT", requestURL, bytes.NewBuffer(bodyBytes))
}
assert.NoError(t, err, "Should create HTTP request")
req.Header.Set("Content-Type", "application/json")
// 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 success message
var response map[string]interface{}
err = json.Unmarshal(body, &response)
assert.NoError(t, err, "Should parse JSON response")
assert.Contains(t, response, "message", "Should have success message")
assert.Equal(t, "Robot member updated successfully", response["message"], "Should have correct success message")
// Run custom validation if provided
if tc.validateFn != nil {
tc.validateFn(t, memberID)
}
}
t.Logf("Robot member update test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body))
}
})
}
}
// Note: getTeamID function is already defined in team_test.go // Note: getTeamID function is already defined in team_test.go

View file

@ -352,6 +352,142 @@ func GinMemberCreateRobot(c *gin.Context) {
response.RespondWithSuccess(c, http.StatusCreated, gin.H{"member_id": memberID}) response.RespondWithSuccess(c, http.StatusCreated, gin.H{"member_id": memberID})
} }
// GinMemberUpdateRobot handles PUT /teams/:team_id/members/robots/:member_id - Update robot member
func GinMemberUpdateRobot(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")
memberID := c.Param("member_id")
if teamID == "" || memberID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Team ID and Member ID are required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Parse request body
var req UpdateRobotMemberRequest
if err := c.ShouldBindJSON(&req); err != nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Invalid request body: " + err.Error(),
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Prepare update data
updateData := maps.MapStrAny{}
// Add fields if provided
if req.Name != "" {
updateData["display_name"] = req.Name
}
if req.Email != "" {
updateData["email"] = req.Email
}
if req.RobotEmail != "" {
updateData["robot_email"] = req.RobotEmail
}
if req.Bio != "" {
updateData["bio"] = req.Bio
}
if req.RoleID != "" {
updateData["role_id"] = req.RoleID
}
if req.ManagerID != "" {
updateData["manager_id"] = req.ManagerID
}
if req.SystemPrompt != "" {
updateData["system_prompt"] = req.SystemPrompt
}
if req.LanguageModel != "" {
updateData["language_model"] = req.LanguageModel
}
if req.Status != "" {
updateData["status"] = req.Status
}
if req.RobotStatus != "" {
updateData["robot_status"] = req.RobotStatus
}
if req.AutonomousMode != "" {
updateData["autonomous_mode"] = toBool(req.AutonomousMode)
}
if req.CostLimit > 0 {
updateData["cost_limit"] = req.CostLimit
}
// Handle array fields (they can be empty arrays)
if req.AuthorizedSenders != nil {
updateData["authorized_senders"] = req.AuthorizedSenders
}
if req.EmailFilterRules != nil {
updateData["email_filter_rules"] = req.EmailFilterRules
}
if req.Agents != nil {
updateData["agents"] = req.Agents
}
if req.MCPServers != nil {
updateData["mcp_servers"] = req.MCPServers
}
// Wrap with update scope for permission tracking
robotData := authInfo.WithUpdateScope(updateData)
// Call business logic
err := memberUpdateRobot(c.Request.Context(), authInfo.UserID, teamID, memberID, robotData)
if err != nil {
log.Error("Failed to update robot member: %v", err)
// Check error type for appropriate response
if strings.Contains(err.Error(), "not found") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: err.Error(),
}
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 if strings.Contains(err.Error(), "not a robot member") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
} else if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: err.Error(),
}
response.RespondWithError(c, response.StatusConflict, errorResp)
} else {
errorResp := &response.ErrorResponse{
Code: response.ErrServerError.Code,
ErrorDescription: "Failed to update robot member",
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
}
return
}
// Return success
response.RespondWithSuccess(c, http.StatusOK, gin.H{"message": "Robot member updated successfully"})
}
// GinMemberUpdate handles PUT /teams/:team_id/members/:member_id - Update team member // GinMemberUpdate handles PUT /teams/:team_id/members/:member_id - Update team member
func GinMemberUpdate(c *gin.Context) { func GinMemberUpdate(c *gin.Context) {
// Get authorized user info // Get authorized user info
@ -921,6 +1057,34 @@ func memberCreateRobot(ctx context.Context, userID, teamID string, robotData map
return memberID, nil return memberID, nil
} }
// memberUpdateRobot handles the business logic for updating a robot member
func memberUpdateRobot(ctx context.Context, userID, teamID, memberID string, robotData maps.MapStrAny) error {
// Check if user has access to the team (write permission: owner only)
isOwner, _, err := checkTeamAccess(ctx, teamID, userID)
if err != nil {
return err
}
// Only allow access if user is owner
if !isOwner {
return fmt.Errorf("access denied: only team owner can update robot members")
}
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
return fmt.Errorf("failed to get user provider: %w", err)
}
// Use UpdateRobotMember method which handles robot-specific logic and validation
err = provider.UpdateRobotMember(ctx, memberID, robotData)
if err != nil {
return fmt.Errorf("failed to update robot member: %w", err)
}
return nil
}
// memberUpdate handles the business logic for updating a team member // memberUpdate handles the business logic for updating a team member
func memberUpdate(ctx context.Context, userID, teamID, memberID string, updateData maps.MapStrAny) error { func memberUpdate(ctx context.Context, userID, teamID, memberID string, updateData maps.MapStrAny) error {
// Check if user has access to the team (write permission: owner only) // Check if user has access to the team (write permission: owner only)

View file

@ -460,6 +460,26 @@ type CreateRobotMemberRequest struct {
CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit in USD CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit in USD
} }
// UpdateRobotMemberRequest represents the request to update a robot member
type UpdateRobotMemberRequest struct {
Name string `json:"name,omitempty"` // Display name
Email string `json:"email,omitempty"` // Email address (optional, for display only)
RobotEmail string `json:"robot_email,omitempty"` // Robot's globally unique email address
AuthorizedSenders []string `json:"authorized_senders,omitempty"` // Whitelist of emails authorized to send commands
EmailFilterRules []string `json:"email_filter_rules,omitempty"` // Email filtering rules (supports regex patterns)
Bio string `json:"bio,omitempty"` // Bio/description
RoleID string `json:"role,omitempty"` // Role ID
ManagerID string `json:"report_to,omitempty"` // Direct manager user ID
SystemPrompt string `json:"prompt,omitempty"` // Identity & role prompt
LanguageModel string `json:"llm,omitempty"` // Language model (e.g., "gpt-4")
Agents []string `json:"agents,omitempty"` // Accessible agents
MCPServers []string `json:"mcp_tools,omitempty"` // MCP servers/tools
AutonomousMode string `json:"autonomous_mode,omitempty"` // "enabled" or "disabled"
CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit in USD
Status string `json:"status,omitempty"` // Status: active, inactive
RobotStatus string `json:"robot_status,omitempty"` // Robot status: idle, working, error
}
// MemberListRequest represents the request to list team members with advanced filtering // MemberListRequest represents the request to list team members with advanced filtering
type MemberListRequest struct { type MemberListRequest struct {
// Pagination // Pagination

View file

@ -136,6 +136,7 @@ func attachTeam(group *gin.RouterGroup, oauth types.OAuth) {
team.GET("/:id/members", GinMemberList) // GET /api/user/teams/:id/members - List team members team.GET("/:id/members", GinMemberList) // GET /api/user/teams/:id/members - List team members
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.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.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", GinMemberGet) // GET /api/user/teams/:id/members/:member_id - Get member details 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 team.PUT("/:id/members/:member_id", GinMemberUpdate) // PUT /api/user/teams/:id/members/:member_id - Update member
team.DELETE("/:id/members/:member_id", GinMemberDelete) // DELETE /api/user/teams/:id/members/:member_id - Remove member team.DELETE("/:id/members/:member_id", GinMemberDelete) // DELETE /api/user/teams/:id/members/:member_id - Remove member