package service import ( "baron-sso-backend/internal/domain" "context" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "gorm.io/gorm" ) // --- Mocks for Repositories --- type MockUserGroupRepository struct { mock.Mock } func (m *MockUserGroupRepository) Create(ctx context.Context, group *domain.UserGroup) error { return m.Called(ctx, group).Error(0) } func (m *MockUserGroupRepository) Update(ctx context.Context, group *domain.UserGroup) error { return m.Called(ctx, group).Error(0) } func (m *MockUserGroupRepository) Delete(ctx context.Context, id string) error { return m.Called(ctx, id).Error(0) } func (m *MockUserGroupRepository) FindByID(ctx context.Context, id string) (*domain.UserGroup, error) { args := m.Called(ctx, id) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*domain.UserGroup), args.Error(1) } func (m *MockUserGroupRepository) ListByTenantID(ctx context.Context, tenantID string) ([]domain.UserGroup, error) { args := m.Called(ctx, tenantID) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).([]domain.UserGroup), args.Error(1) } type MockUserRepository struct { mock.Mock updatedUsers []domain.User } func (m *MockUserRepository) Create(ctx context.Context, user *domain.User) error { return nil } func (m *MockUserRepository) Update(ctx context.Context, user *domain.User) error { copied := *user m.updatedUsers = append(m.updatedUsers, copied) return nil } func (m *MockUserRepository) Delete(ctx context.Context, id string) error { return m.Called(ctx, id).Error(0) } func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) { return nil, nil } func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) { args := m.Called(ctx, id) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*domain.User), args.Error(1) } func (m *MockUserRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) { args := m.Called(ctx, ids) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).([]domain.User), args.Error(1) } func (m *MockUserRepository) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) { return nil, nil } func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { return nil, 0, nil } func (m *MockUserRepository) CountByTenant(ctx context.Context, tenantID string) (int64, error) { args := m.Called(tenantID) return int64(args.Int(0)), args.Error(1) } func (m *MockUserRepository) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) { args := m.Called(ctx, tenantIDs) return args.Get(0).([]domain.User), args.Error(1) } func (m *MockUserRepository) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { args := m.Called(tenantIDs) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(map[string]int64), args.Error(1) } func (m *MockUserRepository) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) { args := m.Called(ctx, codes) return args.Get(0).([]domain.User), args.Error(1) } func (m *MockUserRepository) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { args := m.Called(ctx, codes) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(map[string]int64), args.Error(1) } func (m *MockUserRepository) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error { return nil } func (m *MockUserRepository) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) { return nil, nil } func (m *MockUserRepository) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) { return false, nil } func (m *MockUserRepository) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) { return "", nil } func (m *MockUserRepository) DB() *gorm.DB { return nil } type fakeUserGroupWorksmobileSyncer struct { userUpserts []domain.User } func (f *fakeUserGroupWorksmobileSyncer) EnqueueTenantUpsertIfInScope(ctx context.Context, tenant domain.Tenant) error { return nil } func (f *fakeUserGroupWorksmobileSyncer) EnqueueTenantDeleteIfInScope(ctx context.Context, tenant domain.Tenant) error { return nil } func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserUpsertIfInScope(ctx context.Context, user domain.User) error { f.userUpserts = append(f.userUpserts, user) return nil } func (f *fakeUserGroupWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx context.Context, user domain.User) error { return nil } type MockKetoOutboxRepository struct { mock.Mock } type MockTenantRepository struct { mock.Mock } func (m *MockTenantRepository) Create(ctx context.Context, tenant *domain.Tenant) error { return m.Called(ctx, tenant).Error(0) } func (m *MockTenantRepository) Update(ctx context.Context, tenant *domain.Tenant) error { return nil } func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) { args := m.Called(ctx, id) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*domain.Tenant), args.Error(1) } func (m *MockTenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) { args := m.Called(ctx, ids) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).([]domain.Tenant), args.Error(1) } func (m *MockTenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { return nil, nil } func (m *MockTenantRepository) FindByName(ctx context.Context, name string) (*domain.Tenant, error) { return nil, nil } func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { return nil, nil } func (m *MockTenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { return nil, 0, nil } func (m *MockTenantRepository) ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) { return nil, nil } func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error { return nil } func (m *MockTenantRepository) DeleteBulk(ctx context.Context, ids []string) error { return nil } func TestUserGroupService_Create(t *testing.T) { mockRepo := new(MockUserGroupRepository) mockTenantRepo := new(MockTenantRepository) mockKeto := new(MockKetoServiceShared) mockOutbox := new(MockKetoOutboxRepositoryShared) svc := NewUserGroupService(mockRepo, nil, mockTenantRepo, mockKeto, mockOutbox, nil) tenantID := "company-1" parentID := "parent-group-id" name := "Test Group" description := "Group Description" unitType := "Team" // Mock Tenant FindByID for parent check mockTenantRepo.On("FindByID", mock.Anything, parentID).Return(&domain.Tenant{ID: parentID}, nil) // Mock Tenant creation (Polymorphic) mockTenantRepo.On("Create", mock.Anything, mock.MatchedBy(func(ten *domain.Tenant) bool { return ten.Type == domain.TenantTypeOrganization && ten.Name == name && *ten.ParentID == parentID })).Return(nil) // Mock UserGroup creation mockRepo.On("Create", mock.Anything, mock.MatchedBy(func(g *domain.UserGroup) bool { return g.Name == name && *g.ParentID == parentID && g.TenantID == tenantID })).Return(nil) // Mock Keto sync via Outbox mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { return e.Namespace == "Tenant" && e.Relation == "parents" && e.Subject == "Tenant:"+parentID })).Return(nil) group, err := svc.Create(context.Background(), tenantID, &parentID, name, description, unitType) assert.NoError(t, err) assert.NotNil(t, group) mockTenantRepo.AssertExpectations(t) mockRepo.AssertExpectations(t) mockOutbox.AssertExpectations(t) } func TestUserGroupService_AddMember(t *testing.T) { mockOutbox := new(MockKetoOutboxRepositoryShared) mockUserGroupRepo := new(MockUserGroupRepository) mockUserRepo := new(MockUserRepository) mockTenantRepo := new(MockTenantRepository) mockKratos := new(MockKratosAdminServiceShared) svc := NewUserGroupService(mockUserGroupRepo, mockUserRepo, mockTenantRepo, nil, mockOutbox, mockKratos) groupID := "group-1" userID := "user-1" tenantID := "tenant-1" tenantSlug := "tenant-slug" mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, TenantID: tenantID, Name: "Sales"}, nil) mockUserRepo.On("FindByID", mock.Anything, userID).Return(&domain.User{ID: userID}, nil) mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: tenantSlug}, nil) // Mock Kratos mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{ ID: userID, Traits: map[string]any{"email": "user@test.com"}, State: "active", }, nil) mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{}, nil) // Mock local user repo update (Ignored since Update is hardcoded to return nil without calling m.Called) // mockUserRepo.On("Update", mock.Anything, mock.MatchedBy(func(u *domain.User) bool { // return u.CompanyCode == tenantSlug && *u.TenantID == tenantID && u.Department == "Sales" // })).Return(nil) // First Outbox Create for Group mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID })).Return(nil).Once() // Second Outbox Create for Tenant mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID })).Return(nil).Once() err := svc.AddMember(context.Background(), groupID, userID) assert.NoError(t, err) mockOutbox.AssertExpectations(t) mockKratos.AssertExpectations(t) // mockUserRepo.AssertExpectations(t) } func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T) { mockOutbox := new(MockKetoOutboxRepositoryShared) mockUserGroupRepo := new(MockUserGroupRepository) mockUserRepo := new(MockUserRepository) mockTenantRepo := new(MockTenantRepository) mockKratos := new(MockKratosAdminServiceShared) svc := NewUserGroupService(mockUserGroupRepo, mockUserRepo, mockTenantRepo, nil, mockOutbox, mockKratos) groupID := "group-1" userID := "user-1" tenantID := "tenant-1" tenantSlug := "tenant-slug" mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, TenantID: tenantID, Name: "Sales"}, nil) mockUserRepo.On("FindByID", mock.Anything, userID).Return(nil, gorm.ErrRecordNotFound) mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: tenantSlug}, nil) mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{ ID: userID, Traits: map[string]any{ "email": "user@test.com", "name": "User Test", }, State: "active", }, nil) mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]any) bool { _, hasCompanyCode := traits["companyCode"] return !hasCompanyCode && traits["tenant_id"] == tenantID && traits["department"] == "Sales" }), "active").Return(&KratosIdentity{ ID: userID, Traits: map[string]any{ "email": "user@test.com", "name": "User Test", "tenant_id": tenantID, "department": "Sales", }, State: "active", }, nil) mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID })).Return(nil).Once() mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID })).Return(nil).Once() err := svc.AddMember(context.Background(), groupID, userID) assert.NoError(t, err) assert.Len(t, mockUserRepo.updatedUsers, 1) assert.Equal(t, userID, mockUserRepo.updatedUsers[0].ID) assert.Empty(t, mockUserRepo.updatedUsers[0].CompanyCode) assert.NotNil(t, mockUserRepo.updatedUsers[0].TenantID) assert.Equal(t, tenantID, *mockUserRepo.updatedUsers[0].TenantID) assert.Equal(t, "Sales", mockUserRepo.updatedUsers[0].Department) mockOutbox.AssertExpectations(t) mockKratos.AssertExpectations(t) } func TestUserGroupService_AddMemberEnqueuesWorksmobileUserSync(t *testing.T) { mockOutbox := new(MockKetoOutboxRepositoryShared) mockUserGroupRepo := new(MockUserGroupRepository) mockUserRepo := new(MockUserRepository) mockTenantRepo := new(MockTenantRepository) mockKratos := new(MockKratosAdminServiceShared) worksmobile := &fakeUserGroupWorksmobileSyncer{} svc := NewUserGroupService(mockUserGroupRepo, mockUserRepo, mockTenantRepo, nil, mockOutbox, mockKratos) svc.SetWorksmobileSyncer(worksmobile) groupID := "group-1" userID := "user-1" tenantID := "tenant-1" mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, TenantID: tenantID, Name: "Sales"}, nil) mockUserRepo.On("FindByID", mock.Anything, userID).Return(&domain.User{ ID: userID, Email: "user@test.com", Name: "User Test", Status: "active", }, nil) mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: "tenant-slug"}, nil) mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{ ID: userID, Traits: map[string]any{"email": "user@test.com"}, State: "active", }, nil) mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.Anything, "active").Return(&KratosIdentity{ ID: userID, Traits: map[string]any{"email": "user@test.com", "tenant_id": tenantID, "department": "Sales"}, State: "active", }, nil) mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID })).Return(nil).Once() mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID })).Return(nil).Once() err := svc.AddMember(context.Background(), groupID, userID) assert.NoError(t, err) assert.Len(t, worksmobile.userUpserts, 1) assert.Equal(t, userID, worksmobile.userUpserts[0].ID) assert.NotNil(t, worksmobile.userUpserts[0].TenantID) assert.Equal(t, tenantID, *worksmobile.userUpserts[0].TenantID) assert.Equal(t, "Sales", worksmobile.userUpserts[0].Department) mockOutbox.AssertExpectations(t) mockKratos.AssertExpectations(t) } func TestUserGroupService_AssignRoleToTenant(t *testing.T) { mockOutbox := new(MockKetoOutboxRepositoryShared) mockUserGroupRepo := new(MockUserGroupRepository) svc := NewUserGroupService(mockUserGroupRepo, nil, nil, nil, mockOutbox, nil) groupID := "group-1" tenantID := "tenant-alpha" relation := "manage" mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID}, nil) expectedSubject := "Tenant:" + groupID + "#members" mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == relation && e.Subject == expectedSubject })).Return(nil) err := svc.AssignRoleToTenant(context.Background(), groupID, tenantID, relation) assert.NoError(t, err) mockOutbox.AssertExpectations(t) } func TestUserGroupService_ListRoles(t *testing.T) { mockKeto := new(MockKetoServiceShared) mockTenantRepo := new(MockTenantRepository) mockUserGroupRepo := new(MockUserGroupRepository) svc := NewUserGroupService(mockUserGroupRepo, nil, mockTenantRepo, mockKeto, nil, nil) groupID := "group-1" subject := "Tenant:" + groupID + "#members" mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID}, nil) tuples := []RelationTuple{ {Object: "t1", Relation: "manage", SubjectID: subject}, {Object: "t2", Relation: "view", SubjectID: subject}, } mockKeto.On("ListRelations", mock.Anything, "Tenant", "", "", subject).Return(tuples, nil) tenants := []domain.Tenant{ {ID: "t1", Name: "Tenant One"}, {ID: "t2", Name: "Tenant Two"}, } mockTenantRepo.On("FindByIDs", mock.Anything, []string{"t1", "t2"}).Return(tenants, nil) roles, err := svc.ListRoles(context.Background(), groupID) assert.NoError(t, err) assert.Len(t, roles, 2) } func TestUserGroupService_Get_WithKratosFallback(t *testing.T) { mockRepo := new(MockUserGroupRepository) mockKeto := new(MockKetoServiceShared) mockUserRepo := new(MockUserRepository) mockKratos := new(MockKratosAdminServiceShared) svc := NewUserGroupService(mockRepo, mockUserRepo, nil, mockKeto, nil, mockKratos) groupID := "group-1" mockRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, Name: "Test"}, nil) tuples := []RelationTuple{ {Object: groupID, Relation: "members", SubjectID: "User:u1"}, } mockKeto.On("ListRelations", mock.Anything, "Tenant", groupID, "members", "").Return(tuples, nil) mockUserRepo.On("FindByIDs", mock.Anything, []string{"u1"}).Return([]domain.User{}, nil) mockKratos.On("GetIdentity", mock.Anything, "u1").Return(&KratosIdentity{ ID: "u1", Traits: map[string]any{"name": "User One", "email": "user1@example.com"}, }, nil) group, err := svc.Get(context.Background(), groupID) assert.NoError(t, err) assert.NotNil(t, group) assert.Len(t, group.Members, 1) assert.Equal(t, "User One", group.Members[0].Name) }