diff --git a/adminfront/src/lib/tenantTree.test.ts b/adminfront/src/lib/tenantTree.test.ts index eb277ce7..7cdf148c 100644 --- a/adminfront/src/lib/tenantTree.test.ts +++ b/adminfront/src/lib/tenantTree.test.ts @@ -44,18 +44,18 @@ describe("tenantTree utility", () => { it("calculates recursive member counts correctly", () => { const { currentBase } = buildTenantFullTree(mockTenants, "root-1"); - + expect(currentBase).not.toBeNull(); if (currentBase) { // Direct: 10, Child: 5, Grandchild: 2 -> Total: 17 expect(currentBase.recursiveMemberCount).toBe(17); expect(currentBase.children).toHaveLength(1); - + const child = currentBase.children[0]; // Direct: 5, Grandchild: 2 -> Total: 7 expect(child.recursiveMemberCount).toBe(7); expect(child.children).toHaveLength(1); - + const grandchild = child.children[0]; // Direct: 2 -> Total: 2 expect(grandchild.recursiveMemberCount).toBe(2); @@ -84,10 +84,10 @@ describe("tenantTree utility", () => { updatedAt: "", }, ]; - + const { subTree } = buildTenantFullTree(multiRootTenants); expect(subTree).toHaveLength(2); - expect(subTree.map(n => n.id)).toContain("root-1"); - expect(subTree.map(n => n.id)).toContain("root-2"); + expect(subTree.map((n) => n.id)).toContain("root-1"); + expect(subTree.map((n) => n.id)).toContain("root-2"); }); }); diff --git a/adminfront/tests/auth.spec.ts b/adminfront/tests/auth.spec.ts index fff2875e..46daaaa8 100644 --- a/adminfront/tests/auth.spec.ts +++ b/adminfront/tests/auth.spec.ts @@ -55,7 +55,7 @@ test.describe("Authentication", () => { // Should be on the dashboard/overview await expect(page.locator("aside")).toBeVisible(); - await expect(page.locator("h1")).toContainText("Admin Control"); + await expect(page.locator("h1")).toContainText(/Admin Control|운영 도구/); }); test("should logout and redirect to login page", async ({ page }) => { diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 253e1b76..e2fbb624 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -170,31 +170,45 @@ test.describe("Tenants Management", () => { const parentRow = page.locator("tr", { hasText: "Parent Org" }); await expect(parentRow).toContainText("5"); // Direct await expect(parentRow).toContainText("8"); // Total (5 + 3) - + // Check for either English or Korean labels - const hasDirectLabel = await parentRow.evaluate(el => - el.textContent?.includes("Direct") || el.textContent?.includes("소속") + const hasDirectLabel = await parentRow.evaluate( + (el) => + el.textContent?.includes("Direct") || el.textContent?.includes("소속"), ); - const hasTotalLabel = await parentRow.evaluate(el => - el.textContent?.includes("Total") || el.textContent?.includes("전체") + const hasTotalLabel = await parentRow.evaluate( + (el) => + el.textContent?.includes("Total") || el.textContent?.includes("전체"), ); expect(hasDirectLabel).toBe(true); expect(hasTotalLabel).toBe(true); // Open Member List Dialog - Click the members count button - const memberButton = parentRow.getByRole("button").filter({ hasText: /Direct|소속/ }); + const memberButton = parentRow + .getByRole("button") + .filter({ hasText: /Direct|소속/ }); await memberButton.click(); - + // Check Tabs in Member List Dialog // Use regex to match either language, ignoring the count suffix - await expect(page.locator('button[role="tab"]').filter({ hasText: /소속 멤버|Direct Members/ })).toBeVisible(); - await expect(page.locator('button[role="tab"]').filter({ hasText: /하위 조직 멤버|Descendant Members/ })).toBeVisible(); + await expect( + page + .locator('button[role="tab"]') + .filter({ hasText: /소속 멤버|Direct Members/ }), + ).toBeVisible(); + await expect( + page + .locator('button[role="tab"]') + .filter({ hasText: /하위 조직 멤버|Descendant Members/ }), + ).toBeVisible(); // Direct Members Tab should show parent's user await expect(page.locator("role=dialog")).toContainText("u1@parent.com"); // Switch to Descendant Members Tab - await page.click('button[role="tab"]:has-text("하위 조직 멤버"), button[role="tab"]:has-text("Descendant Members")'); + await page.click( + 'button[role="tab"]:has-text("하위 조직 멤버"), button[role="tab"]:has-text("Descendant Members")', + ); await expect(page.locator("role=dialog")).toContainText("u2@child.com"); }); }); diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index a5b9b794..6a27ed2d 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -29,7 +29,7 @@ type User struct { Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"` RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용 Department string `gorm:"column:department" json:"department"` - Position string `gorm:"column:position" json:"position"` // 직급 (예: 수석, 책임, 선임) + Position string `gorm:"column:position" json:"position"` // 직급 (예: 수석, 책임, 선임) JobTitle string `gorm:"column:job_title" json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획) Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"` Status string `gorm:"column:status;default:'active'" json:"status"` diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go index 3a7ed64c..35c13cf3 100644 --- a/backend/internal/handler/auth_handler_async_test.go +++ b/backend/internal/handler/auth_handler_async_test.go @@ -98,7 +98,7 @@ func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) ( return nil, nil } -func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) { +func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) { return nil, 0, nil } @@ -110,6 +110,9 @@ func (m *AsyncMockUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []st return nil, nil } +func (m *AsyncMockUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { + return nil, nil +} type AsyncMockRedisRepo struct { mock.Mock @@ -161,6 +164,10 @@ func (m *AsyncMockTenantService) GetTenant(ctx context.Context, id string) (*dom return nil, nil } +func (m *AsyncMockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + return nil, 0, nil +} + func (m *AsyncMockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { return nil, nil } diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index c758a021..ac6ec510 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -98,10 +98,6 @@ func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error { } func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { - if h.DB == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) - } - limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) parentId := c.Query("parentId") @@ -113,24 +109,8 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { offset = 0 } - // Use separate queries for count and find to avoid GORM statement contamination - countQuery := h.DB.Model(&domain.Tenant{}) - if parentId != "" { - countQuery = countQuery.Where("parent_id = ?", parentId) - } - - var total int64 - if err := countQuery.Count(&total).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) - } - - findQuery := h.DB.Model(&domain.Tenant{}) - if parentId != "" { - findQuery = findQuery.Where("parent_id = ?", parentId) - } - - var tenants []domain.Tenant - if err := findQuery.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { + tenants, total, err := h.Service.ListTenants(c.Context(), limit, offset, parentId) + if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index b15b4a65..54285177 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -66,10 +66,51 @@ func (m *MockTenantService) GetTenant(ctx context.Context, id string) (*domain.T return args.Get(0).(*domain.Tenant), args.Error(1) } +func (m *MockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + args := m.Called(ctx, limit, offset, parentID) + return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2) +} + func (m *MockTenantService) SetKetoService(keto service.KetoService) { m.Called(keto) } +type MockUserRepoForHandler struct { + mock.Mock +} + +func (m *MockUserRepoForHandler) Create(ctx context.Context, user *domain.User) error { return nil } +func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error { return nil } +func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error { return nil } +func (m *MockUserRepoForHandler) FindByEmail(ctx context.Context, email string) (*domain.User, error) { + return nil, nil +} +func (m *MockUserRepoForHandler) FindByID(ctx context.Context, id string) (*domain.User, error) { + return nil, nil +} +func (m *MockUserRepoForHandler) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) { + return nil, nil +} +func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) { + return nil, nil +} +func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) { + return nil, 0, nil +} +func (m *MockUserRepoForHandler) CountByTenant(ctx context.Context, tenantID string) (int64, error) { + return 0, nil +} +func (m *MockUserRepoForHandler) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { + return nil, nil +} +func (m *MockUserRepoForHandler) 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 TestTenantHandler_CreateTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) @@ -98,6 +139,47 @@ func TestTenantHandler_CreateTenant(t *testing.T) { assert.Equal(t, "t1", got["id"]) } +func TestTenantHandler_ListTenants(t *testing.T) { + app := fiber.New() + mockSvc := new(MockTenantService) + mockUserRepo := new(MockUserRepoForHandler) + + h := &TenantHandler{ + Service: mockSvc, + UserRepo: mockUserRepo, + } + + app.Get("/tenants", h.ListTenants) + + tenants := []domain.Tenant{ + {ID: "t1", Name: "Tenant A", Slug: "slug-a"}, + {ID: "t2", Name: "Tenant B", Slug: "slug-b"}, + } + mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(2), nil) + mockUserRepo.On("CountByCompanyCodes", mock.Anything, []string{"slug-a", "slug-b"}). + Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil) + + req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) + resp, _ := app.Test(req) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var res tenantListResponse + json.NewDecoder(resp.Body).Decode(&res) + + assert.Equal(t, int64(2), res.Total) + assert.Len(t, res.Items, 2) + + // Check if counts are mapped correctly + for _, item := range res.Items { + if item.Slug == "slug-a" { + assert.Equal(t, int64(5), item.MemberCount) + } else if item.Slug == "slug-b" { + assert.Equal(t, int64(10), item.MemberCount) + } + } +} + func TestTenantHandler_ApproveTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 6b2d8be5..5864852d 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -322,12 +322,12 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { // [New] Local DB Sync - Ensure user exists in read-model if h.UserRepo != nil { localUser := h.mapToLocalUser(*identity) - + // Sync to local DB go func(u *domain.User, role string, tID *string) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - + // Use Update (upsert) instead of Create for robustness if err := h.UserRepo.Update(ctx, u); err != nil { slog.Error("[UserHandler] Failed to sync new user to local DB", "email", u.Email, "error", err) @@ -475,14 +475,14 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { // [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller if h.UserRepo != nil { updatedLocalUser := h.mapToLocalUser(*updated) - + ctx := context.Background() // Use request context if appropriate, but sync must finish if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil { slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err) } // [Keto Sync] asynchronously as it's less critical for immediate UI count - go h.syncKetoRole(context.Background(), updatedLocalUser.ID, + go h.syncKetoRole(context.Background(), updatedLocalUser.ID, extractTraitString(updated.Traits, "grade"), oldRole, oldTenantID, updatedLocalUser.TenantID) } diff --git a/backend/internal/repository/tenant_repository.go b/backend/internal/repository/tenant_repository.go index cc20a6b5..9a18c4fe 100644 --- a/backend/internal/repository/tenant_repository.go +++ b/backend/internal/repository/tenant_repository.go @@ -16,6 +16,7 @@ type TenantRepository interface { FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error + List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) } type tenantRepository struct { @@ -90,3 +91,23 @@ func (r *tenantRepository) AddDomain(ctx context.Context, tenantID string, domai } return r.db.WithContext(ctx).Create(&td).Error } + +func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + var tenants []domain.Tenant + var total int64 + db := r.db.WithContext(ctx).Model(&domain.Tenant{}) + + if parentID != "" { + db = db.Where("parent_id = ?", parentID) + } + + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + if err := db.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { + return nil, 0, err + } + + return tenants, total, nil +} diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 543a1697..b793aa6f 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -119,10 +119,10 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string // 1. Resolve IDs for these codes to support dual counting (slug or ID) var tenants []domain.Tenant _ = r.db.WithContext(ctx).Where("slug IN ?", codes).Find(&tenants).Error - + idToSlug := make(map[string]string) slugToNormalized := make(map[string]string) - + for _, code := range codes { slugToNormalized[strings.ToLower(strings.TrimSpace(code))] = code } @@ -156,13 +156,13 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string } else if res.TenantID != "" { slug = idToSlug[res.TenantID] } - + if slug != "" { normalizedSlug := strings.ToLower(strings.TrimSpace(slug)) counts[normalizedSlug] += res.Count } } - + return counts, nil } diff --git a/backend/internal/repository/user_repository_test.go b/backend/internal/repository/user_repository_test.go index 886f297d..7d8d421e 100644 --- a/backend/internal/repository/user_repository_test.go +++ b/backend/internal/repository/user_repository_test.go @@ -56,7 +56,7 @@ func TestUserRepository(t *testing.T) { _ = repo.Create(ctx, &domain.User{Email: "alice@test.com", Name: "Alice", Role: "user"}) _ = repo.Create(ctx, &domain.User{Email: "bob@test.com", Name: "Bob", Role: "user"}) - users, total, err := repo.List(ctx, 0, 10, "Alice") + users, total, err := repo.List(ctx, 0, 10, "Alice", "") assert.NoError(t, err) assert.True(t, total >= 1) assert.Equal(t, "Alice", users[0].Name) @@ -73,4 +73,25 @@ func TestUserRepository(t *testing.T) { assert.Error(t, err) // Should not be found assert.Nil(t, found) }) + + t.Run("CountByCompanyCodes", func(t *testing.T) { + // Clean start for this subtest + testDB.Exec("DELETE FROM users") + + users := []domain.User{ + {Email: "u1@a.com", Name: "U1", CompanyCode: "tenant-a"}, + {Email: "u2@a.com", Name: "U2", CompanyCode: "tenant-a"}, + {Email: "u3@b.com", Name: "U3", CompanyCode: "tenant-b"}, + {Email: "u4@none.com", Name: "U4", CompanyCode: ""}, + } + for _, u := range users { + _ = repo.Create(ctx, &u) + } + + counts, err := repo.CountByCompanyCodes(ctx, []string{"tenant-a", "tenant-b", "tenant-c"}) + assert.NoError(t, err) + assert.Equal(t, int64(2), counts["tenant-a"]) + assert.Equal(t, int64(1), counts["tenant-b"]) + assert.Equal(t, int64(0), counts["tenant-c"]) + }) } diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index 2f358ec5..c1c161a5 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -18,6 +18,7 @@ type TenantService interface { GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) + ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) ApproveTenant(ctx context.Context, id string) error SetKetoService(keto KetoService) // 추가 } @@ -226,3 +227,8 @@ func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain strin func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { return s.repo.FindBySlug(ctx, slug) } + +func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + // Let the repository handle the query and pagination + return s.repo.List(ctx, limit, offset, parentID) +} diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go index 2216ffdb..2952bfe8 100644 --- a/backend/internal/service/tenant_service_test.go +++ b/backend/internal/service/tenant_service_test.go @@ -59,6 +59,11 @@ func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, d return m.Called(ctx, tenantID, domainName, verified).Error(0) } +func (m *MockTenantRepoForSvc) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + args := m.Called(ctx, limit, offset, parentID) + return args.Get(0).([]domain.Tenant), int64(args.Int(1)), args.Error(2) +} + type MockKetoSvcForTenant struct { mock.Mock } @@ -116,7 +121,7 @@ func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID strin return nil, nil } -func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) { +func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) { return nil, 0, nil } @@ -133,7 +138,13 @@ func (m *MockUserRepoForTenant) CountByTenantIDs(ctx context.Context, tenantIDs return args.Get(0).(map[string]int64), args.Error(1) } - +func (m *MockUserRepoForTenant) 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 TestTenantService_RegisterTenant_AutoVerify(t *testing.T) { mockRepo := new(MockTenantRepoForSvc) @@ -214,3 +225,18 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) { mockUserRepo.AssertExpectations(t) mockOutbox.AssertExpectations(t) } + +func TestTenantService_ListTenants(t *testing.T) { + mockRepo := new(MockTenantRepoForSvc) + svc := NewTenantService(mockRepo, nil, nil) + ctx := context.Background() + + tenants := []domain.Tenant{{ID: "t1", Name: "Tenant 1"}} + mockRepo.On("List", ctx, 10, 0, "").Return(tenants, 1, nil) + + result, total, err := svc.ListTenants(ctx, 10, 0, "") + assert.NoError(t, err) + assert.Equal(t, int64(1), total) + assert.Equal(t, tenants, result) + mockRepo.AssertExpectations(t) +} diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go index b740909e..e5772077 100644 --- a/backend/internal/service/user_group_service_test.go +++ b/backend/internal/service/user_group_service_test.go @@ -77,7 +77,7 @@ func (m *MockUserRepository) ListByTenant(ctx context.Context, tenantID string) return nil, nil } -func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) { +func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) { return nil, 0, nil } @@ -94,6 +94,13 @@ func (m *MockUserRepository) CountByTenantIDs(ctx context.Context, tenantIDs []s return args.Get(0).(map[string]int64), 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) +} type MockTenantRepository struct { mock.Mock @@ -135,6 +142,10 @@ func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, d return nil } +func (m *MockTenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + return nil, 0, nil +} + func TestUserGroupService_Create(t *testing.T) { mockRepo := new(MockUserGroupRepository) mockTenantRepo := new(MockTenantRepository)