package user_migration import ( "fmt" "testing" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" ) type fakeClient struct { listPages map[int][]RemoteUserSnapshot getUserResp *RemoteUserSnapshot getUserByID map[int]*RemoteUserSnapshot hasCopyResult bool hasCopyByID map[int]bool convertCalls []int convertReqs map[int]*ConvertRemoteUserRequest } func (f *fakeClient) ListUsers(page, pageSize int) (*ListRemoteUsersResponse, error) { users := f.listPages[page] return &ListRemoteUsersResponse{ Success: true, Data: users, Total: len(users), }, nil } func (f *fakeClient) GetUser(userId int) (*RemoteUserSnapshot, error) { if snapshot, ok := f.getUserByID[userId]; ok { return snapshot, nil } if f.getUserResp != nil { return f.getUserResp, nil } return nil, fmt.Errorf("user %d not found", userId) } func (f *fakeClient) ConvertToSynced(userId int, req *ConvertRemoteUserRequest) error { f.convertCalls = append(f.convertCalls, userId) if f.convertReqs == nil { f.convertReqs = make(map[int]*ConvertRemoteUserRequest) } if req != nil { copyReq := *req f.convertReqs[userId] = ©Req if snapshot, ok := f.getUserByID[userId]; ok && snapshot != nil { snapshot.Source = common.UserSourceSynced snapshot.RemoteUserId = req.RemoteUserId snapshot.SyncedQuota = req.SyncedQuota } if f.getUserResp != nil && f.getUserResp.Id == userId { f.getUserResp.Source = common.UserSourceSynced f.getUserResp.RemoteUserId = req.RemoteUserId f.getUserResp.SyncedQuota = req.SyncedQuota } } return nil } func (f *fakeClient) HasSyncedCopy(cnUserId int) (bool, error) { if value, ok := f.hasCopyByID[cnUserId]; ok { return value, nil } return f.hasCopyResult, nil } func setupMigrationServiceDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) sqlDB, err := db.DB() require.NoError(t, err) sqlDB.SetMaxOpenConns(1) require.NoError(t, db.AutoMigrate( &model.User{}, &model.CustomOAuthProvider{}, &model.UserOAuthBinding{}, &model.UserMigrationBatch{}, &model.UserMigrationItem{}, &model.MigrationQuotaGrant{}, )) return db } func TestScanBatch_NoConflict_MarksReady(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() batch := &model.UserMigrationBatch{Name: "wave-1", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} require.NoError(t, model.CreateUserMigrationBatch(batch)) client := &fakeClient{ listPages: map[int][]RemoteUserSnapshot{ 0: {{ Id: 10000001, Username: "ov-bob", Email: "bob@example.com", Quota: 456, AffCode: "A002", }}, }, } svc := &Service{client: client} require.NoError(t, svc.ScanBatch(batch.Id)) items, err := model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationStrategyCreateNew, items[0].ResolutionStrategy) require.Equal(t, model.UserMigrationItemStatusReady, items[0].Status) var savedBatch model.UserMigrationBatch require.NoError(t, db.First(&savedBatch, batch.Id).Error) require.Equal(t, model.UserMigrationBatchStatusReady, savedBatch.Status) } func TestScanBatch_EmailConflict_NoCNSyncedCopy_RecommendsMerge(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 1, Username: "cn-alice", Password: "hash", Email: "alice@example.com", AffCode: "C001", }).Error) batch := &model.UserMigrationBatch{Name: "wave-2", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} require.NoError(t, model.CreateUserMigrationBatch(batch)) client := &fakeClient{ listPages: map[int][]RemoteUserSnapshot{ 0: {{ Id: 10000001, Username: "ov-alice", Email: "alice@example.com", Quota: 123, AffCode: "A001", }}, }, hasCopyResult: false, } svc := &Service{client: client} require.NoError(t, svc.ScanBatch(batch.Id)) items, err := model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationStrategyMergeExisting, items[0].ResolutionStrategy) require.Equal(t, model.UserMigrationItemStatusReady, items[0].Status) require.Equal(t, 1, items[0].TargetUserId) } func TestScanBatch_EmailConflict_CNHasSyncedCopy_MarksHardConflict(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 456, Username: "cn-user456", Password: "hash", Email: "shared@example.com", AffCode: "C456", }).Error) batch := &model.UserMigrationBatch{Name: "wave-3", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} require.NoError(t, model.CreateUserMigrationBatch(batch)) client := &fakeClient{ listPages: map[int][]RemoteUserSnapshot{ 0: {{ Id: 10000003, Username: "ov-x", Email: "shared@example.com", Quota: 50, AffCode: "OVX", }}, }, hasCopyResult: true, } svc := &Service{client: client} require.NoError(t, svc.ScanBatch(batch.Id)) items, err := model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationItemStatusConflict, items[0].Status) require.Contains(t, items[0].ConflictFlags, "cn_already_synced_to_ov") } func TestScanBatch_GitHubId_SameUser_RecommendsMerge(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 11, Username: "cn-github", Password: "hash", AffCode: "CGH1", GitHubId: "gh-123", }).Error) batch := &model.UserMigrationBatch{Name: "wave-github-merge", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} require.NoError(t, model.CreateUserMigrationBatch(batch)) client := &fakeClient{ listPages: map[int][]RemoteUserSnapshot{ 0: {{ Id: 10000011, Username: "ov-github", GitHubId: "gh-123", Quota: 88, AffCode: "OVGH1", }}, }, hasCopyByID: map[int]bool{11: false}, } svc := &Service{client: client} require.NoError(t, svc.ScanBatch(batch.Id)) items, err := model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationStrategyMergeExisting, items[0].ResolutionStrategy) require.Equal(t, model.UserMigrationItemStatusReady, items[0].Status) require.Equal(t, 11, items[0].TargetUserId) require.Contains(t, items[0].ConflictFlags, "github_id") } func TestScanBatch_OAuthId_DifferentUsers_MultipleMergeTargets(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 21, Username: "cn-github-owner", Password: "hash", AffCode: "COA1", GitHubId: "gh-shared", }).Error) require.NoError(t, db.Create(&model.User{ Id: 22, Username: "cn-discord-owner", Password: "hash", AffCode: "COA2", DiscordId: "dc-shared", }).Error) batch := &model.UserMigrationBatch{Name: "wave-oauth-conflict", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} require.NoError(t, model.CreateUserMigrationBatch(batch)) client := &fakeClient{ listPages: map[int][]RemoteUserSnapshot{ 0: {{ Id: 10000012, Username: "ov-oauth-conflict", GitHubId: "gh-shared", DiscordId: "dc-shared", Quota: 66, AffCode: "OVOA1", }}, }, } svc := &Service{client: client} require.NoError(t, svc.ScanBatch(batch.Id)) items, err := model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationItemStatusConflict, items[0].Status) require.Contains(t, items[0].ConflictFlags, "github_id") require.Contains(t, items[0].ConflictFlags, "discord_id") require.Contains(t, items[0].ConflictFlags, "multiple_merge_targets") } func TestScanBatch_CustomOAuthBinding_MatchesExistingUser_RecommendsMerge(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.CustomOAuthProvider{ Id: 31, Name: "GitHub Enterprise", Slug: "gh-enterprise", Enabled: true, ClientId: "cid", AuthorizationEndpoint: "https://auth.example.com", TokenEndpoint: "https://token.example.com", UserInfoEndpoint: "https://userinfo.example.com", }).Error) require.NoError(t, db.Create(&model.User{ Id: 32, Username: "cn-custom-oauth", Password: "hash", AffCode: "CCO1", }).Error) require.NoError(t, db.Create(&model.UserOAuthBinding{ UserId: 32, ProviderId: 31, ProviderUserId: "provider-user-1", }).Error) batch := &model.UserMigrationBatch{Name: "wave-custom-oauth-merge", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} require.NoError(t, model.CreateUserMigrationBatch(batch)) client := &fakeClient{ listPages: map[int][]RemoteUserSnapshot{ 0: {{ Id: 10000013, Username: "ov-custom-oauth", Quota: 55, AffCode: "OVCO1", OAuthBindings: []RemoteOAuthBinding{{ ProviderSlug: "gh-enterprise", ProviderUserId: "provider-user-1", }}, }}, }, hasCopyByID: map[int]bool{32: false}, } svc := &Service{client: client} require.NoError(t, svc.ScanBatch(batch.Id)) items, err := model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationStrategyMergeExisting, items[0].ResolutionStrategy) require.Equal(t, model.UserMigrationItemStatusReady, items[0].Status) require.Equal(t, 32, items[0].TargetUserId) require.Contains(t, items[0].ConflictFlags, "oauth:gh-enterprise") } func TestScanBatch_CustomOAuthBinding_UnknownSlug_Ignored(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() batch := &model.UserMigrationBatch{Name: "wave-custom-oauth-unknown", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} require.NoError(t, model.CreateUserMigrationBatch(batch)) client := &fakeClient{ listPages: map[int][]RemoteUserSnapshot{ 0: {{ Id: 10000014, Username: "ov-unknown-oauth", Quota: 77, AffCode: "OVUK1", OAuthBindings: []RemoteOAuthBinding{{ ProviderSlug: "unknown-provider", ProviderUserId: "u-1", }}, }}, }, } svc := &Service{client: client} require.NoError(t, svc.ScanBatch(batch.Id)) items, err := model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationStrategyCreateNew, items[0].ResolutionStrategy) require.Equal(t, model.UserMigrationItemStatusReady, items[0].Status) require.NotContains(t, items[0].ConflictFlags, "oauth:") } func TestScanBatch_EmailAndOAuth_SameUser_RecommendsMerge(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 41, Username: "cn-same-user", Password: "hash", Email: "same@example.com", AffCode: "CSU1", GitHubId: "gh-same", }).Error) batch := &model.UserMigrationBatch{Name: "wave-email-oauth-same", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} require.NoError(t, model.CreateUserMigrationBatch(batch)) client := &fakeClient{ listPages: map[int][]RemoteUserSnapshot{ 0: {{ Id: 10000015, Username: "ov-same-user", Email: "same@example.com", GitHubId: "gh-same", Quota: 91, AffCode: "OVSU1", }}, }, hasCopyByID: map[int]bool{41: false}, } svc := &Service{client: client} require.NoError(t, svc.ScanBatch(batch.Id)) items, err := model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationStrategyMergeExisting, items[0].ResolutionStrategy) require.Equal(t, model.UserMigrationItemStatusReady, items[0].Status) require.Equal(t, 41, items[0].TargetUserId) require.Contains(t, items[0].ConflictFlags, "email") require.Contains(t, items[0].ConflictFlags, "github_id") require.NotContains(t, items[0].ConflictFlags, "multiple_merge_targets") } func TestScanBatch_EmailAndOAuth_DifferentUsers_Conflicts(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 51, Username: "cn-email-owner", Password: "hash", Email: "combo@example.com", AffCode: "CEO1", }).Error) require.NoError(t, db.Create(&model.User{ Id: 52, Username: "cn-oauth-owner", Password: "hash", AffCode: "COO1", GitHubId: "gh-combo", }).Error) batch := &model.UserMigrationBatch{Name: "wave-email-oauth-diff", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} require.NoError(t, model.CreateUserMigrationBatch(batch)) client := &fakeClient{ listPages: map[int][]RemoteUserSnapshot{ 0: {{ Id: 10000016, Username: "ov-combo-user", Email: "combo@example.com", GitHubId: "gh-combo", Quota: 92, AffCode: "OVCO2", }}, }, } svc := &Service{client: client} require.NoError(t, svc.ScanBatch(batch.Id)) items, err := model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationItemStatusConflict, items[0].Status) require.Contains(t, items[0].ConflictFlags, "email") require.Contains(t, items[0].ConflictFlags, "github_id") require.Contains(t, items[0].ConflictFlags, "multiple_merge_targets") } func TestScanBatch_UsernameConflict_OverridesMergeCandidate(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 61, Username: "same-name", Password: "hash", AffCode: "CSN1", }).Error) require.NoError(t, db.Create(&model.User{ Id: 62, Username: "other-user", Password: "hash", Email: "mergeable@example.com", AffCode: "CME1", }).Error) batch := &model.UserMigrationBatch{Name: "wave-username-overrides", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} require.NoError(t, model.CreateUserMigrationBatch(batch)) client := &fakeClient{ listPages: map[int][]RemoteUserSnapshot{ 0: {{ Id: 10000017, Username: "same-name", Email: "mergeable@example.com", Quota: 93, AffCode: "OVUN1", }}, }, hasCopyByID: map[int]bool{62: false}, } svc := &Service{client: client} require.NoError(t, svc.ScanBatch(batch.Id)) items, err := model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationItemStatusConflict, items[0].Status) require.Contains(t, items[0].ConflictFlags, "username") } func TestScanBatch_Paginated(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() batch := &model.UserMigrationBatch{Name: "wave-big", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} require.NoError(t, model.CreateUserMigrationBatch(batch)) page0 := make([]RemoteUserSnapshot, scanPageSize) for i := range page0 { page0[i] = RemoteUserSnapshot{ Id: 10000001 + i, Username: fmt.Sprintf("u%d", i), AffCode: fmt.Sprintf("A%d", i), } } page1 := []RemoteUserSnapshot{{ Id: 10000001 + scanPageSize, Username: "last", AffCode: "LAST", }} client := &fakeClient{ listPages: map[int][]RemoteUserSnapshot{ 0: page0, 1: page1, }, } svc := &Service{client: client} require.NoError(t, svc.ScanBatch(batch.Id)) items, err := model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) require.Len(t, items, scanPageSize+1) } func TestScanBatch_Rescan_PreservesHumanStrategy(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() batch := &model.UserMigrationBatch{Name: "wave-rescan", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} require.NoError(t, model.CreateUserMigrationBatch(batch)) client := &fakeClient{ listPages: map[int][]RemoteUserSnapshot{ 0: {{ Id: 10000001, Username: "ov-dave", Email: "dave@example.com", Quota: 100, AffCode: "OVD1", }}, }, hasCopyResult: false, } svc := &Service{client: client} require.NoError(t, svc.ScanBatch(batch.Id)) require.NoError(t, db.Model(&model.UserMigrationItem{}).Where("batch_id = ?", batch.Id). Update("resolution_strategy", model.UserMigrationStrategySkip).Error) require.NoError(t, svc.ScanBatch(batch.Id)) items, err := model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationStrategySkip, items[0].ResolutionStrategy) } func TestMigrationFlow_GrayScenarios_EndToEnd(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 1, Username: "cn-merge", Password: "hash", Email: "merge@example.com", AffCode: "CNM1", Quota: 500, }).Error) require.NoError(t, db.Create(&model.User{ Id: 2, Username: "taken-name", Password: "hash", Email: "taken@example.com", AffCode: "CNT2", Quota: 50, }).Error) require.NoError(t, db.Create(&model.User{ Id: 3, Username: "cn-hard", Password: "hash", Email: "synced@example.com", AffCode: "CNH3", Quota: 300, }).Error) require.NoError(t, db.Create(&model.User{ Id: 4, Username: "cn-soft", Password: "hash", Email: "soft-owner@example.com", AffCode: "SOFT1", Quota: 80, }).Error) batch := &model.UserMigrationBatch{ Name: "gray-scenarios", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1, } require.NoError(t, model.CreateUserMigrationBatch(batch)) remoteUsers := []RemoteUserSnapshot{ { Id: 10000001, Username: "ov-create", Email: "create@example.com", Quota: 100, AffCode: "OVC1", }, { Id: 10000002, Username: "ov-merge", Email: "merge@example.com", Quota: 200, AffCode: "OVM2", }, { Id: 10000003, Username: "ov-hard", Email: "synced@example.com", Quota: 300, AffCode: "OVH3", }, { Id: 10000004, Username: "taken-name", Email: "username-conflict@example.com", Quota: 120, AffCode: "OVU4", }, { Id: 10000005, Username: "ov-soft", Email: "soft@example.com", Quota: 150, AffCode: "SOFT1", }, } getUserByID := make(map[int]*RemoteUserSnapshot, len(remoteUsers)) for i := range remoteUsers { snapshot := remoteUsers[i] getUserByID[snapshot.Id] = &snapshot } client := &fakeClient{ listPages: map[int][]RemoteUserSnapshot{ 0: remoteUsers, }, getUserByID: getUserByID, hasCopyByID: map[int]bool{ 1: false, 3: true, }, } svc := &Service{client: client} require.NoError(t, svc.ScanBatch(batch.Id)) items, err := model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) require.Len(t, items, 5) bySourceUserID := make(map[int]*model.UserMigrationItem, len(items)) for _, item := range items { bySourceUserID[item.SourceUserId] = item } require.Equal(t, model.UserMigrationStrategyCreateNew, bySourceUserID[10000001].ResolutionStrategy) require.Equal(t, model.UserMigrationItemStatusReady, bySourceUserID[10000001].Status) require.Equal(t, model.UserMigrationStrategyMergeExisting, bySourceUserID[10000002].ResolutionStrategy) require.Equal(t, 1, bySourceUserID[10000002].TargetUserId) require.Equal(t, model.UserMigrationItemStatusReady, bySourceUserID[10000002].Status) require.Equal(t, model.UserMigrationItemStatusConflict, bySourceUserID[10000003].Status) require.Contains(t, bySourceUserID[10000003].ConflictFlags, "cn_already_synced_to_ov") require.Equal(t, model.UserMigrationItemStatusConflict, bySourceUserID[10000004].Status) require.Contains(t, bySourceUserID[10000004].ConflictFlags, "username") require.Equal(t, model.UserMigrationStrategyCreateNew, bySourceUserID[10000005].ResolutionStrategy) require.Equal(t, model.UserMigrationItemStatusReady, bySourceUserID[10000005].Status) require.Contains(t, bySourceUserID[10000005].ConflictFlags, "aff_code") require.NoError(t, db.Model(&model.UserMigrationItem{}). Where("id IN ?", []int{bySourceUserID[10000003].Id, bySourceUserID[10000004].Id}). Updates(map[string]any{ "resolution_strategy": model.UserMigrationStrategySkip, "status": model.UserMigrationItemStatusReady, "error_message": "", }).Error) require.NoError(t, model.RefreshUserMigrationBatchStats(batch.Id)) var readyBatch model.UserMigrationBatch require.NoError(t, db.First(&readyBatch, batch.Id).Error) require.Equal(t, model.UserMigrationBatchStatusReady, readyBatch.Status) locked, err := model.TryLockBatchForExecution(batch.Id) require.NoError(t, err) require.True(t, locked) require.NoError(t, svc.ExecuteBatch(batch.Id)) items, err = model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) bySourceUserID = make(map[int]*model.UserMigrationItem, len(items)) for _, item := range items { bySourceUserID[item.SourceUserId] = item } require.Equal(t, model.UserMigrationItemStatusMigrated, bySourceUserID[10000001].Status) require.Equal(t, model.UserMigrationItemStatusMigrated, bySourceUserID[10000002].Status) require.Equal(t, model.UserMigrationItemStatusSkipped, bySourceUserID[10000003].Status) require.Equal(t, model.UserMigrationItemStatusSkipped, bySourceUserID[10000004].Status) require.Equal(t, model.UserMigrationItemStatusMigrated, bySourceUserID[10000005].Status) var createdUser model.User require.NoError(t, db.Where("username = ?", "ov-create").First(&createdUser).Error) require.Equal(t, 100, createdUser.Quota) var mergedUser model.User require.NoError(t, db.First(&mergedUser, 1).Error) require.Equal(t, 700, mergedUser.Quota) var softUser model.User require.NoError(t, db.Where("username = ?", "ov-soft").First(&softUser).Error) require.Equal(t, 150, softUser.Quota) require.NotEqual(t, "SOFT1", softUser.AffCode) require.Equal(t, common.UserSourceSynced, client.getUserByID[10000001].Source) require.Equal(t, createdUser.Id, client.getUserByID[10000001].RemoteUserId) require.Equal(t, 100, client.getUserByID[10000001].SyncedQuota) require.Equal(t, common.UserSourceSynced, client.getUserByID[10000002].Source) require.Equal(t, 1, client.getUserByID[10000002].RemoteUserId) require.Equal(t, 700, client.getUserByID[10000002].SyncedQuota) require.NotEqual(t, common.UserSourceSynced, client.getUserByID[10000003].Source) require.NotEqual(t, common.UserSourceSynced, client.getUserByID[10000004].Source) require.Equal(t, common.UserSourceSynced, client.getUserByID[10000005].Source) require.Equal(t, softUser.Id, client.getUserByID[10000005].RemoteUserId) require.Equal(t, 150, client.getUserByID[10000005].SyncedQuota) for _, sourceUserID := range []int{10000001, 10000002, 10000005} { result, verifyErr := svc.VerifyItem(bySourceUserID[sourceUserID]) require.NoError(t, verifyErr) require.True(t, result.TargetUserExists) require.True(t, result.RemoteConverted) require.True(t, result.QuotaMatched) } require.NoError(t, db.Model(&model.UserMigrationBatch{}).Where("id = ?", batch.Id). Update("status", model.UserMigrationBatchStatusReady).Error) require.NoError(t, db.Model(&model.UserMigrationItem{}). Where("id IN ?", []int{ bySourceUserID[10000001].Id, bySourceUserID[10000002].Id, bySourceUserID[10000005].Id, }). Update("status", model.UserMigrationItemStatusReady).Error) locked, err = model.TryLockBatchForExecution(batch.Id) require.NoError(t, err) require.True(t, locked) require.NoError(t, svc.ExecuteBatch(batch.Id)) require.Len(t, client.convertCalls, 3) require.NoError(t, db.First(&mergedUser, 1).Error) require.Equal(t, 700, mergedUser.Quota) } func TestExecuteBatch_CreateNew_CreatesUserAndConvertsRemote(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() batch := &model.UserMigrationBatch{ Name: "wave-exec", Status: model.UserMigrationBatchStatusReady, } batch.SourceRegion = "ov" batch.TargetRegion = "cn" batch.OperatorId = 1 require.NoError(t, model.CreateUserMigrationBatch(batch)) item := &model.UserMigrationItem{ BatchId: batch.Id, SourceUserId: 10000001, SourceUsername: "ov-alice", SourceEmail: "alice@example.com", SourceQuota: 300, ResolutionStrategy: model.UserMigrationStrategyCreateNew, Status: model.UserMigrationItemStatusReady, MigrationToken: "batch-1-user-10000001", } require.NoError(t, model.UpsertUserMigrationItem(item)) client := &fakeClient{ getUserResp: &RemoteUserSnapshot{ Id: 10000001, Username: "ov-alice", Password: "hashed-password", Email: "alice@example.com", DisplayName: "Alice", Status: 1, Role: 1, Group: "default", Quota: 300, AffCode: "OVA1", }, } svc := &Service{client: client} locked, err := model.TryLockBatchForExecution(batch.Id) require.NoError(t, err) require.True(t, locked) require.NoError(t, svc.ExecuteBatch(batch.Id)) var cnUser model.User require.NoError(t, db.Where("username = ?", "ov-alice").First(&cnUser).Error) require.Equal(t, 300, cnUser.Quota) require.Less(t, cnUser.Id, 10000000) require.Len(t, client.convertCalls, 1) require.Equal(t, 10000001, client.convertCalls[0]) } func TestExecuteBatch_CreateNew_IdempotentOnRetry(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() batch := &model.UserMigrationBatch{ Name: "wave-retry", Status: model.UserMigrationBatchStatusReady, } batch.SourceRegion = "ov" batch.TargetRegion = "cn" batch.OperatorId = 1 require.NoError(t, model.CreateUserMigrationBatch(batch)) item := &model.UserMigrationItem{ BatchId: batch.Id, SourceUserId: 10000002, SourceUsername: "ov-carol", SourceEmail: "carol@example.com", SourceQuota: 150, ResolutionStrategy: model.UserMigrationStrategyCreateNew, Status: model.UserMigrationItemStatusReady, MigrationToken: "batch-1-user-10000002", } require.NoError(t, model.UpsertUserMigrationItem(item)) snapshot := &RemoteUserSnapshot{ Id: 10000002, Username: "ov-carol", Password: "hash", Email: "carol@example.com", Quota: 150, AffCode: "OVC1", } client := &fakeClient{getUserResp: snapshot} svc := &Service{client: client} locked, err := model.TryLockBatchForExecution(batch.Id) require.NoError(t, err) require.True(t, locked) require.NoError(t, svc.ExecuteBatch(batch.Id)) require.NoError(t, db.Model(&model.UserMigrationBatch{}).Where("id = ?", batch.Id). Update("status", model.UserMigrationBatchStatusFailed).Error) require.NoError(t, db.Model(&model.UserMigrationItem{}).Where("batch_id = ?", batch.Id). Update("status", model.UserMigrationItemStatusReady).Error) locked, err = model.TryLockBatchForExecution(batch.Id) require.NoError(t, err) require.True(t, locked) require.NoError(t, svc.ExecuteBatch(batch.Id)) var count int64 require.NoError(t, db.Model(&model.User{}).Where("username = ?", "ov-carol").Count(&count).Error) require.EqualValues(t, 1, count) require.Len(t, client.convertCalls, 1) } func TestExecuteBatch_CreateNew_ExecutionTimeDriftFails(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 7, Username: "cn-alice", Password: "hash", Email: "alice@example.com", AffCode: "C007", }).Error) batch := &model.UserMigrationBatch{ Name: "wave-create-drift", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1, Status: model.UserMigrationBatchStatusReady, } require.NoError(t, model.CreateUserMigrationBatch(batch)) item := &model.UserMigrationItem{ BatchId: batch.Id, SourceUserId: 10000020, SourceUsername: "ov-drift", SourceEmail: "old@example.com", SourceQuota: 120, ResolutionStrategy: model.UserMigrationStrategyCreateNew, Status: model.UserMigrationItemStatusReady, MigrationToken: "batch-1-user-10000020", } require.NoError(t, model.UpsertUserMigrationItem(item)) client := &fakeClient{ getUserResp: &RemoteUserSnapshot{ Id: 10000020, Username: "ov-drift", Password: "hash", Email: "alice@example.com", Quota: 120, AffCode: "OV20", }, hasCopyByID: map[int]bool{7: false}, } svc := &Service{client: client} locked, err := model.TryLockBatchForExecution(batch.Id) require.NoError(t, err) require.True(t, locked) require.NoError(t, svc.ExecuteBatch(batch.Id)) require.Len(t, client.convertCalls, 0) var count int64 require.NoError(t, db.Model(&model.User{}).Where("username = ?", "ov-drift").Count(&count).Error) require.EqualValues(t, 0, count) items, err := model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationItemStatusFailed, items[0].Status) require.Contains(t, items[0].ErrorMessage, "identity drift") } func TestExecuteBatch_MergeExisting_IdempotentQuota(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 1, Username: "cn-bob", Password: "hash", Email: "bob@example.com", AffCode: "CNB1", Quota: 500, }).Error) batch := &model.UserMigrationBatch{ Name: "wave-merge", Status: model.UserMigrationBatchStatusReady, } batch.SourceRegion = "ov" batch.TargetRegion = "cn" batch.OperatorId = 1 require.NoError(t, model.CreateUserMigrationBatch(batch)) item := &model.UserMigrationItem{ BatchId: batch.Id, SourceUserId: 10000002, TargetUserId: 1, SourceUsername: "ov-bob", SourceEmail: "bob@example.com", SourceQuota: 200, ResolutionStrategy: model.UserMigrationStrategyMergeExisting, Status: model.UserMigrationItemStatusReady, MigrationToken: "batch-1-user-10000002", } require.NoError(t, model.UpsertUserMigrationItem(item)) client := &fakeClient{ getUserResp: &RemoteUserSnapshot{ Id: 10000002, Username: "ov-bob", Password: "hashed", Email: "bob@example.com", Quota: 200, }, } svc := &Service{client: client} for i := 0; i < 2; i++ { require.NoError(t, db.Model(&model.UserMigrationBatch{}).Where("id = ?", batch.Id). Update("status", model.UserMigrationBatchStatusReady).Error) require.NoError(t, db.Model(&model.UserMigrationItem{}).Where("batch_id = ?", batch.Id). Update("status", model.UserMigrationItemStatusReady).Error) locked, err := model.TryLockBatchForExecution(batch.Id) require.NoError(t, err) require.True(t, locked) require.NoError(t, svc.ExecuteBatch(batch.Id)) } var saved model.User require.NoError(t, db.First(&saved, 1).Error) require.Equal(t, 700, saved.Quota) require.Len(t, client.convertCalls, 1) } func TestExecuteBatch_MergeExisting_ExecutionTimeDriftFails(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 1, Username: "cn-bob", Password: "hash", Email: "bob@example.com", AffCode: "CNB1", Quota: 500, }).Error) batch := &model.UserMigrationBatch{ Name: "wave-merge-drift", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1, Status: model.UserMigrationBatchStatusReady, } require.NoError(t, model.CreateUserMigrationBatch(batch)) item := &model.UserMigrationItem{ BatchId: batch.Id, SourceUserId: 10000021, TargetUserId: 1, SourceUsername: "ov-bob", SourceEmail: "bob@example.com", SourceQuota: 200, ResolutionStrategy: model.UserMigrationStrategyMergeExisting, Status: model.UserMigrationItemStatusReady, MigrationToken: "batch-1-user-10000021", } require.NoError(t, model.UpsertUserMigrationItem(item)) client := &fakeClient{ getUserResp: &RemoteUserSnapshot{ Id: 10000021, Username: "ov-bob", Password: "hash", Email: "changed@example.com", Quota: 200, AffCode: "OV21", }, } svc := &Service{client: client} locked, err := model.TryLockBatchForExecution(batch.Id) require.NoError(t, err) require.True(t, locked) require.NoError(t, svc.ExecuteBatch(batch.Id)) require.Len(t, client.convertCalls, 0) var cnUser model.User require.NoError(t, db.First(&cnUser, 1).Error) require.Equal(t, 500, cnUser.Quota) items, err := model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationItemStatusFailed, items[0].Status) require.Contains(t, items[0].ErrorMessage, "merge target drift") } func TestExecuteBatch_SkipItem_DoesNotConvert(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() batch := &model.UserMigrationBatch{ Name: "skip-test", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1, Status: model.UserMigrationBatchStatusReady, } require.NoError(t, model.CreateUserMigrationBatch(batch)) require.NoError(t, model.UpsertUserMigrationItem(&model.UserMigrationItem{ BatchId: batch.Id, SourceUserId: 10000099, SourceUsername: "skip-user", SourceQuota: 50, ResolutionStrategy: model.UserMigrationStrategySkip, Status: model.UserMigrationItemStatusReady, MigrationToken: "batch-1-user-10000099", })) client := &fakeClient{} svc := &Service{client: client} locked, err := model.TryLockBatchForExecution(batch.Id) require.NoError(t, err) require.True(t, locked) require.NoError(t, svc.ExecuteBatch(batch.Id)) require.Len(t, client.convertCalls, 0) items, err := model.ListUserMigrationItemsByBatch(batch.Id) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationItemStatusSkipped, items[0].Status) } func TestVerifyItem_CorrectlyChecksRemoteConverted(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 789, Username: "cn-migrated", Password: "hash", AffCode: "CM1", Quota: 300, }).Error) item := &model.UserMigrationItem{ BatchId: 1, SourceUserId: 10000001, TargetUserId: 789, SourceQuota: 300, Status: model.UserMigrationItemStatusMigrated, } client := &fakeClient{ getUserResp: &RemoteUserSnapshot{ Id: 10000001, Source: common.UserSourceSynced, RemoteUserId: 789, SyncedQuota: 300, }, } svc := &Service{client: client} result, err := svc.VerifyItem(item) require.NoError(t, err) require.True(t, result.TargetUserExists) require.True(t, result.RemoteConverted) require.True(t, result.QuotaMatched) } func TestVerifyItem_QuotaMismatchDetected(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 790, Username: "cn-mismatch", Password: "hash", AffCode: "CM2", Quota: 300, }).Error) item := &model.UserMigrationItem{ BatchId: 1, SourceUserId: 10000002, TargetUserId: 790, SourceQuota: 300, Status: model.UserMigrationItemStatusMigrated, } client := &fakeClient{ getUserResp: &RemoteUserSnapshot{ Id: 10000002, Source: common.UserSourceSynced, RemoteUserId: 790, SyncedQuota: 250, }, } svc := &Service{client: client} result, err := svc.VerifyItem(item) require.NoError(t, err) require.True(t, result.TargetUserExists) require.True(t, result.RemoteConverted) require.False(t, result.QuotaMatched) } func TestVerifyItem_AllowsPostMigrationQuotaConsumption(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 791, Username: "cn-consumed", Password: "hash", AffCode: "CM3", Quota: 250, }).Error) item := &model.UserMigrationItem{ BatchId: 1, SourceUserId: 10000003, TargetUserId: 791, SourceQuota: 200, Status: model.UserMigrationItemStatusMigrated, } client := &fakeClient{ getUserResp: &RemoteUserSnapshot{ Id: 10000003, Source: common.UserSourceSynced, RemoteUserId: 791, SyncedQuota: 200, }, } svc := &Service{client: client} result, err := svc.VerifyItem(item) require.NoError(t, err) require.True(t, result.TargetUserExists) require.True(t, result.RemoteConverted) require.True(t, result.QuotaMatched) } func TestExecuteBatch_CreateNewFastPathRevalidatesAndMarksMigrated(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 900, Username: "cn-existing", Password: "hash", AffCode: "CN900", Quota: 300, }).Error) require.NoError(t, db.Create(&model.UserMigrationItem{ Id: 1, BatchId: 10, SourceUserId: 10000010, TargetUserId: 900, SourceUsername: "ov-existing", SourceQuota: 300, ResolutionStrategy: model.UserMigrationStrategyCreateNew, Status: model.UserMigrationItemStatusFailed, MigrationToken: "token-fast-path", }).Error) require.NoError(t, db.Create(&model.UserMigrationBatch{ Id: 10, Name: "batch-fast-path", SourceRegion: "ov", TargetRegion: "cn", Status: model.UserMigrationBatchStatusFailed, OperatorId: 1, }).Error) client := &fakeClient{ getUserByID: map[int]*RemoteUserSnapshot{ 10000010: { Id: 10000010, Username: "ov-existing", Quota: 300, Source: common.UserSourceLocal, OAuthBindings: nil, }, }, } svc := &Service{client: client} require.NoError(t, svc.ExecuteBatch(10)) require.Len(t, client.convertCalls, 1) require.Equal(t, 10000010, client.convertCalls[0]) items, err := model.ListUserMigrationItemsByBatch(10) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationItemStatusMigrated, items[0].Status) } func TestExecuteBatch_CreateNewFastPath_DriftFails(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 901, Username: "cn-existing-drift", Password: "hash", AffCode: "CND901", Quota: 320, }).Error) require.NoError(t, db.Create(&model.UserMigrationItem{ Id: 2, BatchId: 11, SourceUserId: 10000018, TargetUserId: 901, SourceUsername: "ov-fast-drift", SourceQuota: 320, ResolutionStrategy: model.UserMigrationStrategyCreateNew, Status: model.UserMigrationItemStatusFailed, MigrationToken: "token-fast-path-drift", }).Error) require.NoError(t, db.Create(&model.UserMigrationBatch{ Id: 11, Name: "batch-fast-path-drift", SourceRegion: "ov", TargetRegion: "cn", Status: model.UserMigrationBatchStatusFailed, OperatorId: 1, }).Error) client := &fakeClient{ getUserByID: map[int]*RemoteUserSnapshot{ 10000018: { Id: 10000018, Username: "ov-fast-drift", Quota: 320, Source: common.UserSourceSynced, RemoteUserId: 999, SyncedQuota: 320, }, }, } svc := &Service{client: client} require.NoError(t, svc.ExecuteBatch(11)) require.Empty(t, client.convertCalls) items, err := model.ListUserMigrationItemsByBatch(11) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationItemStatusFailed, items[0].Status) require.Contains(t, items[0].ErrorMessage, "already synced") } func TestExecuteBatch_CreateNewFastPath_AlreadySyncedToSameTargetSucceeds(t *testing.T) { db := setupMigrationServiceDB(t) orig := model.DB model.DB = db defer func() { model.DB = orig }() require.NoError(t, db.Create(&model.User{ Id: 902, Username: "cn-existing-same", Password: "hash", AffCode: "CND902", Quota: 321, }).Error) require.NoError(t, db.Create(&model.UserMigrationItem{ Id: 3, BatchId: 12, SourceUserId: 10000019, TargetUserId: 902, SourceUsername: "ov-fast-same", SourceQuota: 321, ResolutionStrategy: model.UserMigrationStrategyCreateNew, Status: model.UserMigrationItemStatusFailed, MigrationToken: "token-fast-path-same", }).Error) require.NoError(t, db.Create(&model.UserMigrationBatch{ Id: 12, Name: "batch-fast-path-same", SourceRegion: "ov", TargetRegion: "cn", Status: model.UserMigrationBatchStatusFailed, OperatorId: 1, }).Error) client := &fakeClient{ getUserByID: map[int]*RemoteUserSnapshot{ 10000019: { Id: 10000019, Username: "ov-fast-same", Quota: 321, Source: common.UserSourceSynced, RemoteUserId: 902, SyncedQuota: 321, }, }, } svc := &Service{client: client} require.NoError(t, svc.ExecuteBatch(12)) require.Empty(t, client.convertCalls) items, err := model.ListUserMigrationItemsByBatch(12) require.NoError(t, err) require.Len(t, items, 1) require.Equal(t, model.UserMigrationItemStatusMigrated, items[0].Status) }