Merge pull request #1237 from trheyi/main

Enhance member management with member_id and email checks
This commit is contained in:
Max 2025-10-26 11:09:35 +08:00 committed by GitHub
commit fb4df03b02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1311 additions and 497 deletions

File diff suppressed because one or more lines are too long

View file

@ -157,14 +157,14 @@ var (
// DefaultMemberFields contains basic member fields
DefaultMemberFields = []interface{}{
"team_id", "user_id", "member_type", "display_name", "bio", "avatar", "email", "role_id", "status",
"member_id", "team_id", "user_id", "member_type", "display_name", "bio", "avatar", "email", "role_id", "status",
"invitation_id", "invited_by", "invited_at", "joined_at", "invitation_token", "invitation_expires_at",
"last_active_at", "login_count", "created_at", "updated_at",
}
// DefaultMemberDetailFields contains all member fields including robot config and permissions
DefaultMemberDetailFields = []interface{}{
"team_id", "user_id", "member_type", "display_name", "bio", "avatar", "email", "role_id", "status",
"member_id", "team_id", "user_id", "member_type", "display_name", "bio", "avatar", "email", "role_id", "status",
"system_prompt", "manager_id", "robot_config", "agents", "mcp_servers",
"language_model", "cost_limit", "autonomous_mode", "last_robot_activity", "robot_status",
"invitation_id", "invited_by", "invited_at", "joined_at", "invitation_token",

View file

@ -11,6 +11,24 @@ import (
// Invitation Code Resource (Official Platform Invitation Codes)
// invitationCodeExists checks if an invitation code exists by code
func (u *DefaultUser) invitationCodeExists(ctx context.Context, code string) (bool, error) {
m := model.Select(u.invitationModel)
invitations, err := m.Get(model.QueryParam{
Select: []interface{}{"id"},
Wheres: []model.QueryWhere{
{Column: "code", Value: code},
},
Limit: 1,
})
if err != nil {
return false, fmt.Errorf("failed to check invitation code existence: %w", err)
}
return len(invitations) > 0, nil
}
// CreateInvitationCodes creates invitation codes in batch
// Supports creating multiple invitation codes at once for efficiency
func (u *DefaultUser) CreateInvitationCodes(ctx context.Context, codeData []maps.MapStrAny) ([]string, error) {
@ -162,7 +180,15 @@ func (u *DefaultUser) UseInvitationCode(ctx context.Context, code string, userID
}
if affected == 0 {
return fmt.Errorf(ErrInvitationCodeNotFound)
// Check if invitation code still exists
exists, checkErr := u.invitationCodeExists(ctx, code)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUseInvitationCode, checkErr)
}
if !exists {
return fmt.Errorf(ErrInvitationCodeNotFound)
}
// Invitation code exists but no changes were made (already in this state)
}
return nil

View file

@ -101,6 +101,50 @@ func (u *DefaultUser) GetMemberByInvitationID(ctx context.Context, invitationID
return members[0], nil
}
// GetMemberByMemberID retrieves member information by member_id (business ID)
func (u *DefaultUser) GetMemberByMemberID(ctx context.Context, memberID string) (maps.MapStrAny, error) {
m := model.Select(u.memberModel)
members, err := m.Get(model.QueryParam{
Select: u.memberFields,
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
},
Limit: 1,
})
if err != nil {
return nil, fmt.Errorf(ErrFailedToGetMember, err)
}
if len(members) == 0 {
return nil, fmt.Errorf(ErrMemberNotFound)
}
return members[0], nil
}
// GetMemberDetailByMemberID retrieves detailed member information by member_id (business ID)
func (u *DefaultUser) GetMemberDetailByMemberID(ctx context.Context, memberID string) (maps.MapStrAny, error) {
m := model.Select(u.memberModel)
members, err := m.Get(model.QueryParam{
Select: u.memberDetailFields,
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
},
Limit: 1,
})
if err != nil {
return nil, fmt.Errorf(ErrFailedToGetMember, err)
}
if len(members) == 0 {
return nil, fmt.Errorf(ErrMemberNotFound)
}
return members[0], nil
}
// MemberExists checks if a member exists by team_id and user_id
func (u *DefaultUser) MemberExists(ctx context.Context, teamID string, userID string) (bool, error) {
m := model.Select(u.memberModel)
@ -120,14 +164,100 @@ func (u *DefaultUser) MemberExists(ctx context.Context, teamID string, userID st
return len(members) > 0, nil
}
// MemberExistsByTeamEmail checks if a member exists by team_id and email
func (u *DefaultUser) MemberExistsByTeamEmail(ctx context.Context, teamID string, email string) (bool, error) {
m := model.Select(u.memberModel)
members, err := m.Get(model.QueryParam{
Select: []interface{}{"id"}, // Only select ID for existence check
Wheres: []model.QueryWhere{
{Column: "team_id", Value: teamID},
{Column: "email", Value: email},
},
Limit: 1,
})
if err != nil {
return false, fmt.Errorf(ErrFailedToGetMember, err)
}
return len(members) > 0, nil
}
// MemberExistsByMemberID checks if a member exists by member_id (business ID)
func (u *DefaultUser) MemberExistsByMemberID(ctx context.Context, memberID string) (bool, error) {
m := model.Select(u.memberModel)
members, err := m.Get(model.QueryParam{
Select: []interface{}{"id"}, // Only select ID for existence check
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
},
Limit: 1,
})
if err != nil {
return false, fmt.Errorf(ErrFailedToGetMember, err)
}
return len(members) > 0, nil
}
// memberExistsByID checks if a member exists by internal database ID
func (u *DefaultUser) memberExistsByID(ctx context.Context, id int64) (bool, error) {
m := model.Select(u.memberModel)
members, err := m.Get(model.QueryParam{
Select: []interface{}{"id"}, // Only select ID for existence check
Wheres: []model.QueryWhere{
{Column: "id", Value: id},
},
Limit: 1,
})
if err != nil {
return false, fmt.Errorf(ErrFailedToGetMember, err)
}
return len(members) > 0, nil
}
// memberExistsByInvitationID checks if a member exists by invitation_id
func (u *DefaultUser) memberExistsByInvitationID(ctx context.Context, invitationID string) (bool, error) {
m := model.Select(u.memberModel)
members, err := m.Get(model.QueryParam{
Select: []interface{}{"id"}, // Only select ID for existence check
Wheres: []model.QueryWhere{
{Column: "invitation_id", Value: invitationID},
},
Limit: 1,
})
if err != nil {
return false, fmt.Errorf(ErrFailedToGetMember, err)
}
return len(members) > 0, nil
}
// CreateMember creates a new team member (user type)
func (u *DefaultUser) CreateMember(ctx context.Context, memberData maps.MapStrAny) (int64, error) {
func (u *DefaultUser) CreateMember(ctx context.Context, memberData maps.MapStrAny) (string, error) {
// Validate required fields for user members
if _, exists := memberData["team_id"]; !exists {
return 0, fmt.Errorf("team_id is required in memberData")
return "", fmt.Errorf("team_id is required in memberData")
}
if _, exists := memberData["role_id"]; !exists {
return 0, fmt.Errorf("role_id is required in memberData")
return "", fmt.Errorf("role_id is required in memberData")
}
// Generate member_id if not provided
var generatedMemberID string
if _, exists := memberData["member_id"]; !exists || memberData["member_id"] == nil || memberData["member_id"] == "" {
memberID, err := u.generateMemberIDWithRetry(ctx)
if err != nil {
return "", fmt.Errorf("failed to generate member ID: %w", err)
}
memberData["member_id"] = memberID
generatedMemberID = memberID
} else {
generatedMemberID = memberData["member_id"].(string)
}
// Add __yao_team_id to the member data
@ -147,7 +277,7 @@ func (u *DefaultUser) CreateMember(ctx context.Context, memberData maps.MapStrAn
if memberType == "user" && status != "pending" {
if _, exists := memberData["user_id"]; !exists {
return 0, fmt.Errorf("user_id is required for active user members")
return "", fmt.Errorf("user_id is required for active user members")
}
}
@ -155,7 +285,7 @@ func (u *DefaultUser) CreateMember(ctx context.Context, memberData maps.MapStrAn
if status == "pending" && memberData["invitation_id"] == nil {
invitationID, err := u.generateInvitationID()
if err != nil {
return 0, fmt.Errorf("failed to generate invitation ID: %w", err)
return "", fmt.Errorf("failed to generate invitation ID: %w", err)
}
memberData["invitation_id"] = invitationID
}
@ -175,22 +305,22 @@ func (u *DefaultUser) CreateMember(ctx context.Context, memberData maps.MapStrAn
}
m := model.Select(u.memberModel)
id, err := m.Create(memberData)
_, err := m.Create(memberData)
if err != nil {
return 0, fmt.Errorf(ErrFailedToCreateMember, err)
return "", fmt.Errorf(ErrFailedToCreateMember, err)
}
return int64(id), nil
return generatedMemberID, nil
}
// CreateRobotMember creates a new robot member
func (u *DefaultUser) CreateRobotMember(ctx context.Context, teamID string, robotData maps.MapStrAny) (int64, error) {
func (u *DefaultUser) CreateRobotMember(ctx context.Context, teamID string, robotData maps.MapStrAny) (string, error) {
// Validate required fields for robot members
if _, exists := robotData["display_name"]; !exists {
return 0, fmt.Errorf("display_name is required for robot members")
return "", fmt.Errorf("display_name is required for robot members")
}
if _, exists := robotData["role_id"]; !exists {
return 0, fmt.Errorf("role_id is required for robot members")
return "", fmt.Errorf("role_id is required for robot members")
}
// Check if email already exists in this team
@ -206,10 +336,10 @@ func (u *DefaultUser) CreateRobotMember(ctx context.Context, teamID string, robo
Limit: 1,
})
if err != nil {
return 0, fmt.Errorf("failed to check email uniqueness: %w", err)
return "", fmt.Errorf("failed to check email uniqueness: %w", err)
}
if len(existingMembers) > 0 {
return 0, fmt.Errorf("email %s already exists in this team", emailStr)
return "", fmt.Errorf("email %s already exists in this team", emailStr)
}
}
@ -256,20 +386,20 @@ func (u *DefaultUser) CreateRobotMember(ctx context.Context, teamID string, robo
}
// AddMember adds a user to a team (invitation-based)
func (u *DefaultUser) AddMember(ctx context.Context, teamID string, userID string, roleID string, invitedBy string) (int64, error) {
func (u *DefaultUser) AddMember(ctx context.Context, teamID string, userID string, roleID string, invitedBy string) (string, error) {
// Check if member already exists
exists, err := u.MemberExists(ctx, teamID, userID)
if err != nil {
return 0, fmt.Errorf("failed to check member existence: %w", err)
return "", fmt.Errorf("failed to check member existence: %w", err)
}
if exists {
return 0, fmt.Errorf("user is already a member of this team")
return "", fmt.Errorf("user is already a member of this team")
}
// Generate invitation token
token, err := generateRandomPassword(32) // Use existing password generation for token
if err != nil {
return 0, fmt.Errorf("failed to generate invitation token: %w", err)
return "", fmt.Errorf("failed to generate invitation token: %w", err)
}
memberData := maps.MapStrAny{
@ -370,7 +500,7 @@ func (u *DefaultUser) AcceptInvitation(ctx context.Context, invitationID string,
// UpdateMember updates an existing member
func (u *DefaultUser) UpdateMember(ctx context.Context, teamID string, userID string, memberData maps.MapStrAny) error {
// Remove sensitive fields that should not be updated directly
sensitiveFields := []string{"id", "team_id", "user_id", "created_at", "invitation_token"}
sensitiveFields := []string{"id", "member_id", "team_id", "user_id", "created_at", "invitation_token"}
for _, field := range sensitiveFields {
delete(memberData, field)
}
@ -394,16 +524,24 @@ func (u *DefaultUser) UpdateMember(ctx context.Context, teamID string, userID st
}
if affected == 0 {
return fmt.Errorf(ErrMemberNotFound)
// Check if member exists
exists, checkErr := u.MemberExists(ctx, teamID, userID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateMember, checkErr)
}
if !exists {
return fmt.Errorf(ErrMemberNotFound)
}
// Member exists but no changes were made
}
return nil
}
// UpdateMemberByID updates a member by internal ID
func (u *DefaultUser) UpdateMemberByID(ctx context.Context, memberID int64, memberData maps.MapStrAny) error {
// UpdateMemberByID updates a member by internal database ID
func (u *DefaultUser) UpdateMemberByID(ctx context.Context, id int64, memberData maps.MapStrAny) error {
// Remove sensitive fields that should not be updated directly
sensitiveFields := []string{"id", "team_id", "user_id", "created_at", "invitation_token"}
sensitiveFields := []string{"id", "member_id", "team_id", "user_id", "created_at", "invitation_token"}
for _, field := range sensitiveFields {
delete(memberData, field)
}
@ -416,7 +554,7 @@ func (u *DefaultUser) UpdateMemberByID(ctx context.Context, memberID int64, memb
m := model.Select(u.memberModel)
affected, err := m.UpdateWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "id", Value: memberID},
{Column: "id", Value: id},
},
Limit: 1,
}, memberData)
@ -426,7 +564,60 @@ func (u *DefaultUser) UpdateMemberByID(ctx context.Context, memberID int64, memb
}
if affected == 0 {
return fmt.Errorf(ErrMemberNotFound)
// Check if member exists
exists, checkErr := u.memberExistsByID(ctx, id)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateMember, checkErr)
}
if !exists {
return fmt.Errorf(ErrMemberNotFound)
}
// Member exists but no changes were made
}
return nil
}
// UpdateMemberByMemberID updates a member by member_id (business ID)
func (u *DefaultUser) UpdateMemberByMemberID(ctx context.Context, memberID string, memberData maps.MapStrAny) error {
// Remove sensitive fields that should not be updated directly
sensitiveFields := []string{"id", "member_id", "team_id", "user_id", "created_at", "invitation_token"}
for _, field := range sensitiveFields {
delete(memberData, field)
}
// Skip update if no valid fields remain
if len(memberData) == 0 {
return nil
}
m := model.Select(u.memberModel)
affected, err := m.UpdateWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
},
Limit: 1,
}, memberData)
if err != nil {
return fmt.Errorf(ErrFailedToUpdateMember, err)
}
// Note: affected=0 can mean either:
// 1. No record found with the given member_id
// 2. Record exists but no fields were changed (values are the same)
// We verify the member exists first to provide a more accurate error
if affected == 0 {
// Check if member exists
exists, checkErr := u.MemberExistsByMemberID(ctx, memberID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateMember, checkErr)
}
if !exists {
return fmt.Errorf(ErrMemberNotFound)
}
// Member exists but no changes were made (values are the same)
// This is not an error, just return nil
}
return nil
@ -454,6 +645,27 @@ func (u *DefaultUser) RemoveMember(ctx context.Context, teamID string, userID st
return nil
}
// RemoveMemberByMemberID removes a member by member_id (business ID, soft delete)
func (u *DefaultUser) RemoveMemberByMemberID(ctx context.Context, memberID string) error {
m := model.Select(u.memberModel)
affected, err := m.DeleteWhere(model.QueryParam{
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
},
Limit: 1,
})
if err != nil {
return fmt.Errorf(ErrFailedToDeleteMember, err)
}
if affected == 0 {
return fmt.Errorf(ErrMemberNotFound)
}
return nil
}
// RemoveAllTeamMembers removes all members from a team (used when deleting team)
func (u *DefaultUser) RemoveAllTeamMembers(ctx context.Context, teamID string) error {
m := model.Select(u.memberModel)
@ -621,14 +833,55 @@ func (u *DefaultUser) UpdateMemberLastActivity(ctx context.Context, teamID strin
return u.UpdateMember(ctx, teamID, userID, updateData)
}
// UpdateRobotActivity updates robot member's last activity and status
func (u *DefaultUser) UpdateRobotActivity(ctx context.Context, memberID int64, robotStatus string) error {
// UpdateMemberRoleByMemberID updates a member's role by member_id
func (u *DefaultUser) UpdateMemberRoleByMemberID(ctx context.Context, memberID string, roleID string) error {
updateData := maps.MapStrAny{
"role_id": roleID,
}
return u.UpdateMemberByMemberID(ctx, memberID, updateData)
}
// UpdateMemberStatusByMemberID updates a member's status by member_id
func (u *DefaultUser) UpdateMemberStatusByMemberID(ctx context.Context, memberID string, status string) error {
updateData := maps.MapStrAny{
"status": status,
}
return u.UpdateMemberByMemberID(ctx, memberID, updateData)
}
// UpdateMemberLastActivityByMemberID updates a member's last activity time by member_id
func (u *DefaultUser) UpdateMemberLastActivityByMemberID(ctx context.Context, memberID string) error {
updateData := maps.MapStrAny{
"last_active_at": time.Now(),
}
// Also increment login count
member, err := u.GetMemberByMemberID(ctx, memberID)
if err != nil {
return err
}
loginCount := int64(0)
if count := member["login_count"]; count != nil {
if parsedCount, err := parseIntFromDB(count); err == nil {
loginCount = parsedCount
}
}
updateData["login_count"] = loginCount + 1
return u.UpdateMemberByMemberID(ctx, memberID, updateData)
}
// UpdateRobotActivity updates robot member's last activity and status by internal database ID
func (u *DefaultUser) UpdateRobotActivity(ctx context.Context, id int64, robotStatus string) error {
updateData := maps.MapStrAny{
"last_robot_activity": time.Now(),
"robot_status": robotStatus,
}
return u.UpdateMemberByID(ctx, memberID, updateData)
return u.UpdateMemberByID(ctx, id, updateData)
}
// UpdateMemberByInvitationID updates a member by invitation_id
@ -658,7 +911,15 @@ func (u *DefaultUser) UpdateMemberByInvitationID(ctx context.Context, invitation
}
if affected == 0 {
return fmt.Errorf(ErrMemberNotFound)
// Check if member exists
exists, checkErr := u.memberExistsByInvitationID(ctx, invitationID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateMember, checkErr)
}
if !exists {
return fmt.Errorf(ErrMemberNotFound)
}
// Member exists but no changes were made
}
return nil

View file

@ -40,7 +40,7 @@ func TestMemberBasicOperations(t *testing.T) {
teamID, err := testProvider.CreateTeam(ctx, teamMap)
assert.NoError(t, err)
var memberID int64
var businessMemberID string
// Test CreateMember
t.Run("CreateMember", func(t *testing.T) {
@ -52,10 +52,10 @@ func TestMemberBasicOperations(t *testing.T) {
"status": "active",
}
id, err := testProvider.CreateMember(ctx, memberData)
memberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
assert.Greater(t, id, int64(0))
memberID = id
assert.NotEmpty(t, memberID)
businessMemberID = memberID
})
// Test GetMember
@ -81,13 +81,15 @@ func TestMemberBasicOperations(t *testing.T) {
assert.Contains(t, member, "updated_at")
})
// Test GetMemberByID
t.Run("GetMemberByID", func(t *testing.T) {
member, err := testProvider.GetMemberByID(ctx, memberID)
// Test GetMemberByMemberID
t.Run("GetMemberByMemberID", func(t *testing.T) {
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.NotNil(t, member)
assert.Equal(t, teamID, member["team_id"])
assert.Equal(t, memberUser, member["user_id"])
// Verify member_id is returned
assert.Equal(t, businessMemberID, member["member_id"])
})
// Test MemberExists
@ -129,22 +131,22 @@ func TestMemberBasicOperations(t *testing.T) {
assert.NoError(t, err) // Should not error, just ignore sensitive fields
})
// Test UpdateMemberByID
t.Run("UpdateMemberByID", func(t *testing.T) {
// Test UpdateMemberByMemberID
t.Run("UpdateMemberByMemberID", func(t *testing.T) {
updateData := maps.MapStrAny{
"status": "inactive",
}
err := testProvider.UpdateMemberByID(ctx, memberID, updateData)
err := testProvider.UpdateMemberByMemberID(ctx, businessMemberID, updateData)
assert.NoError(t, err)
// Verify update
member, err := testProvider.GetMemberByID(ctx, memberID)
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.Equal(t, "inactive", member["status"])
// Change back to active for other tests
err = testProvider.UpdateMemberByID(ctx, memberID, maps.MapStrAny{"status": "active"})
err = testProvider.UpdateMemberByMemberID(ctx, businessMemberID, maps.MapStrAny{"status": "active"})
assert.NoError(t, err)
})
@ -249,7 +251,7 @@ func TestMemberInvitationFlow(t *testing.T) {
t.Run("AddMember", func(t *testing.T) {
memberID, err := testProvider.AddMember(ctx, teamID, inviteeUser, "user", ownerUser)
assert.NoError(t, err)
assert.Greater(t, memberID, int64(0))
assert.NotEmpty(t, memberID)
// Verify member was created with pending status
member, err := testProvider.GetMember(ctx, teamID, inviteeUser)
@ -335,7 +337,7 @@ func TestRobotMemberOperations(t *testing.T) {
teamID, err := testProvider.CreateTeam(ctx, teamMap)
assert.NoError(t, err)
var robotMemberID int64
var robotBusinessMemberID string
// Test CreateRobotMember
t.Run("CreateRobotMember", func(t *testing.T) {
@ -355,13 +357,13 @@ func TestRobotMemberOperations(t *testing.T) {
},
}
id, err := testProvider.CreateRobotMember(ctx, teamID, robotData)
memberID, err := testProvider.CreateRobotMember(ctx, teamID, robotData)
assert.NoError(t, err)
assert.Greater(t, id, int64(0))
robotMemberID = id
assert.NotEmpty(t, memberID)
robotBusinessMemberID = memberID
// Verify robot member was created
member, err := testProvider.GetMemberByID(ctx, robotMemberID)
member, err := testProvider.GetMemberByMemberID(ctx, robotBusinessMemberID)
assert.NoError(t, err)
assert.Equal(t, "robot", member["member_type"])
assert.Equal(t, "active", member["status"]) // Robots are active by default
@ -378,17 +380,30 @@ func TestRobotMemberOperations(t *testing.T) {
assert.Equal(t, "A test robot for unit testing", robots[0]["bio"])
})
// Test UpdateRobotActivity
// Test UpdateRobotActivity - needs internal database ID
t.Run("UpdateRobotActivity", func(t *testing.T) {
err := testProvider.UpdateRobotActivity(ctx, robotMemberID, "working")
assert.NoError(t, err)
// Verify robot activity was updated (use GetMemberDetail for full fields)
// First get team_id for the robot
member, err := testProvider.GetMemberByID(ctx, robotMemberID)
// Get internal database ID from member_id
member, err := testProvider.GetMemberByMemberID(ctx, robotBusinessMemberID)
assert.NoError(t, err)
robotTeamID := member["team_id"].(string)
// UpdateRobotActivity still uses internal database ID
// We need to query the database to get it
m := model.Select("__yao.member")
result, err := m.Get(model.QueryParam{
Select: []interface{}{"id"},
Wheres: []model.QueryWhere{
{Column: "member_id", Value: robotBusinessMemberID},
},
Limit: 1,
})
assert.NoError(t, err)
assert.Len(t, result, 1)
robotDBID := result[0]["id"].(int64)
err = testProvider.UpdateRobotActivity(ctx, robotDBID, "working")
assert.NoError(t, err)
// Get robot members to verify status (robot members don't have user_id)
robots, err := testProvider.GetTeamRobotMembers(ctx, robotTeamID)
assert.NoError(t, err)
@ -401,16 +416,11 @@ func TestRobotMemberOperations(t *testing.T) {
// Test GetActiveRobotMembers
t.Run("GetActiveRobotMembers", func(t *testing.T) {
// First make sure our robot is active
err := testProvider.UpdateMemberByID(ctx, robotMemberID, maps.MapStrAny{
err := testProvider.UpdateMemberByMemberID(ctx, robotBusinessMemberID, maps.MapStrAny{
"autonomous_mode": true,
"status": "active",
})
if err != nil {
// If update fails, log the error and skip the test
t.Logf("Failed to update robot member: %v", err)
t.Skip("Robot member update failed, skipping GetActiveRobotMembers test")
return
}
assert.NoError(t, err)
robots, err := testProvider.GetActiveRobotMembers(ctx)
assert.NoError(t, err)
@ -741,11 +751,11 @@ func TestMemberInvitationExpiry(t *testing.T) {
"invitation_expires_at": expiredTime, // Expired 2 hours ago
}
memberID, err := testProvider.CreateMember(ctx, memberData)
businessMemberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
// Get the invitation_id
member, err := testProvider.GetMemberByID(ctx, memberID)
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
invitationID := member["invitation_id"].(string)
assert.NotEmpty(t, invitationID)
@ -799,12 +809,12 @@ func TestMemberInvitationIDOperations(t *testing.T) {
"invited_by": ownerUser,
}
memberID, err := testProvider.CreateMember(ctx, memberData)
businessMemberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
assert.Greater(t, memberID, int64(0))
assert.NotEmpty(t, businessMemberID)
// Get the created member to verify invitation_id was generated
member, err := testProvider.GetMemberByID(ctx, memberID)
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.NotNil(t, member["invitation_id"])
assert.NotEmpty(t, member["invitation_id"])
@ -937,11 +947,11 @@ func TestCreateMemberInvitationIDGeneration(t *testing.T) {
"invited_by": ownerUser,
}
memberID, err := testProvider.CreateMember(ctx, memberData)
businessMemberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
// Get the created member
member, err := testProvider.GetMemberByID(ctx, memberID)
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
// Verify invitation_id was generated
@ -966,11 +976,11 @@ func TestCreateMemberInvitationIDGeneration(t *testing.T) {
"status": "active",
}
memberID, err := testProvider.CreateMember(ctx, memberData)
businessMemberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
// Get the created member
member, err := testProvider.GetMemberByID(ctx, memberID)
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
// Verify invitation_id is nil for active members
@ -991,11 +1001,11 @@ func TestCreateMemberInvitationIDGeneration(t *testing.T) {
"invitation_id": explicitInvitationID,
}
memberID, err := testProvider.CreateMember(ctx, memberData)
businessMemberID, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
// Get the created member
member, err := testProvider.GetMemberByID(ctx, memberID)
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
// Verify explicit invitation_id was preserved
@ -1003,4 +1013,215 @@ func TestCreateMemberInvitationIDGeneration(t *testing.T) {
})
}
func TestMemberIDOperations(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 users
ownerUser := createTestUser(ctx, t, "owner"+testUUID)
memberUser := createTestUser(ctx, t, "member"+testUUID)
// Create test team
teamMap := maps.MapStrAny{
"name": "Member ID Test Team " + testUUID,
"display_name": "Member ID Test " + testUUID,
"description": "A test team for member_id testing",
"owner_id": ownerUser,
"status": "active",
"type": "corporation",
"type_id": "business",
"metadata": map[string]interface{}{"test": true},
}
teamID, err := testProvider.CreateTeam(ctx, teamMap)
assert.NoError(t, err)
var businessMemberID string
// Test CreateMember generates member_id
t.Run("CreateMember_GeneratesMemberID", func(t *testing.T) {
memberData := maps.MapStrAny{
"team_id": teamID,
"user_id": memberUser,
"member_type": "user",
"role_id": "user",
"status": "active",
}
_, err := testProvider.CreateMember(ctx, memberData)
assert.NoError(t, err)
// Get the created member to verify member_id was generated
member, err := testProvider.GetMember(ctx, teamID, memberUser)
assert.NoError(t, err)
assert.NotNil(t, member["member_id"])
assert.NotEmpty(t, member["member_id"])
businessMemberID = member["member_id"].(string)
t.Logf("Generated member_id: %s", businessMemberID)
})
// Test GetMemberByMemberID
t.Run("GetMemberByMemberID", func(t *testing.T) {
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.NotNil(t, member)
assert.Equal(t, businessMemberID, member["member_id"])
assert.Equal(t, teamID, member["team_id"])
assert.Equal(t, memberUser, member["user_id"])
})
// Test GetMemberDetailByMemberID
t.Run("GetMemberDetailByMemberID", func(t *testing.T) {
member, err := testProvider.GetMemberDetailByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.NotNil(t, member)
assert.Equal(t, businessMemberID, member["member_id"])
assert.Equal(t, teamID, member["team_id"])
// Should contain detailed fields
assert.Contains(t, member, "created_at")
assert.Contains(t, member, "updated_at")
})
// Test UpdateMemberByMemberID
t.Run("UpdateMemberByMemberID", func(t *testing.T) {
updateData := maps.MapStrAny{
"role_id": "admin",
"notes": "Updated via member_id",
}
err := testProvider.UpdateMemberByMemberID(ctx, businessMemberID, updateData)
assert.NoError(t, err)
// Verify update
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.Equal(t, "admin", member["role_id"])
})
// Test UpdateMemberRoleByMemberID
t.Run("UpdateMemberRoleByMemberID", func(t *testing.T) {
err := testProvider.UpdateMemberRoleByMemberID(ctx, businessMemberID, "moderator")
assert.NoError(t, err)
// Verify role was updated
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.Equal(t, "moderator", member["role_id"])
})
// Test UpdateMemberStatusByMemberID
t.Run("UpdateMemberStatusByMemberID", func(t *testing.T) {
err := testProvider.UpdateMemberStatusByMemberID(ctx, businessMemberID, "suspended")
assert.NoError(t, err)
// Verify status was updated
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.Equal(t, "suspended", member["status"])
// Change back to active
err = testProvider.UpdateMemberStatusByMemberID(ctx, businessMemberID, "active")
assert.NoError(t, err)
})
// Test UpdateMemberLastActivityByMemberID
t.Run("UpdateMemberLastActivityByMemberID", func(t *testing.T) {
err := testProvider.UpdateMemberLastActivityByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
// Verify last_active_at was updated and login_count incremented
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.NotNil(t, member["last_active_at"])
// login_count should be at least 1
loginCount := member["login_count"]
if loginCount != nil {
switch v := loginCount.(type) {
case int:
assert.True(t, v >= 1, "login_count should be at least 1")
case int64:
assert.True(t, v >= 1, "login_count should be at least 1")
case int32:
assert.True(t, v >= 1, "login_count should be at least 1")
default:
t.Logf("Unexpected login_count type: %T, value: %v", loginCount, loginCount)
assert.True(t, false, "login_count should be a numeric type")
}
} else {
assert.True(t, false, "login_count should not be nil")
}
})
// Test RemoveMemberByMemberID (at the end)
t.Run("RemoveMemberByMemberID", func(t *testing.T) {
err := testProvider.RemoveMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
// Verify member was removed
_, err = testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "member not found")
})
}
func TestMemberExistsByTeamEmail(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 users
ownerUser := createTestUser(ctx, t, "owner"+testUUID)
// Create test team
teamMap := maps.MapStrAny{
"name": "Email Test Team " + testUUID,
"display_name": "Email Test " + testUUID,
"description": "A test team for email testing",
"owner_id": ownerUser,
"status": "active",
}
teamID, err := testProvider.CreateTeam(ctx, teamMap)
assert.NoError(t, err)
testEmail := "test" + testUUID + "@example.com"
// Create robot member with email
t.Run("CreateRobotMemberWithEmail", func(t *testing.T) {
robotData := maps.MapStrAny{
"display_name": "TestBot" + testUUID,
"email": testEmail,
"role_id": "bot",
}
businessMemberID, err := testProvider.CreateRobotMember(ctx, teamID, robotData)
assert.NoError(t, err)
assert.NotEmpty(t, businessMemberID)
})
// Test MemberExistsByTeamEmail
t.Run("MemberExistsByTeamEmail_Exists", func(t *testing.T) {
exists, err := testProvider.MemberExistsByTeamEmail(ctx, teamID, testEmail)
assert.NoError(t, err)
assert.True(t, exists)
})
// Test with non-existent email
t.Run("MemberExistsByTeamEmail_NotExists", func(t *testing.T) {
exists, err := testProvider.MemberExistsByTeamEmail(ctx, teamID, "nonexistent@example.com")
assert.NoError(t, err)
assert.False(t, exists)
})
}
// Helper function createTestUser is defined in team_test.go

View file

@ -124,7 +124,15 @@ func (u *DefaultUser) UpdateOAuthAccount(ctx context.Context, provider string, s
}
if affected == 0 {
return fmt.Errorf("oauth account not found for provider %s with subject %s", provider, subject)
// Check if OAuth account exists
exists, checkErr := u.OAuthAccountExists(ctx, provider, subject)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateOAuth, checkErr)
}
if !exists {
return fmt.Errorf("oauth account not found for provider %s with subject %s", provider, subject)
}
// OAuth account exists but no changes were made
}
return nil

View file

@ -115,7 +115,15 @@ func (u *DefaultUser) UpdateRole(ctx context.Context, roleID string, roleData ma
}
if affected == 0 {
return fmt.Errorf(ErrRoleNotFound)
// Check if role exists
exists, checkErr := u.RoleExists(ctx, roleID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateRole, checkErr)
}
if !exists {
return fmt.Errorf(ErrRoleNotFound)
}
// Role exists but no changes were made
}
return nil
@ -280,7 +288,15 @@ func (u *DefaultUser) SetRolePermissions(ctx context.Context, roleID string, per
}
if affected == 0 {
return fmt.Errorf(ErrRoleNotFound)
// Check if role exists
exists, checkErr := u.RoleExists(ctx, roleID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateRole, checkErr)
}
if !exists {
return fmt.Errorf(ErrRoleNotFound)
}
// Role exists but no changes were made (same permissions)
}
return nil

View file

@ -142,7 +142,15 @@ func (u *DefaultUser) UpdateTeam(ctx context.Context, teamID string, teamData ma
}
if affected == 0 {
return fmt.Errorf(ErrTeamNotFound)
// Check if team exists
exists, checkErr := u.TeamExists(ctx, teamID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateTeam, checkErr)
}
if !exists {
return fmt.Errorf(ErrTeamNotFound)
}
// Team exists but no changes were made
}
return nil
@ -440,7 +448,15 @@ func (u *DefaultUser) VerifyTeam(ctx context.Context, teamID string, verifiedBy
}
if affected == 0 {
return fmt.Errorf(ErrTeamNotFound)
// Check if team exists
exists, checkErr := u.TeamExists(ctx, teamID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateTeam, checkErr)
}
if !exists {
return fmt.Errorf(ErrTeamNotFound)
}
// Team exists but no changes were made (already verified)
}
return nil
@ -468,7 +484,15 @@ func (u *DefaultUser) UnverifyTeam(ctx context.Context, teamID string) error {
}
if affected == 0 {
return fmt.Errorf(ErrTeamNotFound)
// Check if team exists
exists, checkErr := u.TeamExists(ctx, teamID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateTeam, checkErr)
}
if !exists {
return fmt.Errorf(ErrTeamNotFound)
}
// Team exists but no changes were made (already unverified)
}
return nil

View file

@ -330,14 +330,14 @@ func TestTeamMemberOperations(t *testing.T) {
teamID, err := testProvider.CreateTeam(ctx, teamMap)
assert.NoError(t, err)
var memberID int64
var businessMemberID string
// Test AddMember (invitation-based)
t.Run("AddMember", func(t *testing.T) {
id, err := testProvider.AddMember(ctx, teamID, memberUser, "user", ownerUser)
memberID, err := testProvider.AddMember(ctx, teamID, memberUser, "user", ownerUser)
assert.NoError(t, err)
assert.Greater(t, id, int64(0))
memberID = id
assert.NotEmpty(t, memberID)
businessMemberID = memberID
})
// Test MemberExists
@ -362,9 +362,9 @@ func TestTeamMemberOperations(t *testing.T) {
assert.Equal(t, "pending", member["status"]) // Initially pending
})
// Test GetMemberByID
t.Run("GetMemberByID", func(t *testing.T) {
member, err := testProvider.GetMemberByID(ctx, memberID)
// Test GetMemberByMemberID
t.Run("GetMemberByMemberID", func(t *testing.T) {
member, err := testProvider.GetMemberByMemberID(ctx, businessMemberID)
assert.NoError(t, err)
assert.NotNil(t, member)
assert.Equal(t, teamID, member["team_id"])
@ -462,9 +462,9 @@ func TestTeamMemberOperations(t *testing.T) {
"robot_status": "idle",
}
robotID, err := testProvider.CreateRobotMember(ctx, teamID, robotData)
robotMemberID, err := testProvider.CreateRobotMember(ctx, teamID, robotData)
assert.NoError(t, err)
assert.Greater(t, robotID, int64(0))
assert.NotEmpty(t, robotMemberID)
})
// Test GetTeamRobotMembers

View file

@ -115,7 +115,15 @@ func (u *DefaultUser) UpdateType(ctx context.Context, typeID string, typeData ma
}
if affected == 0 {
return fmt.Errorf(ErrTypeNotFound)
// Check if type exists
exists, checkErr := u.TypeExists(ctx, typeID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateType, checkErr)
}
if !exists {
return fmt.Errorf(ErrTypeNotFound)
}
// Type exists but no changes were made
}
return nil
@ -285,7 +293,15 @@ func (u *DefaultUser) SetTypeConfiguration(ctx context.Context, typeID string, c
}
if affected == 0 {
return fmt.Errorf(ErrTypeNotFound)
// Check if type exists
exists, checkErr := u.TypeExists(ctx, typeID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateType, checkErr)
}
if !exists {
return fmt.Errorf(ErrTypeNotFound)
}
// Type exists but no changes were made
}
return nil
@ -424,7 +440,15 @@ func (u *DefaultUser) SetTypePricing(ctx context.Context, typeID string, pricing
}
if affected == 0 {
return fmt.Errorf(ErrTypeNotFound)
// Check if type exists
exists, checkErr := u.TypeExists(ctx, typeID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateType, checkErr)
}
if !exists {
return fmt.Errorf(ErrTypeNotFound)
}
// Type exists but no changes were made
}
return nil
@ -458,7 +482,15 @@ func (u *DefaultUser) UpdateTypeStatus(ctx context.Context, typeID string, statu
}
if affected == 0 {
return fmt.Errorf(ErrTypeNotFound)
// Check if type exists
exists, checkErr := u.TypeExists(ctx, typeID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateType, checkErr)
}
if !exists {
return fmt.Errorf(ErrTypeNotFound)
}
// Type exists but no changes were made (already has this status)
}
return nil

View file

@ -295,7 +295,15 @@ func (u *DefaultUser) UpdatePassword(ctx context.Context, userID string, newPass
}
if affected == 0 {
return fmt.Errorf(ErrUserNotFound)
// Check if user exists
exists, checkErr := u.UserExists(ctx, userID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateUser, checkErr)
}
if !exists {
return fmt.Errorf(ErrUserNotFound)
}
// User exists but no changes were made (same password)
}
return nil
@ -327,7 +335,15 @@ func (u *DefaultUser) ResetPassword(ctx context.Context, userID string) (string,
}
if affected == 0 {
return "", fmt.Errorf(ErrUserNotFound)
// Check if user exists
exists, checkErr := u.UserExists(ctx, userID)
if checkErr != nil {
return "", fmt.Errorf(ErrFailedToUpdateUser, checkErr)
}
if !exists {
return "", fmt.Errorf(ErrUserNotFound)
}
// User exists but no changes were made
}
return randomPassword, nil
@ -400,7 +416,15 @@ func (u *DefaultUser) UpdateUser(ctx context.Context, userID string, userData ma
}
if affected == 0 {
return fmt.Errorf(ErrUserNotFound)
// Check if user exists
exists, checkErr := u.UserExists(ctx, userID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateUser, checkErr)
}
if !exists {
return fmt.Errorf(ErrUserNotFound)
}
// User exists but no changes were made
}
return nil

View file

@ -199,7 +199,15 @@ func (u *DefaultUser) EnableMFA(ctx context.Context, userID string, secret strin
}
if affected == 0 {
return fmt.Errorf(ErrUserNotFound)
// Check if user exists
exists, checkErr := u.UserExists(ctx, userID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateMFAStatus, checkErr)
}
if !exists {
return fmt.Errorf(ErrUserNotFound)
}
// User exists but no changes were made (already enabled with same secret)
}
return nil
@ -272,7 +280,15 @@ func (u *DefaultUser) DisableMFA(ctx context.Context, userID string, code string
}
if affected == 0 {
return fmt.Errorf(ErrUserNotFound)
// Check if user exists
exists, checkErr := u.UserExists(ctx, userID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateMFAStatus, checkErr)
}
if !exists {
return fmt.Errorf(ErrUserNotFound)
}
// User exists but no changes were made (already disabled)
}
return nil
@ -416,7 +432,15 @@ func (u *DefaultUser) GenerateRecoveryCodes(ctx context.Context, userID string)
}
if affected == 0 {
return nil, fmt.Errorf(ErrUserNotFound)
// Check if user exists
exists, checkErr := u.UserExists(ctx, userID)
if checkErr != nil {
return nil, fmt.Errorf(ErrFailedToUpdateMFAStatus, checkErr)
}
if !exists {
return nil, fmt.Errorf(ErrUserNotFound)
}
// User exists but no changes were made
}
// Return all generated recovery codes

View file

@ -105,7 +105,15 @@ func (u *DefaultUser) SetUserRole(ctx context.Context, userID string, roleID str
}
if affected == 0 {
return fmt.Errorf(ErrUserNotFound)
// Check if user exists
exists, checkErr := u.UserExists(ctx, userID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateUser, checkErr)
}
if !exists {
return fmt.Errorf(ErrUserNotFound)
}
// User exists but no changes were made (already has this role)
}
return nil
@ -270,7 +278,15 @@ func (u *DefaultUser) SetUserType(ctx context.Context, userID string, typeID str
}
if affected == 0 {
return fmt.Errorf(ErrUserNotFound)
// Check if user exists
exists, checkErr := u.UserExists(ctx, userID)
if checkErr != nil {
return fmt.Errorf(ErrFailedToUpdateUser, checkErr)
}
if !exists {
return fmt.Errorf(ErrUserNotFound)
}
// User exists but no changes were made (already has this type)
}
return nil

View file

@ -115,6 +115,79 @@ func (u *DefaultUser) generateInvitationID() (string, error) {
return prefix + id, nil
}
// generateMemberID generates a new member_id based on configured strategy (internal use)
func (u *DefaultUser) generateMemberID() (string, error) {
var id string
var err error
switch u.idStrategy {
case UUIDStrategy:
id, err = generateUUID()
case NanoIDStrategy:
id, err = generateNanoID(12) // 12 characters, URL-safe, readable
case NumericStrategy:
id, err = generateNumericID(12) // 12 characters, numeric, readable (default)
default:
id, err = generateNumericID(12) // 12 characters, URL-safe, readable
}
if err != nil {
return "", err
}
// Add prefix if configured
if u.idPrefix != "" {
return u.idPrefix + id, nil
}
return id, nil
}
// generateMemberIDWithRetry generates a unique member_id with collision detection
func (u *DefaultUser) generateMemberIDWithRetry(ctx context.Context) (string, error) {
const maxRetries = 10 // Prevent infinite loops
for i := 0; i < maxRetries; i++ {
// Generate new ID
id, err := u.generateMemberID()
if err != nil {
return "", fmt.Errorf("failed to generate member_id: %w", err)
}
// Check if ID already exists
exists, err := u.memberIDExists(ctx, id)
if err != nil {
return "", fmt.Errorf("failed to check member_id existence: %w", err)
}
if !exists {
return id, nil // Found unique ID
}
// ID exists, retry with new generation
}
return "", fmt.Errorf("failed to generate unique member_id after %d retries", maxRetries)
}
// memberIDExists checks if a member_id already exists in the database
func (u *DefaultUser) memberIDExists(ctx context.Context, memberID string) (bool, error) {
m := model.Select(u.memberModel)
members, err := m.Get(model.QueryParam{
Select: []interface{}{"id"}, // Just get primary key, minimal data
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
},
Limit: 1,
})
if err != nil {
return false, err
}
return len(members) > 0, nil
}
// userIDExists checks if a user_id already exists in the database
func (u *DefaultUser) userIDExists(ctx context.Context, userID string) (bool, error) {
m := model.Select(u.model)

View file

@ -296,22 +296,27 @@ type UserProvider interface {
GetMember(ctx context.Context, teamID string, userID string) (maps.MapStrAny, error)
GetMemberDetail(ctx context.Context, teamID string, userID string) (maps.MapStrAny, error)
GetMemberByID(ctx context.Context, memberID int64) (maps.MapStrAny, error)
GetMemberByMemberID(ctx context.Context, memberID string) (maps.MapStrAny, error)
GetMemberDetailByMemberID(ctx context.Context, memberID string) (maps.MapStrAny, error)
GetMemberByInvitationID(ctx context.Context, invitationID string) (maps.MapStrAny, error)
MemberExists(ctx context.Context, teamID string, userID string) (bool, error)
CreateMember(ctx context.Context, memberData maps.MapStrAny) (int64, error)
MemberExistsByTeamEmail(ctx context.Context, teamID string, email string) (bool, error)
CreateMember(ctx context.Context, memberData maps.MapStrAny) (string, error)
UpdateMember(ctx context.Context, teamID string, userID string, memberData maps.MapStrAny) error
UpdateMemberByID(ctx context.Context, memberID int64, memberData maps.MapStrAny) error
UpdateMemberByMemberID(ctx context.Context, memberID string, memberData maps.MapStrAny) error
UpdateMemberByInvitationID(ctx context.Context, invitationID string, memberData maps.MapStrAny) error
RemoveMember(ctx context.Context, teamID string, userID string) error
RemoveMemberByMemberID(ctx context.Context, memberID string) error
RemoveMemberByInvitationID(ctx context.Context, invitationID string) error
RemoveAllTeamMembers(ctx context.Context, teamID string) error
// Member Invitation Management
AddMember(ctx context.Context, teamID string, userID string, roleID string, invitedBy string) (int64, error)
AddMember(ctx context.Context, teamID string, userID string, roleID string, invitedBy string) (string, error)
AcceptInvitation(ctx context.Context, invitationID string, invitationToken string, userID string) error
// Robot Member Operations
CreateRobotMember(ctx context.Context, teamID string, robotData maps.MapStrAny) (int64, error)
CreateRobotMember(ctx context.Context, teamID string, robotData maps.MapStrAny) (string, error)
UpdateRobotActivity(ctx context.Context, memberID int64, robotStatus string) error
GetActiveRobotMembers(ctx context.Context) ([]maps.MapStr, error)
@ -323,8 +328,11 @@ type UserProvider interface {
// Member Management
UpdateMemberRole(ctx context.Context, teamID string, userID string, roleID string) error
UpdateMemberRoleByMemberID(ctx context.Context, memberID string, roleID string) error
UpdateMemberStatus(ctx context.Context, teamID string, userID string, status string) error
UpdateMemberStatusByMemberID(ctx context.Context, memberID string, status string) error
UpdateMemberLastActivity(ctx context.Context, teamID string, userID string) error
UpdateMemberLastActivityByMemberID(ctx context.Context, memberID string) error
// Member List and Search
PaginateMembers(ctx context.Context, param model.QueryParam, page int, pagesize int) (maps.MapStr, error)

View file

@ -463,7 +463,7 @@ func setupACLTestData(t *testing.T, ctx context.Context, provider types.UserProv
data := &ACLTestData{
UserIDs: make([]string, 0),
TeamIDs: make([]string, 0),
MemberIDs: make([]int64, 0),
MemberIDs: make([]string, 0),
}
// Set up roles first
@ -577,7 +577,7 @@ func cleanupACLTestData(t *testing.T, ctx context.Context, provider types.UserPr
type ACLTestData struct {
UserIDs []string
TeamIDs []string
MemberIDs []int64
MemberIDs []string
}
// TestEnforceIntegration tests the complete enforcement flow

View file

@ -1036,3 +1036,30 @@ func createTestUser(t *testing.T, server *openapi.OpenAPI, clientID string) (str
t.Logf("Created test user: %s with subject: %s", testUserID, subject)
return testUserID, subject
}
// GetUserProvider returns the UserProvider instance for direct database operations in tests.
// This is useful for creating test data directly without going through API endpoints.
//
// USAGE:
//
// provider := testutils.GetUserProvider(t)
// memberID, err := provider.CreateMember(ctx, memberData)
//
// ERROR HANDLING:
// If the provider is not available, the test will fail immediately with a descriptive error message.
func GetUserProvider(t *testing.T) types.UserProvider {
testMutex.RLock()
defer testMutex.RUnlock()
oauthService := oauth.OAuth
if oauthService == nil {
t.Fatal("Global OAuth service not initialized. Call Prepare(t) first.")
}
provider, err := oauthService.GetUserProvider()
if err != nil || provider == nil {
t.Fatalf("UserProvider not available: %v", err)
}
return provider
}

View file

@ -2,6 +2,7 @@ package user_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@ -11,6 +12,7 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/kun/maps"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/tests/testutils"
)
@ -269,193 +271,6 @@ func TestMemberGet(t *testing.T) {
}
}
// TestMemberCreateDirect tests the POST /user/teams/:team_id/members endpoint
func TestMemberCreateDirect(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 Create Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, testClient.ClientID)
// Obtain access token for authenticated requests
tokenInfo := testutils.ObtainAccessToken(t, serverURL, testClient.ClientID, testClient.ClientSecret, "https://localhost/callback", "openid profile")
// Create a test team
createdTeam := createTestTeam(t, serverURL, baseURL, tokenInfo.AccessToken, "Member Create Test Team")
teamID := getTeamID(createdTeam)
testCases := []struct {
name string
teamID string
body map[string]interface{}
headers map[string]string
expectCode int
expectMsg string
}{
{
"create member without authentication",
teamID,
map[string]interface{}{
"user_id": "test-user-123",
"role_id": "member",
},
map[string]string{},
401,
"should require authentication",
},
{
"create member with valid data",
teamID,
map[string]interface{}{
"user_id": "test-user-123",
"member_type": "user",
"role_id": "member",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
201,
"should create member successfully",
},
{
"create member with settings",
teamID,
map[string]interface{}{
"user_id": "test-user-456",
"member_type": "user",
"role_id": "admin",
"settings": map[string]interface{}{
"notifications": true,
"permissions": []string{"read", "write"},
},
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
201,
"should create member with settings",
},
{
"create member without user_id",
teamID,
map[string]interface{}{
"role_id": "member",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
400,
"should require user_id",
},
{
"create member without role_id",
teamID,
map[string]interface{}{
"user_id": "test-user-789",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
400,
"should require role_id",
},
{
"create duplicate member",
teamID,
map[string]interface{}{
"user_id": "test-user-123", // Same as first successful case
"role_id": "member",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
409,
"should reject duplicate member",
},
{
"create member in non-existent team",
"non-existent-team-id",
map[string]interface{}{
"user_id": "test-user-999",
"role_id": "member",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
404,
"should return not found for non-existent team",
},
{
"create member with invalid JSON",
teamID,
nil, // Will send invalid JSON
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
400,
"should handle invalid JSON",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
requestURL := serverURL + baseURL + "/user/teams/" + tc.teamID + "/members"
var req *http.Request
var err error
if tc.body == nil {
// Send invalid JSON for invalid JSON test case
req, err = http.NewRequest("POST", requestURL, bytes.NewBufferString("invalid json"))
} else {
bodyBytes, _ := json.Marshal(tc.body)
req, err = http.NewRequest("POST", 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 == 201 {
// Parse response as created member
var response map[string]interface{}
err = json.Unmarshal(body, &response)
assert.NoError(t, err, "Should parse JSON response")
// Verify response structure
assert.Contains(t, response, "member_id", "Should have member_id")
assert.NotEmpty(t, response["member_id"], "Member ID should not be empty")
}
t.Logf("Member create test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body))
}
})
}
}
// TestMemberUpdate tests the PUT /user/teams/:team_id/members/:member_id endpoint
func TestMemberUpdate(t *testing.T) {
// Initialize test environment
@ -819,22 +634,6 @@ func TestMemberPermissionVerification(t *testing.T) {
200,
"member should be able to get member details",
},
{
"owner can create members",
"/user/teams/" + teamID + "/members",
"POST",
ownerToken.AccessToken,
201, // Will create successfully
"owner should be able to create members",
},
{
"member cannot create members",
"/user/teams/" + teamID + "/members",
"POST",
nonOwnerToken.AccessToken,
403,
"member should not be able to create members",
},
{
"owner can update members",
"/user/teams/" + teamID + "/members/" + memberID,
@ -944,42 +743,32 @@ func createTestTeam(t *testing.T, serverURL, baseURL, accessToken, teamName stri
return team
}
// createTestMember creates a member for testing and returns the user_id (which serves as member_id in API context)
// createTestMember creates a member for testing using provider directly (no API call).
// This is the recommended approach since direct member creation endpoint was removed.
// Members should normally be added via invitation flow or robot creation endpoint.
// Returns the user_id which serves as member_id in API context.
func createTestMember(t *testing.T, serverURL, baseURL, teamID, accessToken, userID string) string {
createMemberBody := map[string]interface{}{
// Get user provider for direct database operations
provider := testutils.GetUserProvider(t)
ctx := context.Background()
// Create member data using maps.MapStrAny (required by UserProvider interface)
memberData := maps.MapStrAny{
"team_id": teamID,
"user_id": userID,
"member_type": "user",
"role_id": "member",
"role_id": "team:member",
"status": "active",
}
bodyBytes, err := json.Marshal(createMemberBody)
assert.NoError(t, err, "Should marshal member creation body")
// Create member directly in database
memberID, err := provider.CreateMember(ctx, memberData)
assert.NoError(t, err, "Should create member in database")
assert.NotEmpty(t, memberID, "Member ID should not be empty")
req, err := http.NewRequest("POST", serverURL+baseURL+"/user/teams/"+teamID+"/members", bytes.NewBuffer(bodyBytes))
assert.NoError(t, err, "Should create member creation request")
t.Logf("Created test member directly in database: user_id=%s, member_id=%s, team_id=%s", userID, memberID, teamID)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{}
resp, err := client.Do(req)
assert.NoError(t, err, "Should send member creation request")
defer resp.Body.Close()
assert.Equal(t, 201, resp.StatusCode, "Should create member successfully")
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err, "Should read member creation response")
var response map[string]interface{}
err = json.Unmarshal(body, &response)
assert.NoError(t, err, "Should parse member creation response")
_, ok := response["member_id"]
assert.True(t, ok, "Should have member_id in response")
// For API purposes, the member_id is the user_id in the context of team_id
// So we return the user_id that was used to create the member
// Return user_id (which is used as member identifier in API context)
return userID
}
@ -1333,4 +1122,162 @@ func toString(v interface{}) string {
}
}
// TestMemberCheckEmail tests the GET /user/teams/:team_id/members/check endpoint
func TestMemberCheckEmail(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 Check Email Test Client", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, testClient.ClientID)
// Obtain access token for authenticated requests
tokenInfo := testutils.ObtainAccessToken(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, "Email Check Test Team "+testUUID)
teamID := getTeamID(createdTeam)
// Create a robot member with a known email
existingEmail := fmt.Sprintf("existing-robot-%s@test.com", testUUID)
robotBody := map[string]interface{}{
"name": "Existing Robot",
"email": existingEmail,
"role": "member",
"prompt": "You are a test robot",
}
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 {
robotResp.Body.Close()
assert.Equal(t, 201, robotResp.StatusCode, "Should create robot member successfully")
}
testCases := []struct {
name string
teamID string
email string
headers map[string]string
expectCode int
expectExists bool
expectMsg string
}{
{
"check email without authentication",
teamID,
existingEmail,
map[string]string{},
401,
false,
"should require authentication",
},
{
"check existing email",
teamID,
existingEmail,
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
200,
true,
"should return exists=true for existing email",
},
{
"check non-existing email",
teamID,
fmt.Sprintf("nonexistent-%s@test.com", testUUID),
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
200,
false,
"should return exists=false for non-existing email",
},
{
"check email without email parameter",
teamID,
"",
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
400,
false,
"should require email parameter",
},
{
"check email in non-existent team",
"non-existent-team-id",
"test@example.com",
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
404,
false,
"should return not found for non-existent team",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
requestURL := serverURL + baseURL + "/user/teams/" + tc.teamID + "/members/check"
if tc.email != "" {
requestURL += "?email=" + tc.email
}
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)
}
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
var response map[string]interface{}
err = json.Unmarshal(body, &response)
assert.NoError(t, err, "Should parse JSON response")
// Verify response structure
assert.Contains(t, response, "exists", "Should have exists field")
assert.Contains(t, response, "email", "Should have email field")
assert.Contains(t, response, "team_id", "Should have team_id field")
// Verify values
assert.Equal(t, tc.expectExists, response["exists"], "Should have correct exists value")
assert.Equal(t, tc.email, response["email"], "Should have correct email")
assert.Equal(t, teamID, response["team_id"], "Should have correct team_id")
}
t.Logf("Member check email test %s: status=%d, body=%s", tc.name, resp.StatusCode, string(body))
}
})
}
}
// Note: getTeamID function is already defined in team_test.go

View file

@ -91,6 +91,75 @@ func GinMemberList(c *gin.Context) {
response.RespondWithSuccess(c, http.StatusOK, result)
}
// GinMemberCheckEmail handles GET /api/user/teams/:id/members/check?email=xxx - Check if email exists in team
func GinMemberCheckEmail(c *gin.Context) {
// Get authorized user info
authInfo := oauth.GetAuthorizedInfo(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")
if teamID == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Team ID is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
email := c.Query("email")
if email == "" {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Email query parameter is required",
}
response.RespondWithError(c, response.StatusBadRequest, errorResp)
return
}
// Call business logic
exists, err := memberCheckEmail(c.Request.Context(), authInfo.UserID, teamID, email)
if err != nil {
log.Error("Failed to check member email: %v", err)
// Check error type for appropriate response
if strings.Contains(err.Error(), "not found") {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "Team 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: fmt.Sprintf("Failed to check member email: %v", err),
}
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
}
return
}
// Return result
result := map[string]interface{}{
"exists": exists,
"email": email,
"team_id": teamID,
}
response.RespondWithSuccess(c, http.StatusOK, result)
}
// GinMemberGet handles GET /teams/:team_id/members/:member_id - Get team member details
func GinMemberGet(c *gin.Context) {
// Get authorized user info
@ -620,29 +689,57 @@ func memberGet(ctx context.Context, userID, teamID, memberID string) (maps.MapSt
return memberData, nil
}
// memberCreateRobot handles the business logic for creating a robot member
func memberCreateRobot(ctx context.Context, userID, teamID string, robotData maps.MapStrAny) (int64, error) {
// Check if user has access to the team (write permission: owner only)
isOwner, _, err := checkTeamAccess(ctx, teamID, userID)
// memberCheckEmail handles the business logic for checking if member exists by team_id and email
func memberCheckEmail(ctx context.Context, userID, teamID, email string) (bool, error) {
// Check if user has access to the team (read permission: owner or member)
isOwner, isMember, err := checkTeamAccess(ctx, teamID, userID)
if err != nil {
return 0, err
return false, err
}
// Only allow access if user is owner
if !isOwner {
return 0, fmt.Errorf("access denied: only team owner can add robot members")
// Allow access if user is owner or member
if !isOwner && !isMember {
return false, fmt.Errorf("access denied: user is not a member of this team")
}
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
return 0, fmt.Errorf("failed to get user provider: %w", err)
return false, fmt.Errorf("failed to get user provider: %w", err)
}
// Check if member exists by team_id and email
exists, err := provider.MemberExistsByTeamEmail(ctx, teamID, email)
if err != nil {
return false, fmt.Errorf("failed to check member existence: %w", err)
}
return exists, nil
}
// memberCreateRobot handles the business logic for creating a robot member
func memberCreateRobot(ctx context.Context, userID, teamID string, robotData maps.MapStrAny) (string, 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 add robot members")
}
// Get user provider instance
provider, err := getUserProvider()
if err != nil {
return "", fmt.Errorf("failed to get user provider: %w", err)
}
// Use CreateRobotMember method which handles robot-specific logic
memberID, err := provider.CreateRobotMember(ctx, teamID, robotData)
if err != nil {
return 0, fmt.Errorf("failed to create robot member: %w", err)
return "", fmt.Errorf("failed to create robot member: %w", err)
}
return memberID, nil

View file

@ -1162,13 +1162,13 @@ func teamInvitationCreate(ctx context.Context, userID, teamID string, invitation
invitationData["updated_at"] = time.Now()
// Create invitation (as a pending member)
memberID, err := provider.CreateMember(ctx, invitationData)
businessMemberID, err := provider.CreateMember(ctx, invitationData)
if err != nil {
return "", fmt.Errorf("failed to create invitation: %w", err)
}
// Get the created member to retrieve the generated invitation_id
createdMember, err := provider.GetMemberByID(ctx, memberID)
createdMember, err := provider.GetMemberByMemberID(ctx, businessMemberID)
if err != nil {
return "", fmt.Errorf("failed to retrieve created invitation: %w", err)
}

View file

@ -133,11 +133,12 @@ func attachTeam(group *gin.RouterGroup, oauth types.OAuth) {
team.GET("/current", GinTeamCurrent)
// Team Members - Nested resource endpoints
team.GET("/:id/members", GinMemberList) // GET /teams/:id/members - List team members
team.POST("/:id/members/robots", GinMemberCreateRobot) // POST /teams/:id/members/robots - Add robot member
team.GET("/:id/members/:member_id", GinMemberGet) // GET /teams/:id/members/:member_id - Get member details
team.PUT("/:id/members/:member_id", GinMemberUpdate) // PUT /teams/:id/members/:member_id - Update member
team.DELETE("/:id/members/:member_id", GinMemberDelete) // DELETE /teams/:id/members/:member_id - Remove member
team.GET("/:id/members", GinMemberList) // GET /api/user/teams/:id/members - List team members
team.GET("/:id/members/check", GinMemberCheckEmail) // GET /api/user/teams/:id/members/check?email=xxx - Check if email exists in team
team.POST("/:id/members/robots", GinMemberCreateRobot) // POST /api/user/teams/:id/members/robots - Add robot member
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.DELETE("/:id/members/:member_id", GinMemberDelete) // DELETE /api/user/teams/:id/members/:member_id - Remove member
// Team Invitations - Nested resource endpoints
team.GET("/:id/invitations", GinTeamInvitationList) // GET /teams/:id/invitations - List invitations

View file

@ -18,6 +18,15 @@
"comment": "Primary key identifier",
"primary": true
},
{
"name": "member_id",
"type": "string",
"label": "Member ID",
"comment": "Global unique member identifier",
"length": 255,
"unique": true,
"index": true
},
{
"name": "team_id",
"type": "string",