package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "bytes" "context" "encoding/json" "errors" "io" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "gorm.io/gorm" ) // MockTenantService is a mock for service.TenantService type MockTenantService struct { mock.Mock } func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) { args := m.Called(ctx, name, slug, tenantType, description, domains, parentID, creatorID) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*domain.Tenant), args.Error(1) } func (m *MockTenantService) GetTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { args := m.Called(ctx, domainName) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*domain.Tenant), args.Error(1) } func (m *MockTenantService) ApproveTenant(ctx context.Context, tenantID string) error { args := m.Called(ctx, tenantID) return args.Error(0) } func (m *MockTenantService) RequestRegistration(ctx context.Context, name, slug, description, domainName, adminEmail string) (*domain.Tenant, error) { args := m.Called(ctx, name, slug, description, domainName, adminEmail) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*domain.Tenant), args.Error(1) } func (m *MockTenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { args := m.Called(ctx, slug) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*domain.Tenant), args.Error(1) } func (m *MockTenantService) GetTenant(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 *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) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { args := m.Called(ctx, userID) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).([]domain.Tenant), args.Error(1) } func (m *MockTenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) { args := m.Called(ctx, domainName) return args.Bool(0), args.Error(1) } func (m *MockTenantService) SetKetoService(keto service.KetoService) { m.Called(keto) } func (m *MockTenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { args := m.Called(ctx, domainName) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*domain.Tenant), args.Error(1) } type MockUserRepoForHandler struct { mock.Mock deletedIDs []string } 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 { m.deletedIDs = append(m.deletedIDs, id) 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, tenantSlug string) ([]domain.User, int64, error) { for _, call := range m.ExpectedCalls { if call.Method == "List" { args := m.Called(ctx, offset, limit, search, tenantSlug) return args.Get(0).([]domain.User), args.Get(1).(int64), args.Error(2) } } return nil, 0, nil } func (m *MockUserRepoForHandler) CountByTenant(ctx context.Context, tenantID string) (int64, error) { return 0, nil } func (m *MockUserRepoForHandler) 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 *MockUserRepoForHandler) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { return nil, nil } func (m *MockUserRepoForHandler) 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 *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 (m *MockUserRepoForHandler) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error { return nil } func (m *MockUserRepoForHandler) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) { return nil, nil } func (m *MockUserRepoForHandler) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) { return false, nil } func (m *MockUserRepoForHandler) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) { return "", nil } type MockUserProjectionRepoForHandler struct { mock.Mock } func (m *MockUserProjectionRepoForHandler) IsReady(ctx context.Context) (bool, error) { args := m.Called(ctx) return args.Bool(0), args.Error(1) } func (m *MockUserProjectionRepoForHandler) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) { args := m.Called(ctx) return args.Get(0).(domain.UserProjectionStatus), args.Error(1) } func (m *MockUserProjectionRepoForHandler) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) { args := m.Called(ctx, tenants) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(map[string]int64), args.Error(1) } func (m *MockUserProjectionRepoForHandler) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error { args := m.Called(ctx, users) return args.Error(0) } func (m *MockUserProjectionRepoForHandler) MarkFailed(ctx context.Context, syncErr error) error { args := m.Called(ctx, syncErr) return args.Error(0) } func toJSONString(t *testing.T, value any) string { t.Helper() raw, err := json.Marshal(value) require.NoError(t, err) return string(raw) } func TestTenantHandler_CreateTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) // CreateTenant checks h.DB != nil h := &TenantHandler{Service: mockSvc, DB: &gorm.DB{}} app.Post("/tenants", h.CreateTenant) input := map[string]interface{}{ "name": "Test Tenant", "slug": "test-tenant", "domains": []string{"test.com"}, } body, _ := json.Marshal(input) mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string(nil), (*string)(nil), ""). Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil) req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) assert.Equal(t, http.StatusCreated, resp.StatusCode) var got map[string]interface{} json.NewDecoder(resp.Body).Decode(&got) assert.Equal(t, "t1", got["id"]) } func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) mockProjection := new(MockUserProjectionRepoForHandler) h := &TenantHandler{ Service: mockSvc, UserProjectionRepo: mockProjection, } app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ Role: "super_admin", }) return c.Next() }) app.Get("/tenants", h.ListTenants) tenants := []domain.Tenant{ {ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"}, } mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("CountTenantMembers", mock.Anything, tenants). Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once() req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) resp, _ := app.Test(req) require.Equal(t, http.StatusOK, resp.StatusCode) var res tenantListResponse json.NewDecoder(resp.Body).Decode(&res) require.Len(t, res.Items, 1) assert.Equal(t, int64(2), res.Items[0].MemberCount) mockProjection.AssertExpectations(t) } func TestTenantHandler_ListTenantsRejectsStatsWhenUserProjectionIsNotReady(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) mockProjection := new(MockUserProjectionRepoForHandler) h := &TenantHandler{ Service: mockSvc, UserProjectionRepo: mockProjection, } app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ Role: "super_admin", }) return c.Next() }) app.Get("/tenants", h.ListTenants) tenants := []domain.Tenant{ {ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"}, } mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once() mockProjection.On("IsReady", mock.Anything).Return(false, nil).Once() req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) resp, _ := app.Test(req) assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) mockProjection.AssertNotCalled(t, "CountTenantMembers", mock.Anything, mock.Anything) } func TestTenantHandler_ListTenants(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) mockProjection := new(MockUserProjectionRepoForHandler) h := &TenantHandler{ Service: mockSvc, UserProjectionRepo: mockProjection, } app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ Role: "super_admin", }) return c.Next() }) app.Get("/tenants", h.ListTenants) tenants := []domain.Tenant{ {ID: "t1", Name: "Tenant A", Slug: "slug-a"}, {ID: "t2", Name: "Tenant B", Slug: "slug-b"}, } // Mocking for the new allTenants check in ListTenants mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("CountTenantMembers", mock.Anything, tenants). Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once() 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_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) mockProjection := new(MockUserProjectionRepoForHandler) h := &TenantHandler{ Service: mockSvc, UserProjectionRepo: mockProjection, } app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ Role: "super_admin", }) return c.Next() }) app.Get("/tenants", h.ListTenants) createdAt := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC) tenants := []domain.Tenant{ {ID: "00000000-0000-0000-0000-000000000002", Name: "Tenant B", Slug: "slug-b", CreatedAt: createdAt}, {ID: "00000000-0000-0000-0000-000000000001", Name: "Tenant A", Slug: "slug-a", CreatedAt: createdAt.Add(-time.Minute)}, } mockSvc.On("ListTenants", mock.Anything, 2, 0, "").Return(tenants, int64(3), nil).Once() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{}, nil).Once() req := httptest.NewRequest("GET", "/tenants?limit=2&offset=0", nil) resp, _ := app.Test(req) require.Equal(t, http.StatusOK, resp.StatusCode) var res tenantListResponse require.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) require.Len(t, res.Items, 2) require.NotEmpty(t, res.NextCursor) } func TestPageTenantsByCursorUsesStableCreatedAtAndIDOrder(t *testing.T) { createdAt := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC) tenants := []domain.Tenant{ {ID: "00000000-0000-0000-0000-000000000001", Name: "Tenant A", Slug: "slug-a", CreatedAt: createdAt}, {ID: "00000000-0000-0000-0000-000000000003", Name: "Tenant C", Slug: "slug-c", CreatedAt: createdAt}, {ID: "00000000-0000-0000-0000-000000000002", Name: "Tenant B", Slug: "slug-b", CreatedAt: createdAt}, } page, nextCursor, err := pageTenantsByCursor(tenants, 2, "") require.NoError(t, err) require.NotEmpty(t, nextCursor) require.Equal(t, []string{ "00000000-0000-0000-0000-000000000003", "00000000-0000-0000-0000-000000000002", }, []string{page[0].ID, page[1].ID}) nextPage, _, err := pageTenantsByCursor(tenants, 2, nextCursor) require.NoError(t, err) require.Equal(t, []string{"00000000-0000-0000-0000-000000000001"}, []string{nextPage[0].ID}) } func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) mockProjection := new(MockUserProjectionRepoForHandler) h := &TenantHandler{ Service: mockSvc, UserProjectionRepo: mockProjection, } parent := func(id string) *string { return &id } tenants := []domain.Tenant{ {ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"}, {ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"}, {ID: "public-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "공개팀", Slug: "public-team"}, {ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}}, {ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"}, } app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleTenantAdmin, TenantID: parent("company"), }) return c.Next() }) app.Get("/tenants", h.ListTenants) mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool { return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team") })).Return(map[string]int64{}, nil).Once() req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) var res tenantListResponse require.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) require.Equal(t, int64(3), res.Total) require.NotContains(t, toJSONString(t, res), "private-team") require.NotContains(t, toJSONString(t, res), "private-child") } func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) mockProjection := new(MockUserProjectionRepoForHandler) h := &TenantHandler{ Service: mockSvc, UserProjectionRepo: mockProjection, } parent := func(id string) *string { return &id } tenants := []domain.Tenant{ {ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"}, {ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"}, {ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}}, {ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"}, } app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleTenantAdmin, TenantID: parent("company"), ManageableTenants: []domain.Tenant{ {ID: "private-team", Slug: "private-team"}, }, }) return c.Next() }) app.Get("/tenants", h.ListTenants) mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool { return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child") })).Return(map[string]int64{}, nil).Once() req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) var res tenantListResponse require.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) require.Equal(t, int64(4), res.Total) require.Contains(t, toJSONString(t, res), "private-team") require.Contains(t, toJSONString(t, res), "private-child") } func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *testing.T) { parent := func(id string) *string { return &id } tenants := []domain.Tenant{ {ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"}, {ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"}, {ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}}, {ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"}, } mockKeto := new(devMockKetoService) h := &TenantHandler{Keto: mockKeto} mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "private-team", "view_private").Return(true, nil).Once() filtered, err := h.filterPrivateTenantsForProfile(context.Background(), tenants, &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleTenantAdmin, TenantID: parent("company"), }) require.NoError(t, err) require.True(t, tenantSlugsMatch(filtered, "hanmac-family", "hanmac", "private-team", "private-child")) mockKeto.AssertExpectations(t) } func tenantSlugsMatch(got []domain.Tenant, want ...string) bool { if len(got) != len(want) { return false } counts := make(map[string]int, len(want)) for _, slug := range want { counts[slug]++ } for _, tenant := range got { counts[tenant.Slug]-- if counts[tenant.Slug] < 0 { return false } } return true } func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) mockUsers := new(MockUserRepoForHandler) h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers} app.Use(func(c *fiber.Ctx) error { c.Locals("apiKeyName", "orgfront-ssot-client") return c.Next() }) app.Get("/org-context", h.GetOrgContext) now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) parent := func(id string) *string { return &id } tenants := []domain.Tenant{ {ID: "root-other", Type: domain.TenantTypeCompanyGroup, Name: "다른그룹", Slug: "other-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "group-hanmac-family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "company-hanmac", Type: domain.TenantTypeCompany, ParentID: parent("group-hanmac-family"), Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "dept-platform", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "플랫폼실", Slug: "platform", Status: domain.TenantStatusActive, Config: domain.JSONMap{"orgUnitType": "실"}, CreatedAt: now, UpdatedAt: now}, {ID: "team-sso", Type: domain.TenantTypeUserGroup, ParentID: parent("dept-platform"), Name: "SSO팀", Slug: "sso", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "비공개", Slug: "private-team", Status: domain.TenantStatusActive, Config: domain.JSONMap{"visibility": "private"}, CreatedAt: now, UpdatedAt: now}, } usersByTenantID := []domain.User{ { ID: "user-platform-lead", Email: "lead@example.com", Name: "플랫폼 리드", Phone: "010-1111-2222", Status: domain.UserStatusActive, TenantID: parent("dept-platform"), CompanyCode: "platform", Grade: "책임", Position: "실장", JobTitle: "Backend Engineer", Metadata: domain.JSONMap{ "additionalAppointments": []any{ map[string]any{ "tenantId": "dept-platform", "isPrimary": true, "isOwner": true, "grade": "수석", "position": "실장", "jobTitle": "기술기획", }, }, }, CreatedAt: now, UpdatedAt: now, }, { ID: "user-archived", Email: "archived@example.com", Name: "보관 사용자", Status: domain.UserStatusArchived, TenantID: parent("dept-platform"), CompanyCode: "platform", CreatedAt: now, UpdatedAt: now, }, { ID: "user-suspended", Email: "suspended@example.com", Name: "정지 사용자", Status: domain.UserStatusSuspended, TenantID: parent("dept-platform"), CompanyCode: "platform", CreatedAt: now, UpdatedAt: now, }, { ID: "user-temporary-leave", Email: "temporary-leave@example.com", Name: "단기휴무 사용자", Status: domain.UserStatusTemporaryLeave, TenantID: parent("dept-platform"), CompanyCode: "platform", CreatedAt: now, UpdatedAt: now, }, { ID: "user-preboarding", Email: "preboarding@example.com", Name: "입사대기 사용자", Status: domain.UserStatusPreboarding, TenantID: parent("dept-platform"), CompanyCode: "platform", CreatedAt: now, UpdatedAt: now, }, { ID: "user-baron-guest", Email: "baron-guest@example.com", Name: "Baron Guest", Status: domain.UserStatusBaronGuest, TenantID: parent("dept-platform"), CompanyCode: "platform", CreatedAt: now, UpdatedAt: now, }, { ID: "user-extended-leave", Email: "extended-leave@example.com", Name: "장기휴직 사용자", Status: domain.UserStatusExtendedLeave, TenantID: parent("dept-platform"), CompanyCode: "platform", CreatedAt: now, UpdatedAt: now, }, } usersBySlug := []domain.User{ {ID: "user-sso-member", Email: "member@example.com", Name: "SSO 구성원", Status: domain.UserStatusActive, CompanyCode: "sso", Grade: "선임", CreatedAt: now, UpdatedAt: now}, } usersByList := []domain.User{ { ID: "user-appointment-only", Email: "appointment@example.com", Name: "겸직 사용자", Status: domain.UserStatusActive, Metadata: domain.JSONMap{ "additionalAppointments": []any{ map[string]any{ "tenantSlug": "sso", "isManager": true, "position": "파트장", }, }, }, CreatedAt: now, UpdatedAt: now, }, } mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) mockUsers.On("FindByTenantIDs", mock.Anything, []string{"group-hanmac-family", "company-hanmac", "dept-platform", "team-sso"}).Return(usersByTenantID, nil) mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac", "platform", "sso"}).Return(usersBySlug, nil) mockUsers.On("List", mock.Anything, 0, 10000, "", "").Return(usersByList, int64(len(usersByList)), nil) req := httptest.NewRequest(http.MethodGet, "/org-context", nil) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) var got map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) require.Equal(t, "baron.org-context.v1", got["schemaVersion"]) scope := got["scope"].(map[string]any) require.Equal(t, "group-hanmac-family", scope["tenantId"]) require.Equal(t, "hanmac-family", scope["tenantSlug"]) tenantsPayload := got["tenants"].([]any) require.Len(t, tenantsPayload, 4) require.Equal(t, "group-hanmac-family", tenantsPayload[0].(map[string]any)["id"]) require.Equal(t, "company-hanmac", tenantsPayload[1].(map[string]any)["id"]) require.Equal(t, "dept-platform", tenantsPayload[2].(map[string]any)["id"]) require.Equal(t, "team-sso", tenantsPayload[3].(map[string]any)["id"]) require.NotContains(t, got, "users") deptPlatform := tenantsPayload[2].(map[string]any) platformMembers := deptPlatform["members"].([]any) require.Len(t, platformMembers, 3) firstUser := platformMembers[0].(map[string]any) require.NotContains(t, firstUser, "id") require.NotContains(t, firstUser, "phone") require.NotContains(t, firstUser, "tenantIds") require.NotContains(t, firstUser, "tenantSlugs") require.NotContains(t, firstUser, "memberships") require.NotContains(t, firstUser, "role") require.NotContains(t, firstUser, "status") require.NotContains(t, firstUser, "metadata") require.NotContains(t, firstUser, "createdAt") require.NotContains(t, firstUser, "updatedAt") require.Equal(t, "lead@example.com", firstUser["email"]) require.Equal(t, "플랫폼 리드", firstUser["name"]) require.Equal(t, true, firstUser["isOwner"]) require.Equal(t, false, firstUser["isManager"]) require.Equal(t, true, firstUser["isPrimary"]) require.NotContains(t, firstUser, "isLeader") require.Equal(t, "수석", firstUser["grade"]) require.Equal(t, "실장", firstUser["position"]) require.Equal(t, "기술기획", firstUser["jobTitle"]) teamSSO := tenantsPayload[3].(map[string]any) ssoMembers := teamSSO["members"].([]any) require.Len(t, ssoMembers, 1) appointmentOnly := ssoMembers[0].(map[string]any) require.Equal(t, "appointment@example.com", appointmentOnly["email"]) require.Equal(t, false, appointmentOnly["isOwner"]) require.Equal(t, true, appointmentOnly["isManager"]) require.NotContains(t, appointmentOnly, "isLeader") tree := got["tree"].(map[string]any) require.Equal(t, "group-hanmac-family", tree["id"]) require.NotContains(t, tree, "directUserIds") require.Contains(t, tree, "members") require.NotContains(t, toJSONString(t, got), "directUserIds") require.NotContains(t, toJSONString(t, got), "private-team") require.NotContains(t, toJSONString(t, got), "root-other") require.NotContains(t, toJSONString(t, got), "archived@example.com") require.Contains(t, toJSONString(t, got), "suspended@example.com") require.Contains(t, toJSONString(t, got), "temporary-leave@example.com") require.NotContains(t, toJSONString(t, got), "preboarding@example.com") require.NotContains(t, toJSONString(t, got), "baron-guest@example.com") require.NotContains(t, toJSONString(t, got), "extended-leave@example.com") } func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) mockUsers := new(MockUserRepoForHandler) h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers} app.Use(func(c *fiber.Ctx) error { c.Locals("apiKeyName", "orgfront-ssot-client") return c.Next() }) app.Get("/org-context", h.GetOrgContext) now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) parent := func(id string) *string { return &id } tenants := []domain.Tenant{ {ID: "company-hanmac", Type: domain.TenantTypeCompany, Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, } users := []domain.User{ {ID: "user-1", Email: "user@example.com", Name: "사용자", Phone: "010-1234-5678", Status: domain.UserStatusActive, TenantID: parent("company-hanmac"), CompanyCode: "hanmac", CreatedAt: now, UpdatedAt: now}, } mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac"}).Return(users, nil) mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac"}).Return([]domain.User{}, nil) req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac&includeUserIds=true", nil) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) var got map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) require.NotContains(t, got, "users") tenantsPayload := got["tenants"].([]any) members := tenantsPayload[0].(map[string]any)["members"].([]any) require.Len(t, members, 1) member := members[0].(map[string]any) require.Equal(t, "user-1", member["id"]) require.Equal(t, "010-1234-5678", member["phone"]) require.NotContains(t, member, "tenantIds") require.NotContains(t, member, "tenantSlugs") require.NotContains(t, member, "memberships") tree := got["tree"].(map[string]any) treeMembers := tree["members"].([]any) require.Len(t, treeMembers, 1) require.Equal(t, "user-1", treeMembers[0].(map[string]any)["id"]) require.Equal(t, "010-1234-5678", treeMembers[0].(map[string]any)["phone"]) require.NotContains(t, tree, "directUserIds") } func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) mockUsers := new(MockUserRepoForHandler) h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers} app.Use(func(c *fiber.Ctx) error { c.Locals("apiKeyName", "orgfront-ssot-client") return c.Next() }) app.Get("/org-context", h.GetOrgContext) now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) parent := func(id string) *string { return &id } tenants := []domain.Tenant{ {ID: "group-hanmac-family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "company-hanmac", Type: domain.TenantTypeCompany, ParentID: parent("group-hanmac-family"), Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "dept-platform", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "플랫폼실", Slug: "platform", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, {ID: "company-other", Type: domain.TenantTypeCompany, ParentID: parent("group-hanmac-family"), Name: "다른회사", Slug: "other", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, } mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac", "dept-platform"}).Return([]domain.User{}, nil) mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac", "platform"}).Return([]domain.User{}, nil) req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac", nil) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) var got map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) scope := got["scope"].(map[string]any) require.Equal(t, "company-hanmac", scope["tenantId"]) require.Equal(t, "hanmac", scope["tenantSlug"]) require.Contains(t, toJSONString(t, got), "dept-platform") require.NotContains(t, toJSONString(t, got), "company-other") } func TestTenantHandler_GetOrgContextJSONRequiresApiKey(t *testing.T) { app := fiber.New() h := &TenantHandler{} app.Get("/org-context", h.GetOrgContext) req := httptest.NewRequest(http.MethodGet, "/org-context", nil) resp, err := app.Test(req) require.NoError(t, err) require.Equal(t, http.StatusUnauthorized, resp.StatusCode) } func TestTenantHandler_ListTenantsReturnsServiceUnavailableWhenProjectionStatusFails(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) mockProjection := new(MockUserProjectionRepoForHandler) h := &TenantHandler{ Service: mockSvc, UserProjectionRepo: mockProjection, } app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ Role: "super_admin", }) return c.Next() }) app.Get("/tenants", h.ListTenants) tenants := []domain.Tenant{ {ID: "t1", Name: "Tenant A", Slug: "slug-a"}, } mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once() mockProjection.On("IsReady", mock.Anything).Return(false, errors.New("projection state query failed")).Once() req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) resp, _ := app.Test(req) assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) mockProjection.AssertExpectations(t) } func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) mockUserRepo := new(MockUserRepoForHandler) mockProjection := new(MockUserProjectionRepoForHandler) h := &TenantHandler{ Service: mockSvc, UserRepo: mockUserRepo, UserProjectionRepo: mockProjection, } app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ Role: "super_admin", }) return c.Next() }) app.Get("/tenants", h.ListTenants) tenants := []domain.Tenant{ {ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"}, } mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once() mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() mockProjection.On("CountTenantMembers", mock.Anything, tenants). Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once() mockUserRepo.On("CountByCompanyCodes", mock.Anything, []string{"saman"}). Return(map[string]int64{"saman": 152}, nil).Maybe() mockUserRepo.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}). Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 152}, nil).Maybe() 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.Len(t, res.Items, 1) assert.Equal(t, int64(2), res.Items[0].MemberCount) mockProjection.AssertExpectations(t) } func TestTenantHandler_ExportTenantsCSV(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) h := &TenantHandler{Service: mockSvc} app.Get("/tenants/export", h.ExportTenantsCSV) parentID := "parent-1" tenants := []domain.Tenant{ { ID: "t1", Name: "Tenant A", Type: domain.TenantTypeCompany, ParentID: &parentID, Slug: "tenant-a", Description: "Primary tenant", Config: domain.JSONMap{ "visibility": "internal", "orgUnitType": "센터", }, Domains: []domain.TenantDomain{ {Domain: "tenant-a.example.com"}, {Domain: "login.tenant-a.example.com"}, }, }, } mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(1), nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil) resp, _ := app.Test(req) body, _ := io.ReadAll(resp.Body) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Contains(t, resp.Header.Get("Content-Disposition"), "tenants.csv") assert.Equal(t, "text/csv", strings.Split(resp.Header.Get("Content-Type"), ";")[0]) assert.Contains(t, string(body), "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type") assert.Contains(t, string(body), "t1,Tenant A,COMPANY,parent-1,,tenant-a,Primary tenant,tenant-a.example.com;login.tenant-a.example.com,internal,센터") } func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) h := &TenantHandler{Service: mockSvc} app.Get("/tenants/export", h.ExportTenantsCSV) parentID := "parent-1" tenants := []domain.Tenant{ { ID: parentID, Name: "Parent Tenant", Type: domain.TenantTypeCompanyGroup, Slug: "parent-tenant", }, { ID: "child-1", Name: "Child Tenant", Type: domain.TenantTypeUserGroup, ParentID: &parentID, Slug: "child-tenant", }, } mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(2), nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=false", nil) resp, _ := app.Test(req) body, _ := io.ReadAll(resp.Body) text := string(body) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Contains(t, text, "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type") assert.Contains(t, text, "Child Tenant,USER_GROUP,parent-tenant,child-tenant,,") assert.NotContains(t, text, "tenant_id") assert.NotContains(t, text, "parent_tenant_id") assert.NotContains(t, text, "child-1") mockSvc.AssertExpectations(t) } func TestTenantHandler_ExportTenantsCSV_OrdersByInputOrder(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) h := &TenantHandler{Service: mockSvc} app.Get("/tenants/export", h.ExportTenantsCSV) oldest := time.Date(2026, 1, 2, 9, 0, 0, 0, time.UTC) middle := oldest.Add(time.Hour) newest := oldest.Add(2 * time.Hour) tenants := []domain.Tenant{ {ID: "newest", Name: "Newest Tenant", Type: domain.TenantTypeCompany, Slug: "newest", CreatedAt: newest}, {ID: "middle", Name: "Middle Tenant", Type: domain.TenantTypeCompany, Slug: "middle", CreatedAt: middle}, {ID: "oldest", Name: "Oldest Tenant", Type: domain.TenantTypeCompany, Slug: "oldest", CreatedAt: oldest}, } mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil) resp, _ := app.Test(req) body, _ := io.ReadAll(resp.Body) lines := strings.Split(strings.TrimSpace(string(body)), "\n") require.Equal(t, http.StatusOK, resp.StatusCode) require.Len(t, lines, 4) assert.Contains(t, lines[1], "oldest,Oldest Tenant") assert.Contains(t, lines[2], "middle,Middle Tenant") assert.Contains(t, lines[3], "newest,Newest Tenant") mockSvc.AssertExpectations(t) } func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) h := &TenantHandler{Service: mockSvc} app.Get("/tenants/export", h.ExportTenantsCSV) parentID := "11111111-2222-4333-8444-555555555555" childID := "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" grandchildID := "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff" unrelatedID := "cccccccc-dddd-4eee-8fff-111111111111" tenants := []domain.Tenant{ { ID: parentID, Name: "Parent Org", Type: domain.TenantTypeCompany, Slug: "parent-org", }, { ID: childID, Name: "Child Org", Type: domain.TenantTypeOrganization, ParentID: &parentID, Slug: "child-org", }, { ID: grandchildID, Name: "Leaf Team", Type: domain.TenantTypeUserGroup, ParentID: &childID, Slug: "leaf-team", }, { ID: unrelatedID, Name: "Unrelated Org", Type: domain.TenantTypeOrganization, Slug: "unrelated-org", }, } mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) req := httptest.NewRequest("GET", "/tenants/export?includeIds=true&parentId="+parentID, nil) resp, _ := app.Test(req) body, _ := io.ReadAll(resp.Body) text := string(body) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Contains(t, text, "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type") assert.Contains(t, text, childID+",Child Org,ORGANIZATION,"+parentID+",parent-org,child-org,") assert.Contains(t, text, grandchildID+",Leaf Team,USER_GROUP,"+childID+",child-org,leaf-team,") assert.NotContains(t, text, unrelatedID) assert.NotContains(t, text, "Parent Org") mockSvc.AssertExpectations(t) } func TestTenantHandler_ExportTenantsCSV_HidesPrivateSubtreeForUnauthorizedUser(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) h := &TenantHandler{Service: mockSvc} parent := func(id string) *string { return &id } tenants := []domain.Tenant{ {ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"}, {ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"}, {ID: "public-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "공개팀", Slug: "public-team"}, {ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}}, {ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"}, } app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ ID: "user-1", Role: domain.RoleTenantAdmin, TenantID: parent("company"), }) return c.Next() }) app.Get("/tenants/export", h.ExportTenantsCSV) mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once() req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil) resp, _ := app.Test(req) body, _ := io.ReadAll(resp.Body) text := string(body) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Contains(t, text, "public-team") assert.NotContains(t, text, "private-team") assert.NotContains(t, text, "private-child") mockSvc.AssertExpectations(t) } func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) h := &TenantHandler{Service: mockSvc} app.Post("/tenants/import", h.ImportTenantsCSV) var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("file", "tenants.csv") assert.NoError(t, err) _, err = part.Write([]byte("tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Imported Tenant,COMPANY,parent-1,imported-tenant,Imported memo,imported.example.com;login.imported.example.com\n")) assert.NoError(t, err) assert.NoError(t, writer.Close()) mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once() mockSvc.On( "RegisterTenant", mock.Anything, "Imported Tenant", "imported-tenant", domain.TenantTypeCompany, "Imported memo", []string{"imported.example.com", "login.imported.example.com"}, mock.MatchedBy(func(parentID *string) bool { return parentID != nil && *parentID == "parent-1" }), "", ).Return(&domain.Tenant{ID: "imported-1", Name: "Imported Tenant", Slug: "imported-tenant"}, nil) req := httptest.NewRequest("POST", "/tenants/import", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) resp, _ := app.Test(req) assert.Equal(t, http.StatusOK, resp.StatusCode) var got map[string]interface{} json.NewDecoder(resp.Body).Decode(&got) assert.Equal(t, float64(1), got["created"]) assert.Equal(t, float64(0), got["updated"]) assert.Equal(t, float64(0), got["failed"]) mockSvc.AssertExpectations(t) } func TestTenantHandler_ImportTenantsCSVResolvesParentSlugToID(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) h := &TenantHandler{Service: mockSvc} app.Post("/tenants/import", h.ImportTenantsCSV) var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("file", "tenants.csv") assert.NoError(t, err) _, err = part.Write([]byte("name,type,parent_tenant_slug,slug,memo,email_domain\nParent Tenant,COMPANY,,parent-slug,,\nChild Tenant,ORGANIZATION,parent-slug,child-slug,,\n")) assert.NoError(t, err) assert.NoError(t, writer.Close()) parentID := "parent-id" mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once() mockSvc.On( "RegisterTenant", mock.Anything, "Parent Tenant", "parent-slug", domain.TenantTypeCompany, "", []string{}, (*string)(nil), "", ).Return(&domain.Tenant{ID: parentID, Name: "Parent Tenant", Slug: "parent-slug"}, nil).Once() mockSvc.On( "RegisterTenant", mock.Anything, "Child Tenant", "child-slug", domain.TenantTypeOrganization, "", []string{}, mock.MatchedBy(func(got *string) bool { return got != nil && *got == parentID }), "", ).Return(&domain.Tenant{ID: "child-id", Name: "Child Tenant", Slug: "child-slug"}, nil).Once() req := httptest.NewRequest("POST", "/tenants/import", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) resp, _ := app.Test(req) assert.Equal(t, http.StatusOK, resp.StatusCode) var got map[string]interface{} json.NewDecoder(resp.Body).Decode(&got) assert.Equal(t, float64(2), got["created"]) assert.Equal(t, float64(0), got["failed"]) mockSvc.AssertExpectations(t) } func TestTenantHandler_ImportTenantsCSVDoesNotAssignCreatorAsOrganizationMember(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) h := &TenantHandler{Service: mockSvc} app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ID: "system-admin-id"}) return c.Next() }) app.Post("/tenants/import", h.ImportTenantsCSV) var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("file", "tenants.csv") assert.NoError(t, err) _, err = part.Write([]byte("name,type,parent_tenant_id,slug,memo,email_domain\nImported Org,ORGANIZATION,parent-1,imported-org,,\n")) assert.NoError(t, err) assert.NoError(t, writer.Close()) mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return([]domain.Tenant{}, int64(0), nil).Once() mockSvc.On( "RegisterTenant", mock.Anything, "Imported Org", "imported-org", domain.TenantTypeOrganization, "", []string{}, mock.MatchedBy(func(parentID *string) bool { return parentID != nil && *parentID == "parent-1" }), "", ).Return(&domain.Tenant{ID: "imported-org-id", Name: "Imported Org", Slug: "imported-org"}, nil).Once() req := httptest.NewRequest("POST", "/tenants/import", &body) req.Header.Set("Content-Type", writer.FormDataContentType()) resp, _ := app.Test(req) assert.Equal(t, http.StatusOK, resp.StatusCode) var got map[string]interface{} json.NewDecoder(resp.Body).Decode(&got) assert.Equal(t, float64(1), got["created"]) assert.Equal(t, float64(0), got["failed"]) mockSvc.AssertExpectations(t) } func TestNormalizeTenantTypeAllowsOrganization(t *testing.T) { assert.Equal(t, domain.TenantTypeOrganization, normalizeTenantType("organization")) } func TestTenantCSVAllowedDomainsRoundTrip(t *testing.T) { records, err := parseTenantCSVRecords(strings.NewReader( "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n" + "Hanmac,COMPANY,,hanmac,,\"samaneng.com, hanmaceng.co.kr;login.hmac.kr\",internal,센터\n", )) assert.NoError(t, err) assert.Len(t, records, 1) assert.Equal(t, []string{"samaneng.com", "hanmaceng.co.kr", "login.hmac.kr"}, records[0].Domains) assert.Equal(t, "internal", records[0].Visibility) assert.Equal(t, "센터", records[0].OrgUnitType) } func TestNormalizeTenantDomainInputsSplitsCommaAndWhitespace(t *testing.T) { got := normalizeTenantDomainInputs([]string{ "samaneng.com, hanmaceng.co.kr", " LOGIN.HMAC.KR\nportal.hmac.kr ", "samaneng.com", }) assert.Equal(t, []string{ "samaneng.com", "hanmaceng.co.kr", "login.hmac.kr", "portal.hmac.kr", }, got) } func TestNormalizeTenantConfigForcesIndexedForLoginIDFields(t *testing.T) { config, err := normalizeTenantConfig(map[string]any{ "userSchema": []any{ map[string]any{ "key": "emp_no", "label": "사번", "type": "text", "indexed": false, "isLoginId": true, "maxLength": 20, }, }, }) assert.NoError(t, err) fields, ok := config["userSchema"].([]any) assert.True(t, ok) assert.Len(t, fields, 1) field, ok := fields[0].(map[string]any) assert.True(t, ok) assert.Equal(t, true, field["indexed"]) assert.Equal(t, true, field["isLoginId"]) assert.NotContains(t, field, "maxLength") } func TestNormalizeTenantConfigRejectsNonTextLoginIDFields(t *testing.T) { _, err := normalizeTenantConfig(map[string]any{ "userSchema": []any{ map[string]any{ "key": "emp_no", "type": "number", "isLoginId": true, }, }, }) assert.Error(t, err) assert.Contains(t, err.Error(), "login ID fields must be text") } func TestNormalizeTenantConfigAcceptsTenantVisibilityAndOrgUnitType(t *testing.T) { config, err := normalizeTenantConfig(map[string]any{ "visibility": "internal", "orgUnitType": "센터", }) assert.NoError(t, err) assert.Equal(t, "internal", config["visibility"]) assert.Equal(t, "센터", config["orgUnitType"]) } func TestNormalizeTenantConfigAcceptsTaskForceAndExecutiveOrgUnitTypes(t *testing.T) { for _, orgUnitType := range []string{"TF", "TF팀", "임원직속"} { t.Run(orgUnitType, func(t *testing.T) { config, err := normalizeTenantConfig(map[string]any{ "orgUnitType": orgUnitType, }) assert.NoError(t, err) assert.Equal(t, orgUnitType, config["orgUnitType"]) }) } } func TestNormalizeTenantConfigRejectsInvalidTenantVisibility(t *testing.T) { _, err := normalizeTenantConfig(map[string]any{ "visibility": "secret", }) assert.Error(t, err) assert.Contains(t, err.Error(), "visibility must be public, internal, or private") } func TestValidateTenantOrgConfigScopeRequiresHanmacFamilyDescendant(t *testing.T) { hanmacFamily := domain.Tenant{ID: "family", Slug: "hanmac-family", Type: domain.TenantTypeCompanyGroup} saman := domain.Tenant{ID: "saman", Slug: "saman", Type: domain.TenantTypeCompany, ParentID: &hanmacFamily.ID} outsider := domain.Tenant{ID: "outsider", Slug: "outsider", Type: domain.TenantTypeCompany} err := validateTenantOrgConfigScope(saman, []domain.Tenant{hanmacFamily, saman}, domain.JSONMap{ "visibility": "private", "orgUnitType": "팀", }) assert.NoError(t, err) err = validateTenantOrgConfigScope(outsider, []domain.Tenant{hanmacFamily, saman, outsider}, domain.JSONMap{ "visibility": "private", }) assert.Error(t, err) assert.Contains(t, err.Error(), "only hanmac-family descendants") } func TestFilterPublicTenantsExcludesInternalPrivateAndDescendants(t *testing.T) { root := domain.Tenant{ID: "root", Slug: "hanmac-family"} publicTenant := domain.Tenant{ID: "public", Slug: "public", ParentID: &root.ID} internalTenant := domain.Tenant{ID: "internal", Slug: "internal", ParentID: &root.ID, Config: domain.JSONMap{"visibility": "internal"}} privateTenant := domain.Tenant{ID: "private", Slug: "private", ParentID: &root.ID, Config: domain.JSONMap{"visibility": "private"}} privateChild := domain.Tenant{ID: "private-child", Slug: "private-child", ParentID: &privateTenant.ID} filtered := filterPublicTenants([]domain.Tenant{ root, publicTenant, internalTenant, privateTenant, privateChild, }) assert.Equal(t, []domain.Tenant{root, publicTenant}, filtered) } func TestTenantHandler_ApproveTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) h := &TenantHandler{Service: mockSvc} app.Post("/tenants/:id/approve", h.ApproveTenant) mockSvc.On("ApproveTenant", mock.Anything, "t1").Return(nil) req := httptest.NewRequest("POST", "/tenants/t1/approve", nil) resp, _ := app.Test(req) assert.Equal(t, http.StatusOK, resp.StatusCode) } func (m *MockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error { args := m.Called(ctx, tenantIDs) return args.Error(0) } func (m *MockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { args := m.Called(ctx, userID) if args.Get(0) != nil { return args.Get(0).([]domain.Tenant), args.Error(1) } return nil, args.Error(1) }