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

@@ -87,7 +87,7 @@ describe("tenantTree utility", () => {
const { subTree } = buildTenantFullTree(multiRootTenants); const { subTree } = buildTenantFullTree(multiRootTenants);
expect(subTree).toHaveLength(2); expect(subTree).toHaveLength(2);
expect(subTree.map(n => n.id)).toContain("root-1"); 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-2");
}); });
}); });

View File

@@ -55,7 +55,7 @@ test.describe("Authentication", () => {
// Should be on the dashboard/overview // Should be on the dashboard/overview
await expect(page.locator("aside")).toBeVisible(); 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 }) => { test("should logout and redirect to login page", async ({ page }) => {

View File

@@ -172,29 +172,43 @@ test.describe("Tenants Management", () => {
await expect(parentRow).toContainText("8"); // Total (5 + 3) await expect(parentRow).toContainText("8"); // Total (5 + 3)
// Check for either English or Korean labels // Check for either English or Korean labels
const hasDirectLabel = await parentRow.evaluate(el => const hasDirectLabel = await parentRow.evaluate(
el.textContent?.includes("Direct") || el.textContent?.includes("소속") (el) =>
el.textContent?.includes("Direct") || el.textContent?.includes("소속"),
); );
const hasTotalLabel = await parentRow.evaluate(el => const hasTotalLabel = await parentRow.evaluate(
el.textContent?.includes("Total") || el.textContent?.includes("전체") (el) =>
el.textContent?.includes("Total") || el.textContent?.includes("전체"),
); );
expect(hasDirectLabel).toBe(true); expect(hasDirectLabel).toBe(true);
expect(hasTotalLabel).toBe(true); expect(hasTotalLabel).toBe(true);
// Open Member List Dialog - Click the members count button // 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(); await memberButton.click();
// Check Tabs in Member List Dialog // Check Tabs in Member List Dialog
// Use regex to match either language, ignoring the count suffix // Use regex to match either language, ignoring the count suffix
await expect(page.locator('button[role="tab"]').filter({ hasText: /소속 멤버|Direct Members/ })).toBeVisible(); await expect(
await expect(page.locator('button[role="tab"]').filter({ hasText: /하위 조직 멤버|Descendant Members/ })).toBeVisible(); 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 // Direct Members Tab should show parent's user
await expect(page.locator("role=dialog")).toContainText("u1@parent.com"); await expect(page.locator("role=dialog")).toContainText("u1@parent.com");
// Switch to Descendant Members Tab // 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"); 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"` Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용 RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
Department string `gorm:"column:department" json:"department"` 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"` // 직무 (예: 프론트엔드 개발, 기획) JobTitle string `gorm:"column:job_title" json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획)
Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"` Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"`
Status string `gorm:"column:status;default:'active'" json:"status"` 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 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 return nil, 0, nil
} }
@@ -110,6 +110,9 @@ func (m *AsyncMockUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []st
return nil, nil return nil, nil
} }
func (m *AsyncMockUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
return nil, nil
}
type AsyncMockRedisRepo struct { type AsyncMockRedisRepo struct {
mock.Mock mock.Mock
@@ -161,6 +164,10 @@ func (m *AsyncMockTenantService) GetTenant(ctx context.Context, id string) (*dom
return nil, nil 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) { func (m *AsyncMockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
return nil, nil 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 { 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) limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0) offset := c.QueryInt("offset", 0)
parentId := c.Query("parentId") parentId := c.Query("parentId")
@@ -113,24 +109,8 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
offset = 0 offset = 0
} }
// Use separate queries for count and find to avoid GORM statement contamination tenants, total, err := h.Service.ListTenants(c.Context(), limit, offset, parentId)
countQuery := h.DB.Model(&domain.Tenant{}) if err != nil {
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 {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) 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) 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) { func (m *MockTenantService) SetKetoService(keto service.KetoService) {
m.Called(keto) 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) { func TestTenantHandler_CreateTenant(t *testing.T) {
app := fiber.New() app := fiber.New()
mockSvc := new(MockTenantService) mockSvc := new(MockTenantService)
@@ -98,6 +139,47 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
assert.Equal(t, "t1", got["id"]) 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) { func TestTenantHandler_ApproveTenant(t *testing.T) {
app := fiber.New() app := fiber.New()
mockSvc := new(MockTenantService) mockSvc := new(MockTenantService)

View File

@@ -16,6 +16,7 @@ type TenantRepository interface {
FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error)
AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) 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 { 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 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

@@ -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: "alice@test.com", Name: "Alice", Role: "user"})
_ = repo.Create(ctx, &domain.User{Email: "bob@test.com", Name: "Bob", 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.NoError(t, err)
assert.True(t, total >= 1) assert.True(t, total >= 1)
assert.Equal(t, "Alice", users[0].Name) 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.Error(t, err) // Should not be found
assert.Nil(t, 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) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
GetTenant(ctx context.Context, id 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 ApproveTenant(ctx context.Context, id string) error
SetKetoService(keto KetoService) // 추가 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) { func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
return s.repo.FindBySlug(ctx, slug) 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) 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 { type MockKetoSvcForTenant struct {
mock.Mock mock.Mock
} }
@@ -116,7 +121,7 @@ func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID strin
return nil, nil 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 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) 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) { func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc) mockRepo := new(MockTenantRepoForSvc)
@@ -214,3 +225,18 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) {
mockUserRepo.AssertExpectations(t) mockUserRepo.AssertExpectations(t)
mockOutbox.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 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 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) 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 { type MockTenantRepository struct {
mock.Mock mock.Mock
@@ -135,6 +142,10 @@ func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, d
return nil 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) { func TestUserGroupService_Create(t *testing.T) {
mockRepo := new(MockUserGroupRepository) mockRepo := new(MockUserGroupRepository)
mockTenantRepo := new(MockTenantRepository) mockTenantRepo := new(MockTenantRepository)