1
0
forked from baron/baron-sso

네이버 계정 정합성 맞춤

This commit is contained in:
2026-06-15 19:54:09 +09:00
parent 8e9d015443
commit 4d468cd39f
97 changed files with 5837 additions and 2031 deletions

View File

@@ -113,7 +113,16 @@ func (m *MockUserRepoForHandler) DB() *gorm.DB {
}
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) Update(ctx context.Context, user *domain.User) error {
for _, call := range m.ExpectedCalls {
if call.Method == "Update" {
args := m.Called(ctx, user)
return args.Error(0)
}
}
return nil
}
func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error {
m.deletedIDs = append(m.deletedIDs, id)
return nil
@@ -155,7 +164,20 @@ func (m *MockUserRepoForHandler) FindByTenantIDs(ctx context.Context, tenantIDs
}
func (m *MockUserRepoForHandler) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
return nil, nil
for _, call := range m.ExpectedCalls {
if call.Method == "CountByTenantIDs" {
args := m.Called(ctx, tenantIDs)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(map[string]int64), args.Error(1)
}
}
counts := make(map[string]int64, len(tenantIDs))
for _, tenantID := range tenantIDs {
counts[tenantID] = 0
}
return counts, nil
}
func (m *MockUserRepoForHandler) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) {
@@ -187,10 +209,6 @@ func (m *MockUserRepoForHandler) FindTenantIDByLoginID(ctx context.Context, logi
return "", nil
}
type MockUserProjectionRepoForHandler struct {
mock.Mock
}
type mockOrgChartCache struct {
mock.Mock
values map[string]string
@@ -210,40 +228,9 @@ func (m *mockOrgChartCache) Set(key string, value string, expiration time.Durati
return args.Error(0)
}
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) CountTenantMembersRecursive(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 (m *mockOrgChartCache) DeleteByPrefix(ctx context.Context, prefix string) (int64, error) {
args := m.Called(prefix)
return args.Get(0).(int64), args.Error(1)
}
func toJSONString(t *testing.T, value any) string {
@@ -281,14 +268,14 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
assert.Equal(t, "t1", got["id"])
}
func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *testing.T) {
func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
app.Use(func(c *fiber.Ctx) error {
@@ -303,11 +290,11 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
{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).
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 7}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").
Return([]domain.User{}, int64(7), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -320,7 +307,7 @@ func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *
require.Len(t, res.Items, 1)
assert.Equal(t, int64(2), res.Items[0].MemberCount)
assert.Equal(t, int64(7), res.Items[0].TotalMemberCount)
mockProjection.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_GetOrgChartSnapshotReturnsRedisCacheHit(t *testing.T) {
@@ -353,7 +340,6 @@ func TestTenantHandler_GetOrgChartSnapshotReturnsRedisCacheHit(t *testing.T) {
func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
cache := &mockOrgChartCache{}
now := time.Date(2026, 6, 9, 0, 0, 0, 0, time.UTC)
@@ -370,15 +356,15 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
cache.On("Get", mock.Anything).Return("", redis.Nil).Once()
cache.On("Set", mock.MatchedBy(func(key string) bool {
return strings.HasPrefix(key, "orgchart:snapshot:")
}), mock.Anything, mock.AnythingOfType("time.Duration")).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Once()
}), mock.Anything, time.Duration(0)).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Twice()
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1]}, nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{familyID: 0, samanID: 1}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).Return(map[string]int64{familyID: 1, samanID: 1}, nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID}).Return(map[string]int64{familyID: 0, samanID: 1}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, samanID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{samanID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return(users, int64(1), "", nil).Once()
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, UserProjectionRepo: mockProjection, OrgChartCache: cache}
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
@@ -401,14 +387,103 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
require.Equal(t, int64(1), body.Tenants[0].TotalMemberCount)
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockProjection.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestOrgChartSnapshotCacheKeySharesSuperAdminGlobalSnapshot(t *testing.T) {
first := orgChartSnapshotCacheKey(&domain.UserProfileResponse{
ID: "super-admin-1",
Role: domain.RoleSuperAdmin,
}, "")
second := orgChartSnapshotCacheKey(&domain.UserProfileResponse{
ID: "super-admin-2",
Role: domain.RoleSuperAdmin,
}, "")
require.Equal(t, first, second)
require.Equal(t, "orgchart:snapshot:v1:super_admin:all:none", first)
}
func TestResolveTenantDeletionPromotionTargetsUsesNearestRemainingAncestor(t *testing.T) {
rootID := "root"
parentID := "parent"
childID := "child"
tenants := []domain.Tenant{
{ID: rootID, Slug: "root"},
{ID: parentID, Slug: "parent", ParentID: &rootID},
{ID: childID, Slug: "child", ParentID: &parentID},
}
targets, err := resolveTenantDeletionPromotionTargets(tenants, []string{parentID, childID}, []string{childID})
require.NoError(t, err)
require.Equal(t, map[string]string{
childID: rootID,
}, targets)
}
func TestTenantHandler_WarmOrgChartSnapshotCacheStoresSuperAdminGlobalSnapshot(t *testing.T) {
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
cache := &mockOrgChartCache{}
now := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
familyID := "family"
tenants := []domain.Tenant{
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
require.NoError(t, h.WarmOrgChartSnapshotCache(context.Background()))
raw := cache.values["orgchart:snapshot:v1:super_admin:all:none"]
require.NotEmpty(t, raw)
var cached orgChartSnapshotResponse
require.NoError(t, json.Unmarshal([]byte(raw), &cached))
require.Len(t, cached.Tenants, 1)
require.Equal(t, "database", cached.Cache.Source)
require.False(t, cached.Cache.Hit)
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_RefreshOrgChartSnapshotCacheAfterTenantChangeInvalidatesAllSnapshotKeys(t *testing.T) {
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
cache := &mockOrgChartCache{}
now := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
familyID := "family"
tenants := []domain.Tenant{
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
cache.On("DeleteByPrefix", "orgchart:snapshot:v1:").Return(int64(3), nil).Once()
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
h.refreshOrgChartSnapshotCacheAfterTenantChange(context.Background(), "tenant_created")
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
now := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)
parent := func(id string) *string { return &id }
@@ -424,7 +499,7 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
{ID: "user-1", Email: "user@samaneng.com", Name: "Saman User", Role: domain.RoleUser, Status: domain.UserStatusActive, TenantID: &samanID, Tenant: &tenants[1], CreatedAt: now, UpdatedAt: now},
}
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, UserProjectionRepo: mockProjection}
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
@@ -438,14 +513,11 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
})
app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
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", "saman", "saman-platform")
})).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "saman", "saman-platform")
})).Return(map[string]int64{familyID: 1, samanID: 1, teamID: 0}, nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID, teamID}).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, samanID, teamID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{samanID, teamID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{teamID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{familyID, samanID, teamID}, "").Return(users, int64(1), "", nil).Once()
mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1], tenants[2]}, nil).Once()
@@ -463,18 +535,17 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
require.True(t, tenantSummarySlugsMatch(body.Tenants, "hanmac-family", "saman", "saman-platform"))
require.Len(t, body.Users, 1)
mockSvc.AssertExpectations(t)
mockProjection.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
app.Use(func(c *fiber.Ctx) error {
@@ -492,11 +563,13 @@ func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testi
{ID: childID, Name: "Child", Slug: "child", ParentID: &parentID},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "", "").Return(tenants, int64(2), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{parentID, childID}).
Return(map[string]int64{parentID: 1, childID: 2}, nil).Once()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
Return(map[string]int64{parentID: 3, childID: 2}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{parentID, childID}, "").
Return([]domain.User{}, int64(3), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{childID}, "").
Return([]domain.User{}, int64(2), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -510,49 +583,17 @@ func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testi
assert.Equal(t, int64(3), res.Items[0].TotalMemberCount)
assert.Equal(t, int64(2), res.Items[1].MemberCount)
assert.Equal(t, int64(2), res.Items[1].TotalMemberCount)
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)
mockProjection.AssertNotCalled(t, "CountTenantMembersRecursive", mock.Anything, mock.Anything)
mockUsers.AssertExpectations(t)
}
func TestTenantHandler_ListTenants(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
app.Use(func(c *fiber.Ctx) error {
@@ -569,11 +610,10 @@ func TestTenantHandler_ListTenants(t *testing.T) {
// Mocking for the new allTenants check in ListTenants
mockSvc.On("ListTenants", mock.Anything, 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()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"t1", "t2"}).
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"t1"}, "").Return([]domain.User{}, int64(5), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"t2"}, "").Return([]domain.User{}, int64(10), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -599,11 +639,11 @@ func TestTenantHandler_ListTenants(t *testing.T) {
func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
app.Use(func(c *fiber.Ctx) error {
@@ -621,9 +661,10 @@ func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.
}
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()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(3), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000002", "00000000-0000-0000-0000-000000000001"}).Return(map[string]int64{}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000002"}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").Return([]domain.User{}, int64(0), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=2&offset=0", nil)
resp, _ := app.Test(req)
@@ -662,11 +703,11 @@ func TestPageTenantsByCursorUsesStableCreatedAtAndIDOrder(t *testing.T) {
func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
parent := func(id string) *string { return &id }
@@ -688,14 +729,12 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
})
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()
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
})).Return(map[string]int64{}, nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"family", "company", "public-team"}).Return(map[string]int64{}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"family", "company", "public-team", "private-team", "private-child"}, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"company", "public-team", "private-team", "private-child"}, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{"public-team"}, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
resp, err := app.Test(req)
@@ -712,11 +751,11 @@ func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *test
func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUsers,
}
parent := func(id string) *string { return &id }
@@ -740,14 +779,10 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
})
app.Get("/tenants", h.ListTenants)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
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()
mockProjection.On("CountTenantMembersRecursive", 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()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"family", "company", "private-team", "private-child"}).Return(map[string]int64{}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", mock.Anything, "").Return([]domain.User{}, int64(0), "", nil).Maybe()
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
resp, err := app.Test(req)
@@ -761,6 +796,41 @@ func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *test
require.Contains(t, toJSONString(t, res), "private-child")
}
func TestTenantHandler_ListTenantsRejectsStaleProfileTenantScope(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"},
}
staleTenantID := "deleted-tenant"
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleUser,
TenantID: &staleTenantID,
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), 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.StatusConflict, resp.StatusCode)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Contains(t, body["error"], "tenant scope is not available")
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *testing.T) {
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
@@ -785,6 +855,41 @@ func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *te
mockKeto.AssertExpectations(t)
}
func TestTenantHandler_FilterPrivateTenantsTreatsMissingKetoRelationsAsDenied(t *testing.T) {
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: "company", Type: domain.TenantTypeCompany, Name: "한맥", Slug: "hanmac"},
{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}},
{ID: "public-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "공개팀", Slug: "public-team"},
}
mockKeto := new(devMockKetoService)
h := &TenantHandler{Keto: mockKeto}
relationErr := errors.New(`keto returned status 400: {"reason":"relation \"view_private\" does not exist"}`)
for _, relation := range []string{"view_private", "view_private_descendants", "view", "manage"} {
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "private-team", relation).Return(false, relationErr).Once()
}
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "company", "view_private_descendants").Return(false, relationErr).Once()
filtered, err := h.filterPrivateTenantsForProfile(context.Background(), tenants, &domain.UserProfileResponse{
ID: "user-1",
Role: "tenant_admin",
TenantID: parent("company"),
})
require.NoError(t, err)
require.ElementsMatch(t, []string{"hanmac", "public-team"}, tenantSlugs(filtered))
mockKeto.AssertExpectations(t)
}
func tenantSlugs(tenants []domain.Tenant) []string {
slugs := make([]string, 0, len(tenants))
for _, tenant := range tenants {
slugs = append(slugs, tenant.Slug)
}
return slugs
}
func tenantSlugsMatch(got []domain.Tenant, want ...string) bool {
if len(got) != len(want) {
return false
@@ -1127,47 +1232,14 @@ func TestTenantHandler_GetOrgContextJSONRequiresApiKey(t *testing.T) {
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) {
func TestTenantHandler_ListTenantsUsesUserRepositoryCountsWhenAvailable(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUserRepo := new(MockUserRepoForHandler)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserRepo: mockUserRepo,
UserProjectionRepo: mockProjection,
Service: mockSvc,
UserRepo: mockUserRepo,
}
app.Use(func(c *fiber.Ctx) error {
@@ -1183,16 +1255,11 @@ func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T
}
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()
mockProjection.On("CountTenantMembersRecursive", 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()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
mockUserRepo.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 152}, nil).Maybe()
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 152}, nil).Once()
mockUserRepo.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").
Return([]domain.User{}, int64(152), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -1203,8 +1270,8 @@ func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T
json.NewDecoder(resp.Body).Decode(&res)
assert.Len(t, res.Items, 1)
assert.Equal(t, int64(2), res.Items[0].MemberCount)
mockProjection.AssertExpectations(t)
assert.Equal(t, int64(152), res.Items[0].MemberCount)
mockUserRepo.AssertExpectations(t)
}
func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
@@ -1718,6 +1785,46 @@ func TestTenantHandler_ApproveTenant(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
func TestTenantHandler_ApproveTenantRefreshesOrgChartSnapshotCache(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
cache := &mockOrgChartCache{}
now := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
familyID := "family"
tenantID := "tenant-1"
tenants := []domain.Tenant{
{ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
{ID: tenantID, Type: domain.TenantTypeOrganization, Name: "조직", Slug: "team", ParentID: &familyID, Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
mockSvc.On("ApproveTenant", mock.Anything, tenantID).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, tenantID}).Return(map[string]int64{familyID: 0, tenantID: 0}, nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, tenantID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 1, "", []string{tenantID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
cache.On("DeleteByPrefix", "orgchart:snapshot:v1:").Return(int64(1), nil).Once()
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
h := &TenantHandler{
Service: mockSvc,
UserRepo: mockUsers,
OrgChartCache: cache,
}
app.Post("/tenants/:id/approve", h.ApproveTenant)
req := httptest.NewRequest("POST", "/tenants/"+tenantID+"/approve", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
func (m *MockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []string) error {
args := m.Called(ctx, tenantIDs)
return args.Error(0)