package service import ( "baron-sso-backend/internal/domain" "context" "testing" "time" "github.com/stretchr/testify/require" "gorm.io/gorm" ) func TestWorksmobileSyncServiceRejectsAliasEmailAlreadyUsedByOtherUser(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") rootID := "root-tenant" tenantID := "saman-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } tenant := domain.Tenant{ ID: tenantID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, } target := domain.User{ ID: "target-user", Email: "target@samaneng.com", Name: "Target", TenantID: &tenantID, Metadata: domain.JSONMap{ "aliasEmails": []any{"used@hanmaceng.co.kr"}, }, } existing := domain.User{ ID: "existing-user", Email: "used@hanmaceng.co.kr", Name: "Existing", TenantID: &tenantID, } outboxRepo := &fakeWorksmobileOutboxRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}}, &fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target, existing}}, outboxRepo, nil, ) item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "") require.Nil(t, item) require.Error(t, err) require.Contains(t, err.Error(), "이미 사용 중") require.Empty(t, outboxRepo.created) } func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") rootID := "root-tenant" tenantID := "saman-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "Hanmac Family", } tenant := domain.Tenant{ ID: tenantID, Slug: "saman", Name: "Saman", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, } target := domain.User{ ID: "target-user", Email: "target@samaneng.com", Name: "Target", Status: domain.UserStatusSuspended, TenantID: &tenantID, } outboxRepo := &fakeWorksmobileOutboxRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}}, &fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}}, outboxRepo, nil, ) item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "") require.NoError(t, err) require.NotNil(t, item) require.Len(t, outboxRepo.created, 1) require.Equal(t, domain.WorksmobileActionSuspend, outboxRepo.created[0].Action) require.Equal(t, domain.UserStatusSuspended, outboxRepo.created[0].Payload["baronStatus"]) request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload) require.True(t, ok) require.NotEmpty(t, request.Organizations) require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"]) } func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") rootID := "root-tenant" tenantID := "saman-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "Hanmac Family", } tenant := domain.Tenant{ ID: tenantID, Slug: "saman", Name: "Saman", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, } target := domain.User{ ID: "target-user", Email: "target@samaneng.com", Name: "Target", Status: domain.UserStatusActive, TenantID: &tenantID, } outboxRepo := &fakeWorksmobileOutboxRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}}, &fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}}, outboxRepo, nil, ) item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "batch-1") require.NoError(t, err) require.NotNil(t, item) require.Len(t, outboxRepo.created, 1) require.Equal(t, "batch-1", outboxRepo.created[0].Payload["credentialBatchId"]) require.NotEmpty(t, outboxRepo.created[0].Payload["credentialBatchCreatedAt"]) require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"]) require.Equal(t, "Saman", outboxRepo.created[0].Payload["primaryLeafOrgName"]) } func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") rootID := "root-tenant" tenantID := "saman-leaf" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "Hanmac Family", } tenant := domain.Tenant{ ID: tenantID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, } target := domain.User{ ID: "target-user", Email: "target@samaneng.com", Name: "Target", Status: domain.UserStatusActive, TenantID: &tenantID, } outboxRepo := &fakeWorksmobileOutboxRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}}, &fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}}, outboxRepo, nil, ) item, err := service.EnqueueUserPasswordReset(context.Background(), rootID, target.ID, "reset-batch-1") require.NoError(t, err) require.NotNil(t, item) require.Len(t, outboxRepo.created, 1) require.Equal(t, domain.WorksmobileActionPasswordReset, outboxRepo.created[0].Action) require.Equal(t, "reset-batch-1", outboxRepo.created[0].Payload["credentialBatchId"]) require.Equal(t, "worksmobile_password_reset", outboxRepo.created[0].Payload["credentialOperation"]) require.NotEmpty(t, outboxRepo.created[0].Payload["credentialBatchCreatedAt"]) require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"]) require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"]) require.Equal(t, "인재성장", outboxRepo.created[0].Payload["primaryLeafOrgName"]) require.NotEmpty(t, outboxRepo.created[0].Payload["initialPassword"]) } func TestWorksmobileSyncServiceFiltersInitialPasswordsByCredentialBatchID(t *testing.T) { rootID := "root-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "Hanmac Family", } outboxRepo := &fakeWorksmobileOutboxRepo{ credentialBatchJobs: []domain.WorksmobileOutbox{ { ResourceType: domain.WorksmobileResourceUser, Status: domain.WorksmobileOutboxStatusProcessed, Payload: domain.JSONMap{ "tenantRootId": rootID, "loginEmail": "batch-user@samaneng.com", "displayName": "Batch User", "primaryLeafOrgName": "인재성장", "initialPassword": "BatchPass1!", "credentialBatchId": "batch-1", }, }, { ResourceType: domain.WorksmobileResourceUser, Status: domain.WorksmobileOutboxStatusProcessed, Payload: domain.JSONMap{ "tenantRootId": rootID, "loginEmail": "other-user@samaneng.com", "initialPassword": "OtherPass1!", "credentialBatchId": "batch-2", }, }, { ResourceType: domain.WorksmobileResourceUser, Status: domain.WorksmobileOutboxStatusProcessed, Payload: domain.JSONMap{ "tenantRootId": rootID, "loginEmail": "legacy-user@samaneng.com", "initialPassword": "LegacyPass1!", }, }, }, } service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}}, &fakeWorksmobileUserRepo{}, outboxRepo, nil, ) credentials, err := service.ListInitialPasswordCredentials(context.Background(), rootID, "batch-1") require.NoError(t, err) require.Equal(t, []WorksmobileInitialPasswordCredential{ { Email: "batch-user@samaneng.com", Name: "Batch User", PrimaryLeafOrgName: "인재성장", InitialPassword: "BatchPass1!", Status: domain.WorksmobileOutboxStatusProcessed, }, }, credentials) } func TestWorksmobileSyncServiceDeletesCredentialBatchPasswordsButKeepsHistory(t *testing.T) { rootID := "root-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "Hanmac Family", } outboxRepo := &fakeWorksmobileOutboxRepo{ credentialBatchJobs: []domain.WorksmobileOutbox{ { ID: "job-1", ResourceType: domain.WorksmobileResourceUser, Status: domain.WorksmobileOutboxStatusProcessed, Payload: domain.JSONMap{ "tenantRootId": rootID, "loginEmail": "batch-user@samaneng.com", "initialPassword": "BatchPass1!", "credentialBatchId": "batch-1", "credentialOperation": "worksmobile_user_sync", "request": map[string]any{"passwordConfig": map[string]any{"password": "BatchPass1!"}}, }, }, { ID: "job-2", ResourceID: "failed-user", ResourceType: domain.WorksmobileResourceUser, Status: domain.WorksmobileOutboxStatusFailed, RetryCount: 2, LastError: "worksmobile api failed", Payload: domain.JSONMap{ "tenantRootId": rootID, "loginEmail": "failed-user@samaneng.com", "initialPassword": "FailedPass1!", "credentialBatchId": "batch-1", "credentialOperation": "worksmobile_user_sync", "request": map[string]any{"passwordConfig": map[string]any{"password": "FailedPass1!"}}, }, }, }, } service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}}, &fakeWorksmobileUserRepo{}, outboxRepo, nil, ) before, err := service.ListCredentialBatches(context.Background(), rootID) require.NoError(t, err) require.Len(t, before, 1) require.True(t, before[0].HasPasswords) require.Equal(t, 1, before[0].FailedCount) require.Len(t, before[0].Failures, 1) require.Equal(t, "failed-user", before[0].Failures[0].UserID) require.Equal(t, "failed-user@samaneng.com", before[0].Failures[0].Email) require.Equal(t, "worksmobile api failed", before[0].Failures[0].LastError) after, err := service.DeleteCredentialBatchPasswords(context.Background(), rootID, "batch-1") require.NoError(t, err) require.Equal(t, "batch-1", after.BatchID) require.False(t, after.HasPasswords) require.Equal(t, 2, after.UserCount) require.NotEmpty(t, after.DeletedAt) require.Len(t, outboxRepo.payloadUpdates, 2) require.Empty(t, stringValue(outboxRepo.payloadUpdates[0]["initialPassword"])) require.Empty(t, stringValue(outboxRepo.payloadUpdates[1]["initialPassword"])) request := outboxRepo.payloadUpdates[0]["request"].(map[string]any) passwordConfig := request["passwordConfig"].(map[string]any) require.Empty(t, stringValue(passwordConfig["password"])) } func TestAggregateWorksmobileCredentialBatchesUsesCredentialBatchCreatedAt(t *testing.T) { oldCreatedAt := time.Date(2026, 5, 29, 1, 4, 15, 0, time.UTC) batchCreatedAt := time.Date(2026, 6, 1, 7, 20, 0, 0, time.UTC) batches := aggregateWorksmobileCredentialBatches([]domain.WorksmobileOutbox{ { ID: "job-1", CreatedAt: oldCreatedAt, UpdatedAt: batchCreatedAt.Add(time.Minute), Status: domain.WorksmobileOutboxStatusPending, Payload: domain.JSONMap{ "credentialBatchId": "batch-1", "credentialOperation": "worksmobile_user_sync", "credentialBatchCreatedAt": batchCreatedAt.Format(time.RFC3339), }, }, }) require.Len(t, batches, 1) require.Equal(t, batchCreatedAt, batches[0].CreatedAt) } func TestWorksmobileSyncServiceDeprovisionsArchivedUser(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") rootID := "root-tenant" tenantID := "saman-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "Hanmac Family", } tenant := domain.Tenant{ ID: tenantID, Slug: "saman", Name: "Saman", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, } target := domain.User{ ID: "archived-user", Email: "archived@samaneng.com", Name: "Archived", Status: domain.UserStatusArchived, TenantID: &tenantID, } outboxRepo := &fakeWorksmobileOutboxRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}}, &fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}}, outboxRepo, nil, ) item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "") require.NoError(t, err) require.NotNil(t, item) require.Equal(t, domain.WorksmobileActionDelete, item.Action) require.Len(t, outboxRepo.created, 1) err = service.EnqueueUserUpsertIfInScope(context.Background(), target) require.NoError(t, err) require.Len(t, outboxRepo.created, 2) require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[1].Action) } func TestWorksmobileSyncServiceOverviewExposesAdminTenantIDForPasswordManageLink(t *testing.T) { t.Setenv("WORKS_ADMIN_TENANT_ID", "works-tenant-1") root := domain.Tenant{ ID: "root-tenant", Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{root.ID: root}}, &fakeWorksmobileUserRepo{}, &fakeWorksmobileOutboxRepo{}, nil, ) overview, err := service.GetTenantOverview(context.Background(), root.ID) require.NoError(t, err) require.Equal(t, "works-tenant-1", overview.Config.AdminTenantID) } func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *testing.T) { root := domain.Tenant{ ID: "root-tenant", Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } outboxRepo := &fakeWorksmobileOutboxRepo{ recent: []domain.WorksmobileOutbox{ { ID: "job-user-upsert", ResourceType: domain.WorksmobileResourceUser, ResourceID: "user-1", Action: domain.WorksmobileActionUpsert, Status: domain.WorksmobileOutboxStatusProcessed, Payload: domain.JSONMap{ "loginEmail": "changed@example.com", "displayName": "변경 사용자", "primaryLeafOrgName": "인재성장", "initialPassword": "Secret123!", "request": WorksmobileUserPayload{ Email: "changed@example.com", UserExternalKey: "user-1", UserName: WorksmobileUserName{LastName: "변경 사용자"}, PasswordConfig: WorksmobilePasswordConfig{Password: "Secret123!"}, }, }, }, { ID: "job-org-upsert", ResourceType: domain.WorksmobileResourceOrgUnit, ResourceID: "org-1", Action: domain.WorksmobileActionUpsert, Status: domain.WorksmobileOutboxStatusProcessed, Payload: domain.JSONMap{ "matchLocalPart": "people-growth", "request": WorksmobileOrgUnitPayload{ OrgUnitName: "인재성장", Email: "people-growth@example.com", OrgUnitExternalKey: "org-1", ParentOrgUnitID: "externalKey:parent-1", }, }, }, }, } service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{root.ID: root}}, &fakeWorksmobileUserRepo{}, outboxRepo, nil, ) overview, err := service.GetTenantOverview(context.Background(), root.ID) require.NoError(t, err) require.Len(t, overview.RecentJobs, 2) userPayload := overview.RecentJobs[0].Payload require.Equal(t, "changed@example.com", userPayload["loginEmail"]) require.Equal(t, "변경 사용자", userPayload["displayName"]) require.Equal(t, "인재성장", userPayload["primaryLeafOrgName"]) require.NotContains(t, userPayload, "initialPassword") require.NotContains(t, userPayload, "request") require.Equal(t, domain.JSONMap{ "email": "changed@example.com", "displayName": "변경 사용자", "userExternalKey": "user-1", }, userPayload["requestSummary"]) orgPayload := overview.RecentJobs[1].Payload require.Equal(t, "people-growth", orgPayload["matchLocalPart"]) require.NotContains(t, orgPayload, "request") require.Equal(t, domain.JSONMap{ "email": "people-growth@example.com", "orgUnitName": "인재성장", "orgUnitExternalKey": "org-1", "parentOrgUnitId": "externalKey:parent-1", }, orgPayload["requestSummary"]) } func TestWorksmobileSyncServiceEnqueueTenantUpsertReflectsChangedParentOrgUnit(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") rootID := "root-tenant" companyID := "saman-tenant" newParentID := "new-parent-org" childID := "child-org" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup, } company := domain.Tenant{ ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, } newParent := domain.Tenant{ ID: newParentID, Slug: "planning", Name: "총괄기획", Type: domain.TenantTypeOrganization, ParentID: &companyID, } child := domain.Tenant{ ID: childID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization, ParentID: &newParentID, } outboxRepo := &fakeWorksmobileOutboxRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{ tenants: map[string]domain.Tenant{ rootID: root, companyID: company, newParentID: newParent, childID: child, }, list: []domain.Tenant{root, company, newParent, child}, }, &fakeWorksmobileUserRepo{}, outboxRepo, nil, ) err := service.EnqueueTenantUpsertIfInScope(context.Background(), child) require.NoError(t, err) require.Len(t, outboxRepo.created, 1) require.Equal(t, domain.WorksmobileResourceOrgUnit, outboxRepo.created[0].ResourceType) require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action) require.Equal(t, childID, outboxRepo.created[0].ResourceID) request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload) require.True(t, ok) require.Equal(t, childID, request.OrgUnitExternalKey) require.Equal(t, "externalKey:"+newParentID, request.ParentOrgUnitID) require.Equal(t, "people-growth", outboxRepo.created[0].Payload["matchLocalPart"]) } func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t *testing.T) { parentID := "root-tenant" root := domain.Tenant{ ID: parentID, Name: "한맥가족", Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup, } hanmac := domain.Tenant{ ID: "hanmac-tenant", Name: "한맥기술", Slug: "hanmac", Type: domain.TenantTypeCompany, ParentID: &parentID, } barongroup := domain.Tenant{ ID: "barongroup-tenant", Name: "바론그룹", Slug: "baron-group", Type: domain.TenantTypeCompany, ParentID: &parentID, } barongroupChildCompany := domain.Tenant{ ID: "barongroup-child-company", Name: "바론그룹 하위 회사", Type: domain.TenantTypeCompany, ParentID: &barongroup.ID, } organization := domain.Tenant{ ID: "organization-tenant", Name: "정규 조직", Type: domain.TenantTypeOrganization, ParentID: &hanmac.ID, } userGroup := domain.Tenant{ ID: "legacy-user-group-tenant", Name: "사용자 그룹 조직", Type: domain.TenantTypeUserGroup, ParentID: &hanmac.ID, } items := compareWorksmobileGroups( []domain.Tenant{root, hanmac, barongroup, barongroupChildCompany, organization, userGroup}, []WorksmobileRemoteGroup{ {ID: "works-root", ExternalID: root.ID, DisplayName: root.Name}, {ID: "works-hanmac", ExternalID: hanmac.ID, DisplayName: hanmac.Name, Email: "hanmac@hanmaceng.co.kr"}, {ID: "works-barongroup", ExternalID: barongroup.ID, DisplayName: barongroup.Name}, {ID: "works-barongroup-child", ExternalID: barongroupChildCompany.ID, DisplayName: barongroupChildCompany.Name}, {ID: "works-organization", ExternalID: organization.ID, DisplayName: organization.Name, ParentID: "works-hanmac"}, {ID: "works-user-group", ExternalID: userGroup.ID, DisplayName: userGroup.Name, ParentID: "works-hanmac"}, {ID: "works-orphan", ExternalID: "works-orphan", DisplayName: "WORKS 전용 조직"}, }, true, ) require.Len(t, items, 4) require.Equal(t, barongroupChildCompany.ID, items[0].BaronID) require.Equal(t, "matched", items[0].Status) require.Equal(t, organization.ID, items[1].BaronID) require.Equal(t, "matched", items[1].Status) require.Equal(t, "works-hanmac", items[1].WorksmobileParentID) require.Equal(t, hanmac.Name, items[1].WorksmobileParentName) require.Equal(t, "hanmac@hanmaceng.co.kr", items[1].WorksmobileParentEmail) require.Equal(t, hanmac.ID, items[1].WorksmobileParentExternalKey) require.Equal(t, "works-hanmac", items[1].BaronParentWorksmobileID) require.Equal(t, hanmac.Name, items[1].BaronParentWorksmobileName) require.Equal(t, "hanmac@hanmaceng.co.kr", items[1].BaronParentWorksmobileEmail) require.Equal(t, userGroup.ID, items[2].BaronID) require.Equal(t, "matched", items[2].Status) require.Equal(t, "works-orphan", items[3].ExternalKey) require.Equal(t, "missing_in_baron", items[3].Status) } func TestCompareWorksmobileGroupsShowsUserGroupMissingInWorksmobile(t *testing.T) { parentID := "company-tenant" userGroup := domain.Tenant{ ID: "team-tenant", Name: "신규 팀", Slug: "new-team", Type: domain.TenantTypeUserGroup, ParentID: &parentID, } items := compareWorksmobileGroups( []domain.Tenant{ {ID: parentID, Slug: "company", Name: "계열사", Type: domain.TenantTypeCompany}, userGroup, }, nil, false, ) require.Len(t, items, 1) require.Equal(t, userGroup.ID, items[0].BaronID) require.Equal(t, "missing_in_worksmobile", items[0].Status) } func TestCompareWorksmobileGroupsMarksMatchedOrgUnitNeedsUpdate(t *testing.T) { parentID := "parent-tenant" tenant := domain.Tenant{ ID: "team-tenant", Name: "변경된 팀명", Slug: "team", Type: domain.TenantTypeUserGroup, ParentID: &parentID, } items := compareWorksmobileGroups( []domain.Tenant{ {ID: parentID, Slug: "parent", Name: "상위 조직", Type: domain.TenantTypeUserGroup}, tenant, }, []WorksmobileRemoteGroup{ {ID: "works-parent", ExternalID: parentID, DisplayName: "상위 조직"}, {ID: "works-team", ExternalID: tenant.ID, DisplayName: "이전 팀명", ParentID: "works-parent"}, }, false, ) require.Len(t, items, 1) require.Equal(t, tenant.ID, items[0].BaronID) require.Equal(t, "needs_update", items[0].Status) } func TestCompareWorksmobileGroupsCoversHanmacOrganizationRegressionIDs(t *testing.T) { rootID := "038326b6-954a-48a7-a85f-efd83f62b82a" samanID := "9caf62e1-297d-4e8f-870b-61780998bbeb" hanmacID := "369c1843-56af-4344-9c21-0e01197ab861" baronGroupID := "96369f12-6b66-4b2a-a916-d1c99d326f02" changedID := "818c856b-9545-442f-b827-d1c569f200b0" hanmacOnlyID := "2d217948-9c5a-42ea-805b-eef9c7421775" baronOnlyID := "32464fd6-da51-473f-844a-ab88603ad1f0" localTenants := []domain.Tenant{ {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup}, {ID: samanID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID}, {ID: hanmacID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID}, {ID: baronGroupID, Slug: "baron-group", Name: "바론그룹", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}, {ID: changedID, Slug: "rnd-saman", Name: "삼안기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &samanID}, {ID: hanmacOnlyID, Slug: "rnd-hanmac", Name: "한맥기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &hanmacID}, {ID: baronOnlyID, Slug: "rnd-baron", Name: "바론기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &baronGroupID}, } remoteGroups := []WorksmobileRemoteGroup{ {ID: "works-saman", ExternalID: samanID, DisplayName: "삼안"}, {ID: "works-hanmac", ExternalID: hanmacID, DisplayName: "한맥기술"}, { ID: "works-rnd-saman", ExternalID: changedID, DisplayName: "삼안기술개발센터(조직도용)", }, } items := compareWorksmobileGroups(localTenants, remoteGroups, false) itemsByBaronID := map[string]WorksmobileComparisonItem{} for _, item := range items { itemsByBaronID[item.BaronID] = item } require.Equal(t, "needs_update", itemsByBaronID[changedID].Status) require.Equal(t, "missing_in_worksmobile", itemsByBaronID[hanmacOnlyID].Status) require.Equal(t, "missing_in_worksmobile", itemsByBaronID[baronOnlyID].Status) } func TestCompareWorksmobileGroupsDoesNotMatchBaronGroupOrganizationInGPDTDCDomain(t *testing.T) { t.Setenv("GPDTDC_DOMAIN_ID", "1003") t.Setenv("BARONGROUP_DOMAIN_ID", "1004") rootID := "038326b6-954a-48a7-a85f-efd83f62b82a" baronGroupID := "96369f12-6b66-4b2a-a916-d1c99d326f02" orgID := "32464fd6-da51-473f-844a-ab88603ad1f0" localTenants := []domain.Tenant{ {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup}, {ID: baronGroupID, Slug: "baron-group", Name: "바론그룹", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}, {ID: orgID, Slug: "rnd-baron", Name: "바론기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &baronGroupID}, } remoteGroups := []WorksmobileRemoteGroup{ { ID: "works-rnd-baron-gpdtdc", ExternalID: orgID, DisplayName: "바론기술개발센터(조직도용)", DomainID: 1003, DomainName: "총괄기획&기술개발센터", }, } items := compareWorksmobileGroups(localTenants, remoteGroups, false) require.Len(t, items, 1) require.Equal(t, orgID, items[0].BaronID) require.Equal(t, "missing_in_worksmobile", items[0].Status) require.Empty(t, items[0].WorksmobileID) } func TestCompareWorksmobileGroupsMatchesBaronGroupOrganizationInBaronGroupDomain(t *testing.T) { t.Setenv("GPDTDC_DOMAIN_ID", "1003") t.Setenv("BARONGROUP_DOMAIN_ID", "1004") rootID := "038326b6-954a-48a7-a85f-efd83f62b82a" baronGroupID := "96369f12-6b66-4b2a-a916-d1c99d326f02" orgID := "32464fd6-da51-473f-844a-ab88603ad1f0" localTenants := []domain.Tenant{ {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup}, {ID: baronGroupID, Slug: "baron-group", Name: "바론그룹", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}, {ID: orgID, Slug: "rnd-baron", Name: "바론기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &baronGroupID}, } remoteGroups := []WorksmobileRemoteGroup{ { ID: "works-rnd-baron", ExternalID: orgID, DisplayName: "바론기술개발센터(조직도용)", DomainID: 1004, DomainName: "바론그룹", }, } diffOnly := compareWorksmobileGroups(localTenants, remoteGroups, false) all := compareWorksmobileGroups(localTenants, remoteGroups, true) require.Empty(t, diffOnly) require.Len(t, all, 1) require.Equal(t, orgID, all[0].BaronID) require.Equal(t, "matched", all[0].Status) require.Equal(t, int64(1004), all[0].WorksmobileDomainID) } func TestWorksmobileSyncServiceRejectsDomainCompanyOrgUnitSync(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") rootID := "root-tenant" companyID := "company-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } company := domain.Tenant{ ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, } outboxRepo := &fakeWorksmobileOutboxRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}}, &fakeWorksmobileUserRepo{}, outboxRepo, nil, ) item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, companyID) require.Nil(t, item) require.Error(t, err) require.Contains(t, err.Error(), "worksmobile orgunit tenant") require.Empty(t, outboxRepo.created) } func TestWorksmobileSyncServiceEnqueuesBarongroupChildCompanyOrgUnitSync(t *testing.T) { t.Setenv("BARONGROUP_DOMAIN_ID", "1004") rootID := "root-tenant" barongroupID := "barongroup-tenant" companyID := "barongroup-child-company" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } barongroup := domain.Tenant{ ID: barongroupID, Slug: "baron-group", Name: "바론그룹", Type: domain.TenantTypeCompany, ParentID: &rootID, } company := domain.Tenant{ ID: companyID, Slug: "barongroup-child", Name: "바론그룹 하위 회사", Type: domain.TenantTypeCompany, ParentID: &barongroupID, } outboxRepo := &fakeWorksmobileOutboxRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, barongroupID: barongroup, companyID: company}, list: []domain.Tenant{root, barongroup, company}}, &fakeWorksmobileUserRepo{}, outboxRepo, nil, ) item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, companyID) require.NoError(t, err) require.NotNil(t, item) require.Len(t, outboxRepo.created, 1) request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload) require.Equal(t, companyID, request.OrgUnitExternalKey) require.Empty(t, request.ParentOrgUnitID) } func TestWorksmobileSyncServiceEnqueuesOrganizationOrgUnitSync(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") rootID := "root-tenant" companyID := "company-tenant" organizationID := "organization-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } company := domain.Tenant{ ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, } organization := domain.Tenant{ ID: organizationID, Slug: "engineering", Name: "기술본부", Type: domain.TenantTypeOrganization, ParentID: &companyID, } outboxRepo := &fakeWorksmobileOutboxRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{ tenants: map[string]domain.Tenant{rootID: root, companyID: company, organizationID: organization}, list: []domain.Tenant{root, company, organization}, }, &fakeWorksmobileUserRepo{}, outboxRepo, nil, ) item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, organizationID) require.NoError(t, err) require.NotNil(t, item) require.Len(t, outboxRepo.created, 1) request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload) require.Equal(t, organizationID, request.OrgUnitExternalKey) require.Empty(t, request.ParentOrgUnitID) require.Equal(t, 1, request.DisplayOrder) } func TestWorksmobileSyncServiceEnqueuesExternalKeyMissingOrgUnitDelete(t *testing.T) { rootID := "root-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } outboxRepo := &fakeWorksmobileOutboxRepo{} client := &fakeWorksmobileDirectoryClient{ groups: []WorksmobileRemoteGroup{ { ID: "works-org-1", DisplayName: "WORKS 전용 조직", DomainID: 1001, ParentID: "works-parent", }, }, } service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}}, &fakeWorksmobileUserRepo{}, outboxRepo, client, ) item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-org-1") require.NoError(t, err) require.NotNil(t, item) require.Len(t, outboxRepo.created, 1) require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[0].Action) require.Equal(t, "works-org-1", outboxRepo.created[0].Payload["worksmobileId"]) } func TestWorksmobileSyncServiceEnqueuesExternalKeyPresentWorksOnlyOrgUnitDelete(t *testing.T) { rootID := "root-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } outboxRepo := &fakeWorksmobileOutboxRepo{} client := &fakeWorksmobileDirectoryClient{ groups: []WorksmobileRemoteGroup{ { ID: "works-org-1", ExternalID: "baron-tenant-1", ParentID: "works-parent", }, }, } service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}}, &fakeWorksmobileUserRepo{}, outboxRepo, client, ) item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-org-1") require.NoError(t, err) require.NotNil(t, item) require.Len(t, outboxRepo.created, 1) require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[0].Action) require.Equal(t, "baron-tenant-1", outboxRepo.created[0].Payload["externalKey"]) } func TestWorksmobileSyncServiceReconcilesWorksOnlyOrgUnitBySlugLocalPart(t *testing.T) { t.Setenv("GPDTDC_DOMAIN_ID", "1001") rootID := "root-tenant" orgID := "baron-org-1" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } organization := domain.Tenant{ ID: orgID, Slug: "tech-dev-center", Name: "기술개발센터", Type: domain.TenantTypeOrganization, ParentID: &rootID, } outboxRepo := &fakeWorksmobileOutboxRepo{} client := &fakeWorksmobileDirectoryClient{ groups: []WorksmobileRemoteGroup{ { ID: "works-org-1", ExternalID: "legacy-external-key", DisplayName: "기술개발센터", MailLocalPart: "tech-dev-center", DomainID: 1001, ParentID: "works-parent", }, }, } service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{ tenants: map[string]domain.Tenant{rootID: root, orgID: organization}, list: []domain.Tenant{root, organization}, }, &fakeWorksmobileUserRepo{}, outboxRepo, client, ) item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-org-1") require.NoError(t, err) require.NotNil(t, item) require.Len(t, outboxRepo.created, 1) require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action) require.Equal(t, orgID, outboxRepo.created[0].ResourceID) request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload) require.Equal(t, orgID, request.OrgUnitExternalKey) require.Equal(t, "tech-dev-center", outboxRepo.created[0].Payload["matchLocalPart"]) } func TestCompareWorksmobileGroupsFillsParentDisplayFromBaronParentMatch(t *testing.T) { rootID := "root-tenant" parent := domain.Tenant{ ID: "parent-tenant", Name: "삼안", Slug: "saman", Type: domain.TenantTypeCompany, ParentID: &rootID, } child := domain.Tenant{ ID: "child-tenant", Name: "업무", Slug: "operations", Type: domain.TenantTypeOrganization, ParentID: &parent.ID, } items := compareWorksmobileGroups( []domain.Tenant{ {ID: rootID, Name: "한맥가족", Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup}, parent, child, }, []WorksmobileRemoteGroup{ {ID: "works-parent", ExternalID: parent.ID, DisplayName: "삼안", Email: "saman@samaneng.com"}, {ID: "works-child", ExternalID: child.ID, DisplayName: "업무", ParentID: "works-parent"}, }, true, ) require.Len(t, items, 1) require.Equal(t, child.ID, items[0].BaronID) require.Equal(t, "works-parent", items[0].WorksmobileParentID) require.Equal(t, "삼안", items[0].WorksmobileParentName) require.Equal(t, "saman@samaneng.com", items[0].WorksmobileParentEmail) require.Equal(t, parent.ID, items[0].WorksmobileParentExternalKey) } func TestWorksmobileSyncServiceReconcilesTopLevelWorksOnlyOrgUnitBeforeProtectedDeleteGuard(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") rootID := "root-tenant" orgID := "baron-operations" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } samanID := "saman-tenant" saman := domain.Tenant{ ID: samanID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, } organization := domain.Tenant{ ID: orgID, Slug: "operations", Name: "업무", Type: domain.TenantTypeOrganization, ParentID: &samanID, } outboxRepo := &fakeWorksmobileOutboxRepo{} client := &fakeWorksmobileDirectoryClient{ groups: []WorksmobileRemoteGroup{ { ID: "works-operations", ExternalID: "legacy-operations-id", DisplayName: "업무팀", Email: "operations@samaneng.com", MailLocalPart: "operations", DomainID: 1001, }, }, } service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{ tenants: map[string]domain.Tenant{rootID: root, samanID: saman, orgID: organization}, list: []domain.Tenant{root, saman, organization}, }, &fakeWorksmobileUserRepo{}, outboxRepo, client, ) item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-operations") require.NoError(t, err) require.NotNil(t, item) require.Len(t, outboxRepo.created, 1) require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action) require.Equal(t, orgID, outboxRepo.created[0].ResourceID) request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload) require.Equal(t, orgID, request.OrgUnitExternalKey) require.Equal(t, "operations", outboxRepo.created[0].Payload["matchLocalPart"]) } func TestWorksmobileSyncServiceRejectsProtectedDomainRootOrgUnitDelete(t *testing.T) { rootID := "root-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } outboxRepo := &fakeWorksmobileOutboxRepo{} client := &fakeWorksmobileDirectoryClient{ groups: []WorksmobileRemoteGroup{ { ID: "works-root", DisplayName: "한맥기술", }, }, } service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}}, &fakeWorksmobileUserRepo{}, outboxRepo, client, ) item, err := service.EnqueueOrgUnitDelete(context.Background(), rootID, "works-root") require.Nil(t, item) require.Error(t, err) require.Contains(t, err.Error(), "protected worksmobile domain root") require.Empty(t, outboxRepo.created) } func TestWorksmobileSyncServiceDeletesPendingJobsForTenantRoot(t *testing.T) { rootID := "root-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } outboxRepo := &fakeWorksmobileOutboxRepo{deletedPendingCount: 2} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}}, &fakeWorksmobileUserRepo{}, outboxRepo, nil, ) result, err := service.DeletePendingJobs(context.Background(), rootID) require.NoError(t, err) require.Equal(t, 2, result.DeletedCount) require.Equal(t, rootID, outboxRepo.deletedPendingTenantRootID) } func TestWorksmobileSyncServiceTreatsHanmacFamilyChildCompaniesAsDomainRoots(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") t.Setenv("HANMAC_DOMAIN_ID", "1002") t.Setenv("GPDTDC_DOMAIN_ID", "1003") t.Setenv("HALLA_DOMAIN_ID", "1005") t.Setenv("WORKS_DEFAULT_DOMAIN_HALLA", "hallasanup.com") t.Setenv("BARONGROUP_DOMAIN_ID", "1004") rootID := "root-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } tests := []struct { name string company domain.Tenant organization domain.Tenant wantDomainID int64 wantEmail string }{ { name: "saman", company: domain.Tenant{ ID: "company-saman", Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, }, organization: domain.Tenant{ ID: "org-saman-planning", Slug: "saman-planning", Name: "삼안 기획팀", Type: domain.TenantTypeOrganization, }, wantDomainID: 1001, wantEmail: "saman-planning@samaneng.com", }, { name: "hanmac", company: domain.Tenant{ ID: "company-hanmac", Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}}, }, organization: domain.Tenant{ ID: "org-hanmac-planning", Slug: "hanmac-planning", Name: "한맥 기획팀", Type: domain.TenantTypeOrganization, }, wantDomainID: 1002, wantEmail: "hanmac-planning@hanmaceng.co.kr", }, { name: "gpdtdc", company: domain.Tenant{ ID: "company-gpdtdc", Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompany, ParentID: &rootID, }, organization: domain.Tenant{ ID: "org-gpdtdc-planning", Slug: "gpdtdc-planning", Name: "총괄 기획팀", Type: domain.TenantTypeOrganization, }, wantDomainID: 1003, wantEmail: "gpdtdc-planning@baroncs.co.kr", }, { name: "baron-group", company: domain.Tenant{ ID: "company-barongroup", Slug: "baron-group", Name: "바론그룹", Type: domain.TenantTypeCompany, ParentID: &rootID, }, organization: domain.Tenant{ ID: "org-baron-planning", Slug: "baron-planning", Name: "바론 기획팀", Type: domain.TenantTypeOrganization, }, wantDomainID: 1004, wantEmail: "baron-planning@brsw.kr", }, { name: "halla", company: domain.Tenant{ ID: "company-halla", Slug: "halla", Name: "한라산업개발", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "hallasanup.com"}}, }, organization: domain.Tenant{ ID: "org-halla-planning", Slug: "halla-planning", Name: "한라 기획팀", Type: domain.TenantTypeOrganization, }, wantDomainID: 1005, wantEmail: "halla-planning@hallasanup.com", }, { name: "hanlla legacy slug", company: domain.Tenant{ ID: "company-hanlla", Slug: "hanlla", Name: "한라산업개발", Type: domain.TenantTypeOrganization, ParentID: &rootID, }, organization: domain.Tenant{ ID: "org-hanlla-construction-sites", Slug: "hanlla-construction-sites", Name: "시공현장", Type: domain.TenantTypeOrganization, }, wantDomainID: 1005, wantEmail: "hanlla-construction-sites@hallasanup.com", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { organization := tt.organization organization.ParentID = &tt.company.ID outboxRepo := &fakeWorksmobileOutboxRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{ tenants: map[string]domain.Tenant{ rootID: root, tt.company.ID: tt.company, organization.ID: organization, }, list: []domain.Tenant{root, tt.company, organization}, }, &fakeWorksmobileUserRepo{}, outboxRepo, nil, ) item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, organization.ID) require.NoError(t, err) require.NotNil(t, item) require.Len(t, outboxRepo.created, 1) request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload) require.Equal(t, organization.ID, request.OrgUnitExternalKey) require.Equal(t, tt.wantDomainID, request.DomainID) require.Equal(t, tt.wantEmail, request.Email) require.Empty(t, request.ParentOrgUnitID) }) } } func TestWorksmobileSyncServiceUsesBaronGroupDomainForBaronGroupChildOrganization(t *testing.T) { t.Setenv("GPDTDC_DOMAIN_ID", "1003") t.Setenv("BARONGROUP_DOMAIN_ID", "1004") rootID := "038326b6-954a-48a7-a85f-efd83f62b82a" baronGroupID := "96369f12-6b66-4b2a-a916-d1c99d326f02" orgID := "32464fd6-da51-473f-844a-ab88603ad1f0" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup, } baronGroup := domain.Tenant{ ID: baronGroupID, Slug: "baron-group", Name: "바론그룹", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID, } organization := domain.Tenant{ ID: orgID, Slug: "rnd-baron", Name: "바론기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &baronGroupID, } outboxRepo := &fakeWorksmobileOutboxRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{ tenants: map[string]domain.Tenant{ root.ID: root, baronGroup.ID: baronGroup, organization.ID: organization, }, list: []domain.Tenant{root, baronGroup, organization}, }, &fakeWorksmobileUserRepo{}, outboxRepo, nil, ) item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, organization.ID) require.NoError(t, err) require.NotNil(t, item) require.Len(t, outboxRepo.created, 1) request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload) require.Equal(t, int64(1004), request.DomainID) require.Equal(t, "rnd-baron@brsw.kr", request.Email) } func TestWorksmobileSyncServiceUsesGPDTDCDomainForGPDTDCChildOrganization(t *testing.T) { t.Setenv("GPDTDC_DOMAIN_ID", "1003") t.Setenv("BARONGROUP_DOMAIN_ID", "1004") rootID := "038326b6-954a-48a7-a85f-efd83f62b82a" gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee" orgID := "gpdtdc-child-organization" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup, } gpdtdc := domain.Tenant{ ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeOrganization, ParentID: &rootID, } organization := domain.Tenant{ ID: orgID, Slug: "planning", Name: "기획", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID, } outboxRepo := &fakeWorksmobileOutboxRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{ tenants: map[string]domain.Tenant{ root.ID: root, gpdtdc.ID: gpdtdc, organization.ID: organization, }, list: []domain.Tenant{root, gpdtdc, organization}, }, &fakeWorksmobileUserRepo{}, outboxRepo, nil, ) item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, organization.ID) require.NoError(t, err) require.NotNil(t, item) require.Len(t, outboxRepo.created, 1) request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload) require.Equal(t, int64(1003), request.DomainID) require.Equal(t, "planning@baroncs.co.kr", request.Email) } func TestWorksmobileDomainClassificationUsesAncestorCompanyForGPDTDCOrganization(t *testing.T) { t.Setenv("GPDTDC_DOMAIN_ID", "1003") rootID := "root-tenant" companyID := "company-tenant" organizationID := "organization-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } company := domain.Tenant{ ID: companyID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "baroncs.co.kr"}}, } organization := domain.Tenant{ ID: organizationID, Slug: "gpd", Name: "총괄기획실", Type: domain.TenantTypeOrganization, ParentID: &companyID, } tenantByID := worksmobileTenantByID([]domain.Tenant{root, company, organization}) domainTenant := worksmobileDomainClassificationTenant(organization, tenantByID) payload, err := BuildWorksmobileOrgUnitPayloadForDomainTenant(organization, domainTenant, nil, 1) require.NoError(t, err) require.Equal(t, companyID, domainTenant.ID) require.Equal(t, int64(1003), payload.DomainID) require.Equal(t, "gpd@baroncs.co.kr", payload.Email) } func TestWorksmobileSyncServiceKeepsCompanyUsersInComparisonScope(t *testing.T) { rootID := "root-tenant" companyID := "company-tenant" userGroupID := "user-group-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } company := domain.Tenant{ ID: companyID, Name: "계열사", Type: domain.TenantTypeCompany, ParentID: &rootID, } userGroup := domain.Tenant{ ID: userGroupID, Name: "연동 조직", Type: domain.TenantTypeOrganization, ParentID: &companyID, } userRepo := &fakeWorksmobileUserRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company, userGroupID: userGroup}, list: []domain.Tenant{root, company, userGroup}}, userRepo, &fakeWorksmobileOutboxRepo{}, &fakeWorksmobileDirectoryClient{}, ) _, err := service.GetComparison(context.Background(), rootID, true) require.NoError(t, err) require.ElementsMatch(t, []string{companyID, userGroupID}, userRepo.requestedTenantIDs) } func TestWorksmobileSyncServiceSkipsArchivedUsersInComparison(t *testing.T) { rootID := "root-tenant" companyID := "company-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } company := domain.Tenant{ ID: companyID, Name: "계열사", Type: domain.TenantTypeCompany, ParentID: &rootID, } archived := domain.User{ ID: "archived-user", Email: "archived@samaneng.com", Name: "Archived", TenantID: &companyID, Status: domain.UserStatusArchived, } service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}}, &fakeWorksmobileUserRepo{byTenant: []domain.User{archived}}, &fakeWorksmobileOutboxRepo{}, &fakeWorksmobileDirectoryClient{users: []WorksmobileRemoteUser{{ ID: "works-archived", ExternalID: archived.ID, Email: archived.Email, }}}, ) comparison, err := service.GetComparison(context.Background(), rootID, true) require.NoError(t, err) require.Empty(t, comparison.Users) } func TestWorksmobileSyncServiceBackfillDryRunSkipsArchivedUsers(t *testing.T) { rootID := "root-tenant" companyID := "company-tenant" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } company := domain.Tenant{ ID: companyID, Name: "계열사", Type: domain.TenantTypeCompany, ParentID: &rootID, } active := domain.User{ ID: "active-user", Email: "active@samaneng.com", Name: "Active", TenantID: &companyID, Status: domain.UserStatusActive, } archived := domain.User{ ID: "archived-user", Email: "archived@samaneng.com", Name: "Archived", TenantID: &companyID, Status: domain.UserStatusArchived, } outboxRepo := &fakeWorksmobileOutboxRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, companyID: company}, list: []domain.Tenant{root, company}}, &fakeWorksmobileUserRepo{byTenant: []domain.User{active, archived}}, outboxRepo, nil, ) dryRun, err := service.EnqueueBackfillDryRun(context.Background(), rootID) require.NoError(t, err) require.Equal(t, 1, dryRun.UserCount) require.Len(t, outboxRepo.created, 1) require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"]) } func TestWorksmobileSyncServiceBackfillDryRunSkipsWorksmobileExcludedSubtree(t *testing.T) { rootID := "root-tenant" excludedCompanyID := "excluded-company" excludedOrgID := "excluded-org" includedCompanyID := "included-company" includedOrgID := "included-org" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } excludedCompany := domain.Tenant{ ID: excludedCompanyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Config: domain.JSONMap{"worksmobileExcluded": true}, } excludedOrg := domain.Tenant{ ID: excludedOrgID, Slug: "excluded-team", Name: "제외팀", Type: domain.TenantTypeOrganization, ParentID: &excludedCompanyID, } includedCompany := domain.Tenant{ ID: includedCompanyID, Slug: "halla", Name: "한라산업개발", Type: domain.TenantTypeCompany, ParentID: &rootID, } includedOrg := domain.Tenant{ ID: includedOrgID, Slug: "included-team", Name: "연동팀", Type: domain.TenantTypeOrganization, ParentID: &includedCompanyID, } excludedUser := domain.User{ ID: "excluded-user", Email: "excluded@samaneng.com", Name: "Excluded User", TenantID: &excludedOrgID, Status: domain.UserStatusActive, } includedUser := domain.User{ ID: "included-user", Email: "included@hallasanup.com", Name: "Included User", TenantID: &includedOrgID, Status: domain.UserStatusActive, } outboxRepo := &fakeWorksmobileOutboxRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{ tenants: map[string]domain.Tenant{ rootID: root, excludedCompanyID: excludedCompany, excludedOrgID: excludedOrg, includedCompanyID: includedCompany, includedOrgID: includedOrg, }, list: []domain.Tenant{root, excludedCompany, excludedOrg, includedCompany, includedOrg}, }, &fakeWorksmobileUserRepo{byTenant: []domain.User{excludedUser, includedUser}}, outboxRepo, nil, ) dryRun, err := service.EnqueueBackfillDryRun(context.Background(), rootID) require.NoError(t, err) require.Equal(t, 1, dryRun.OrgUnitCount) require.Equal(t, 1, dryRun.UserCount) require.Len(t, outboxRepo.created, 1) require.ElementsMatch(t, []string{includedOrgID}, outboxRepo.created[0].Payload["tenantIds"]) require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"]) } func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) { rootID := "root-tenant" excludedCompanyID := "excluded-company" excludedOrgID := "excluded-org" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } excludedCompany := domain.Tenant{ ID: excludedCompanyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Config: domain.JSONMap{"worksmobileExcluded": true}, } excludedOrg := domain.Tenant{ ID: excludedOrgID, Slug: "excluded-team", Name: "제외팀", Type: domain.TenantTypeOrganization, ParentID: &excludedCompanyID, } outboxRepo := &fakeWorksmobileOutboxRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{ tenants: map[string]domain.Tenant{rootID: root, excludedCompanyID: excludedCompany, excludedOrgID: excludedOrg}, list: []domain.Tenant{root, excludedCompany, excludedOrg}, }, &fakeWorksmobileUserRepo{}, outboxRepo, nil, ) item, err := service.EnqueueOrgUnitSync(context.Background(), rootID, excludedOrgID) require.Nil(t, item) require.ErrorContains(t, err, "excluded from Worksmobile sync") require.Empty(t, outboxRepo.created) } func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) { rootID := "root-tenant" excludedCompanyID := "excluded-company" excludedOrgID := "excluded-org" root := domain.Tenant{ ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", } excludedCompany := domain.Tenant{ ID: excludedCompanyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Config: domain.JSONMap{"worksmobileExcluded": true}, } excludedOrg := domain.Tenant{ ID: excludedOrgID, Slug: "excluded-team", Name: "제외팀", Type: domain.TenantTypeOrganization, ParentID: &excludedCompanyID, } user := domain.User{ ID: "excluded-user", Email: "excluded@samaneng.com", Name: "Excluded User", TenantID: &excludedOrgID, Status: domain.UserStatusActive, } outboxRepo := &fakeWorksmobileOutboxRepo{} service := NewWorksmobileSyncService( &fakeWorksmobileTenantService{ tenants: map[string]domain.Tenant{rootID: root, excludedCompanyID: excludedCompany, excludedOrgID: excludedOrg}, list: []domain.Tenant{root, excludedCompany, excludedOrg}, }, &fakeWorksmobileUserRepo{byID: map[string]domain.User{user.ID: user}}, outboxRepo, nil, ) require.NoError(t, service.EnqueueTenantUpsertIfInScope(context.Background(), excludedOrg)) require.NoError(t, service.EnqueueTenantDeleteIfInScope(context.Background(), excludedOrg)) require.NoError(t, service.EnqueueUserUpsertIfInScope(context.Background(), user)) item, err := service.EnqueueUserSync(context.Background(), rootID, user.ID, "") require.Nil(t, item) require.ErrorContains(t, err, "excluded from Worksmobile sync") require.Empty(t, outboxRepo.created) } func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) { tenantID := "tenant-leaf" user := domain.User{ ID: "user-manager", Email: "manager@samaneng.com", Name: "Manager User", TenantID: &tenantID, Status: domain.UserStatusActive, Metadata: domain.JSONMap{ "additionalAppointments": []any{ map[string]any{ "tenantId": tenantID, "isPrimary": true, "isManager": true, }, }, }, } remoteManager := false items := compareWorksmobileUsers( []domain.User{user}, []WorksmobileRemoteUser{{ ID: "works-user-manager", ExternalID: user.ID, Email: user.Email, DisplayName: user.Name, PrimaryOrgUnitID: "externalKey:" + tenantID, PrimaryOrgUnitIsManager: &remoteManager, }}, true, map[string]domain.Tenant{ tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization}, }, ) require.Len(t, items, 1) require.Equal(t, "needs_update", items[0].Status) } func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testing.T) { primaryTenantID := "tenant-company" secondaryTenantID := "tenant-gpdtdc-leaf" user := domain.User{ ID: "user-secondary-manager", Email: "secondary-manager@samaneng.com", Name: "Secondary Manager User", TenantID: &secondaryTenantID, Status: domain.UserStatusActive, Metadata: domain.JSONMap{ "additionalAppointments": []any{ map[string]any{ "tenantId": primaryTenantID, "isPrimary": true, }, map[string]any{ "tenantId": secondaryTenantID, "isPrimary": false, "isManager": true, }, }, }, } remotePrimaryManager := false remoteSecondaryManager := false items := compareWorksmobileUsers( []domain.User{user}, []WorksmobileRemoteUser{{ ID: "works-user-secondary-manager", ExternalID: user.ID, Email: user.Email, DisplayName: user.Name, PrimaryOrgUnitID: "externalKey:" + primaryTenantID, PrimaryOrgUnitIsManager: &remotePrimaryManager, OrgUnitManagers: map[string]*bool{ "externalKey:" + primaryTenantID: &remotePrimaryManager, "externalKey:" + secondaryTenantID: &remoteSecondaryManager, }, }}, true, map[string]domain.Tenant{ primaryTenantID: {ID: primaryTenantID, Name: "Company", Type: domain.TenantTypeCompany}, secondaryTenantID: {ID: secondaryTenantID, Name: "GPDTDC Leaf", Type: domain.TenantTypeOrganization}, }, ) require.Len(t, items, 1) require.Equal(t, "needs_update", items[0].Status) } func TestCompareWorksmobileUsersMarksMissingSecondaryOrganizationNeedsUpdate(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") t.Setenv("GPDTDC_DOMAIN_ID", "1003") rootID := "tenant-root" primaryTenantID := "tenant-saman" gpdtdcID := "tenant-gpdtdc" secondaryTenantID := "tenant-gpdtdc-leaf" user := domain.User{ ID: "user-secondary-org", Email: "secondary-org@samaneng.com", Name: "Secondary Org User", TenantID: &secondaryTenantID, Status: domain.UserStatusActive, Metadata: domain.JSONMap{ "additionalAppointments": []any{ map[string]any{ "tenantId": primaryTenantID, "isPrimary": true, }, map[string]any{ "tenantId": secondaryTenantID, "isPrimary": false, }, }, }, } remotePrimaryManager := false items := compareWorksmobileUsers( []domain.User{user}, []WorksmobileRemoteUser{{ ID: "works-user-secondary-org", ExternalID: user.ID, Email: user.Email, DisplayName: user.Name, Organizations: []WorksmobileUserOrganization{ { DomainID: 1001, Email: user.Email, Primary: true, OrgUnits: []WorksmobileUserOrgUnit{ { OrgUnitID: "externalKey:" + primaryTenantID, Primary: true, }, }, }, }, PrimaryOrgUnitID: "externalKey:" + primaryTenantID, PrimaryOrgUnitIsManager: &remotePrimaryManager, }}, true, map[string]domain.Tenant{ rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup}, primaryTenantID: {ID: primaryTenantID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}}, gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}, secondaryTenantID: {ID: secondaryTenantID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID}, }, ) require.Len(t, items, 1) require.Equal(t, "needs_update", items[0].Status) } func TestCompareWorksmobileUsersMarksPhoneAndEmployeeNumberChangesNeedsUpdate(t *testing.T) { tenantID := "tenant-saman" user := domain.User{ ID: "user-phone-employee", Email: "phone-employee@samaneng.com", Name: "Phone Employee User", Phone: "010-1234-5678", TenantID: &tenantID, Status: domain.UserStatusActive, Metadata: domain.JSONMap{ "employeeNumber": "EMP001", }, } items := compareWorksmobileUsers( []domain.User{user}, []WorksmobileRemoteUser{{ ID: "works-user-phone-employee", ExternalID: user.ID, Email: user.Email, DisplayName: user.Name, CellPhone: "+821099998888", EmployeeNumber: "EMP999", }}, true, map[string]domain.Tenant{ tenantID: {ID: tenantID, Name: "삼안", Type: domain.TenantTypeCompany}, }, ) require.Len(t, items, 1) require.Equal(t, "needs_update", items[0].Status) } type fakeWorksmobileTenantService struct { tenants map[string]domain.Tenant list []domain.Tenant } func (f *fakeWorksmobileTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) { return nil, nil } func (f *fakeWorksmobileTenantService) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) { return nil, nil } func (f *fakeWorksmobileTenantService) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) { return nil, nil } func (f *fakeWorksmobileTenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { return nil, nil } func (f *fakeWorksmobileTenantService) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) { tenant := f.tenants[id] return &tenant, nil } func (f *fakeWorksmobileTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { return f.list, int64(len(f.list)), nil } func (f *fakeWorksmobileTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { return nil, nil } func (f *fakeWorksmobileTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { return nil, nil } func (f *fakeWorksmobileTenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) { return false, nil } func (f *fakeWorksmobileTenantService) ApproveTenant(ctx context.Context, id string) error { return nil } func (f *fakeWorksmobileTenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { return nil, nil } func (f *fakeWorksmobileTenantService) SetKetoService(keto KetoService) {} func (f *fakeWorksmobileTenantService) DeleteTenantsBulk(ctx context.Context, ids []string) error { return nil } type fakeWorksmobileUserRepo struct { byID map[string]domain.User byTenant []domain.User requestedTenantIDs []string } func (f *fakeWorksmobileUserRepo) Create(ctx context.Context, user *domain.User) error { return nil } func (f *fakeWorksmobileUserRepo) Update(ctx context.Context, user *domain.User) error { return nil } func (f *fakeWorksmobileUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) { return nil, nil } func (f *fakeWorksmobileUserRepo) FindByID(ctx context.Context, id string) (*domain.User, error) { user := f.byID[id] return &user, nil } func (f *fakeWorksmobileUserRepo) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) { return nil, nil } func (f *fakeWorksmobileUserRepo) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) { return nil, nil } func (f *fakeWorksmobileUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) { return nil, 0, nil } func (f *fakeWorksmobileUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) { return 0, nil } func (f *fakeWorksmobileUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) { return nil, nil } func (f *fakeWorksmobileUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) { return nil, nil } func (f *fakeWorksmobileUserRepo) FindByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.User, error) { f.requestedTenantIDs = append([]string(nil), tenantIDs...) if len(tenantIDs) == 0 { return nil, nil } allowed := make(map[string]bool, len(tenantIDs)) for _, tenantID := range tenantIDs { allowed[tenantID] = true } users := make([]domain.User, 0, len(f.byTenant)) for _, user := range f.byTenant { if user.TenantID == nil { continue } if allowed[*user.TenantID] { users = append(users, user) } } return users, nil } func (f *fakeWorksmobileUserRepo) FindByCompanyCodes(ctx context.Context, codes []string) ([]domain.User, error) { return nil, nil } func (f *fakeWorksmobileUserRepo) Delete(ctx context.Context, id string) error { return nil } func (f *fakeWorksmobileUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error { return nil } func (f *fakeWorksmobileUserRepo) GetUserLoginIDs(ctx context.Context, userID string) ([]domain.UserLoginID, error) { return nil, nil } func (f *fakeWorksmobileUserRepo) IsLoginIDTaken(ctx context.Context, loginID string) (bool, error) { return false, nil } func (f *fakeWorksmobileUserRepo) FindTenantIDByLoginID(ctx context.Context, loginID string) (string, error) { return "", nil } func (f *fakeWorksmobileUserRepo) DB() *gorm.DB { return nil }