package service import ( "baron-sso-backend/internal/domain" "context" "testing" "github.com/stretchr/testify/require" "gorm.io/gorm" ) func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(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@samaneng.com", 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 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 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 TestWorksmobileSyncServiceTreatsHanmacFamilyChildCompaniesAsDomainRoots(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") t.Setenv("HANMAC_DOMAIN_ID", "1002") t.Setenv("GPDTDC_DOMAIN_ID", "1003") 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", }, } 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"]) } 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...) return f.byTenant, 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 }