Enhance member management with member_id and email checks
- Introduced member_id as a unique identifier for members, improving data integrity and consistency across member operations. - Updated member-related functions to utilize member_id instead of internal IDs, enhancing API usability and clarity. - Implemented email existence checks for team members, allowing for better validation during member creation and updates. - Refactored existing tests to accommodate changes in member identification and email validation, ensuring comprehensive coverage and reliability. - Added new API endpoint for checking if an email exists within a team, improving user experience during member management.
This commit is contained in:
parent
fd10a058b1
commit
5f6f8d7911
13 changed files with 973 additions and 231 deletions
284
data/bindata.go
284
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,46 @@ 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
|
||||
}
|
||||
|
||||
// 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 +223,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 +231,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 +251,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 +282,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 +332,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 +446,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)
|
||||
}
|
||||
|
|
@ -403,7 +479,7 @@ func (u *DefaultUser) UpdateMember(ctx context.Context, teamID string, userID st
|
|||
// UpdateMemberByID updates a member by internal ID
|
||||
func (u *DefaultUser) UpdateMemberByID(ctx context.Context, memberID 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)
|
||||
}
|
||||
|
|
@ -432,6 +508,38 @@ func (u *DefaultUser) UpdateMemberByID(ctx context.Context, memberID int64, memb
|
|||
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)
|
||||
}
|
||||
|
||||
if affected == 0 {
|
||||
return fmt.Errorf(ErrMemberNotFound)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveMember removes a member from a team (soft delete)
|
||||
func (u *DefaultUser) RemoveMember(ctx context.Context, teamID string, userID string) error {
|
||||
m := model.Select(u.memberModel)
|
||||
|
|
@ -454,6 +562,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,6 +750,47 @@ func (u *DefaultUser) UpdateMemberLastActivity(ctx context.Context, teamID strin
|
|||
return u.UpdateMember(ctx, teamID, userID, updateData)
|
||||
}
|
||||
|
||||
// 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
|
||||
func (u *DefaultUser) UpdateRobotActivity(ctx context.Context, memberID int64, robotStatus string) error {
|
||||
updateData := maps.MapStrAny{
|
||||
|
|
|
|||
|
|
@ -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,7 +416,7 @@ 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",
|
||||
})
|
||||
|
|
@ -741,11 +756,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 +814,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 +952,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 +981,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 +1006,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 +1018,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1333,4 +1333,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue