1
0
forked from baron/baron-sso

테넌트 crud 테스트 코드 추가

This commit is contained in:
2026-02-27 11:28:39 +09:00
parent f02ba3cbbd
commit f97b989455
14 changed files with 221 additions and 53 deletions

View File

@@ -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");
});
});

View File

@@ -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 }) => {

View File

@@ -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");
});
});

View File

@@ -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"`

View File

@@ -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
}

View File

@@ -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()})
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"])
})
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)