Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 

1667 строки
46 KiB

  1. package user_migration
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "testing"
  6. "github.com/QuantumNous/new-api/common"
  7. "github.com/QuantumNous/new-api/model"
  8. "github.com/glebarez/sqlite"
  9. "github.com/stretchr/testify/require"
  10. "gorm.io/gorm"
  11. )
  12. type fakeClient struct {
  13. listPages map[int][]RemoteUserSnapshot
  14. queryPages map[int][]RemoteUserSnapshot
  15. querySummary *SelectionSummary
  16. getUserResp *RemoteUserSnapshot
  17. getUserByID map[int]*RemoteUserSnapshot
  18. hasCopyResult bool
  19. hasCopyByID map[int]bool
  20. convertCalls []int
  21. convertReqs map[int]*ConvertRemoteUserRequest
  22. }
  23. func (f *fakeClient) ListUsers(page, pageSize int, keyword string) (*ListRemoteUsersResponse, error) {
  24. users := f.listPages[page]
  25. total := 0
  26. for _, pageUsers := range f.listPages {
  27. total += len(pageUsers)
  28. }
  29. return &ListRemoteUsersResponse{
  30. Success: true,
  31. Data: users,
  32. Total: total,
  33. }, nil
  34. }
  35. func (f *fakeClient) QueryUsers(page, pageSize int, sourceUserIDs []int) (*QueryRemoteUsersResponse, error) {
  36. users := f.queryPages[page]
  37. total := len(users)
  38. if f.querySummary != nil {
  39. total = f.querySummary.Matched
  40. }
  41. return &QueryRemoteUsersResponse{
  42. Success: true,
  43. Data: users,
  44. Total: total,
  45. SelectionSummary: f.querySummary,
  46. }, nil
  47. }
  48. func (f *fakeClient) GetUser(userId int) (*RemoteUserSnapshot, error) {
  49. if snapshot, ok := f.getUserByID[userId]; ok {
  50. return snapshot, nil
  51. }
  52. if f.getUserResp != nil {
  53. return f.getUserResp, nil
  54. }
  55. return nil, fmt.Errorf("user %d not found", userId)
  56. }
  57. func (f *fakeClient) ConvertToSynced(userId int, req *ConvertRemoteUserRequest) error {
  58. f.convertCalls = append(f.convertCalls, userId)
  59. if f.convertReqs == nil {
  60. f.convertReqs = make(map[int]*ConvertRemoteUserRequest)
  61. }
  62. if req != nil {
  63. copyReq := *req
  64. f.convertReqs[userId] = &copyReq
  65. if snapshot, ok := f.getUserByID[userId]; ok && snapshot != nil {
  66. snapshot.Source = common.UserSourceSynced
  67. snapshot.RemoteUserId = req.RemoteUserId
  68. snapshot.SyncedQuota = req.SyncedQuota
  69. }
  70. if f.getUserResp != nil && f.getUserResp.Id == userId {
  71. f.getUserResp.Source = common.UserSourceSynced
  72. f.getUserResp.RemoteUserId = req.RemoteUserId
  73. f.getUserResp.SyncedQuota = req.SyncedQuota
  74. }
  75. }
  76. return nil
  77. }
  78. func (f *fakeClient) HasSyncedCopy(cnUserId int) (bool, error) {
  79. if value, ok := f.hasCopyByID[cnUserId]; ok {
  80. return value, nil
  81. }
  82. return f.hasCopyResult, nil
  83. }
  84. func setupMigrationServiceDB(t *testing.T) *gorm.DB {
  85. t.Helper()
  86. db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
  87. require.NoError(t, err)
  88. sqlDB, err := db.DB()
  89. require.NoError(t, err)
  90. sqlDB.SetMaxOpenConns(1)
  91. require.NoError(t, db.AutoMigrate(
  92. &model.User{},
  93. &model.CustomOAuthProvider{},
  94. &model.UserOAuthBinding{},
  95. &model.UserMigrationBatch{},
  96. &model.UserMigrationItem{},
  97. &model.MigrationQuotaGrant{},
  98. ))
  99. return db
  100. }
  101. func TestScanBatch_NoConflict_MarksReady(t *testing.T) {
  102. db := setupMigrationServiceDB(t)
  103. orig := model.DB
  104. model.DB = db
  105. defer func() {
  106. model.DB = orig
  107. }()
  108. batch := &model.UserMigrationBatch{Name: "wave-1", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1}
  109. require.NoError(t, model.CreateUserMigrationBatch(batch))
  110. client := &fakeClient{
  111. listPages: map[int][]RemoteUserSnapshot{
  112. 0: {{
  113. Id: 10000001,
  114. Username: "ov-bob",
  115. Email: "bob@example.com",
  116. Quota: 456,
  117. AffCode: "A002",
  118. }},
  119. },
  120. }
  121. svc := &Service{client: client}
  122. require.NoError(t, svc.ScanBatch(batch.Id))
  123. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  124. require.NoError(t, err)
  125. require.Len(t, items, 1)
  126. require.Equal(t, model.UserMigrationStrategyCreateNew, items[0].ResolutionStrategy)
  127. require.Equal(t, model.UserMigrationItemStatusReady, items[0].Status)
  128. var savedBatch model.UserMigrationBatch
  129. require.NoError(t, db.First(&savedBatch, batch.Id).Error)
  130. require.Equal(t, model.UserMigrationBatchStatusReady, savedBatch.Status)
  131. }
  132. func TestScanBatch_EmailConflict_NoCNSyncedCopy_RecommendsMerge(t *testing.T) {
  133. db := setupMigrationServiceDB(t)
  134. orig := model.DB
  135. model.DB = db
  136. defer func() {
  137. model.DB = orig
  138. }()
  139. require.NoError(t, db.Create(&model.User{
  140. Id: 1,
  141. Username: "cn-alice",
  142. Password: "hash",
  143. Email: "alice@example.com",
  144. AffCode: "C001",
  145. }).Error)
  146. batch := &model.UserMigrationBatch{Name: "wave-2", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1}
  147. require.NoError(t, model.CreateUserMigrationBatch(batch))
  148. client := &fakeClient{
  149. listPages: map[int][]RemoteUserSnapshot{
  150. 0: {{
  151. Id: 10000001,
  152. Username: "ov-alice",
  153. Email: "alice@example.com",
  154. Quota: 123,
  155. AffCode: "A001",
  156. }},
  157. },
  158. hasCopyResult: false,
  159. }
  160. svc := &Service{client: client}
  161. require.NoError(t, svc.ScanBatch(batch.Id))
  162. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  163. require.NoError(t, err)
  164. require.Len(t, items, 1)
  165. require.Equal(t, model.UserMigrationStrategyMergeExisting, items[0].ResolutionStrategy)
  166. require.Equal(t, model.UserMigrationItemStatusReady, items[0].Status)
  167. require.Equal(t, 1, items[0].TargetUserId)
  168. }
  169. func TestScanBatch_EmailConflict_CNHasSyncedCopy_MarksHardConflict(t *testing.T) {
  170. db := setupMigrationServiceDB(t)
  171. orig := model.DB
  172. model.DB = db
  173. defer func() {
  174. model.DB = orig
  175. }()
  176. require.NoError(t, db.Create(&model.User{
  177. Id: 456,
  178. Username: "cn-user456",
  179. Password: "hash",
  180. Email: "shared@example.com",
  181. AffCode: "C456",
  182. }).Error)
  183. batch := &model.UserMigrationBatch{Name: "wave-3", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1}
  184. require.NoError(t, model.CreateUserMigrationBatch(batch))
  185. client := &fakeClient{
  186. listPages: map[int][]RemoteUserSnapshot{
  187. 0: {{
  188. Id: 10000003,
  189. Username: "ov-x",
  190. Email: "shared@example.com",
  191. Quota: 50,
  192. AffCode: "OVX",
  193. }},
  194. },
  195. hasCopyResult: true,
  196. }
  197. svc := &Service{client: client}
  198. require.NoError(t, svc.ScanBatch(batch.Id))
  199. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  200. require.NoError(t, err)
  201. require.Len(t, items, 1)
  202. require.Equal(t, model.UserMigrationItemStatusConflict, items[0].Status)
  203. require.Contains(t, items[0].ConflictFlags, "cn_already_synced_to_ov")
  204. }
  205. func TestScanBatch_GitHubId_SameUser_RecommendsMerge(t *testing.T) {
  206. db := setupMigrationServiceDB(t)
  207. orig := model.DB
  208. model.DB = db
  209. defer func() {
  210. model.DB = orig
  211. }()
  212. require.NoError(t, db.Create(&model.User{
  213. Id: 11,
  214. Username: "cn-github",
  215. Password: "hash",
  216. AffCode: "CGH1",
  217. GitHubId: "gh-123",
  218. }).Error)
  219. batch := &model.UserMigrationBatch{Name: "wave-github-merge", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1}
  220. require.NoError(t, model.CreateUserMigrationBatch(batch))
  221. client := &fakeClient{
  222. listPages: map[int][]RemoteUserSnapshot{
  223. 0: {{
  224. Id: 10000011,
  225. Username: "ov-github",
  226. GitHubId: "gh-123",
  227. Quota: 88,
  228. AffCode: "OVGH1",
  229. }},
  230. },
  231. hasCopyByID: map[int]bool{11: false},
  232. }
  233. svc := &Service{client: client}
  234. require.NoError(t, svc.ScanBatch(batch.Id))
  235. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  236. require.NoError(t, err)
  237. require.Len(t, items, 1)
  238. require.Equal(t, model.UserMigrationStrategyMergeExisting, items[0].ResolutionStrategy)
  239. require.Equal(t, model.UserMigrationItemStatusReady, items[0].Status)
  240. require.Equal(t, 11, items[0].TargetUserId)
  241. require.Contains(t, items[0].ConflictFlags, "github_id")
  242. }
  243. func TestScanBatch_ExplicitIDs_OnlySelectedUsersProcessed(t *testing.T) {
  244. db := setupMigrationServiceDB(t)
  245. orig := model.DB
  246. model.DB = db
  247. defer func() {
  248. model.DB = orig
  249. }()
  250. payloadJSON, err := common.Marshal(SelectionPayload{SourceUserIDs: []int{10000021, 10000023}})
  251. require.NoError(t, err)
  252. batch := &model.UserMigrationBatch{
  253. Name: "explicit-scan",
  254. SourceRegion: "ov",
  255. TargetRegion: "cn",
  256. SelectionMode: SelectionModeExplicitIDs,
  257. SelectionPayload: string(payloadJSON),
  258. RequestedUserCount: 2,
  259. OperatorId: 1,
  260. }
  261. require.NoError(t, model.CreateUserMigrationBatch(batch))
  262. client := &fakeClient{
  263. queryPages: map[int][]RemoteUserSnapshot{
  264. 0: {
  265. {Id: 10000021, Username: "ov-a", Quota: 10, AffCode: "OVA"},
  266. {Id: 10000023, Username: "ov-b", Quota: 20, AffCode: "OVB"},
  267. },
  268. },
  269. querySummary: &SelectionSummary{
  270. Requested: 2,
  271. Matched: 2,
  272. Excluded: nil,
  273. },
  274. }
  275. svc := &Service{client: client}
  276. require.NoError(t, svc.ScanBatch(batch.Id))
  277. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  278. require.NoError(t, err)
  279. require.Len(t, items, 2)
  280. require.ElementsMatch(t, []int{10000021, 10000023}, []int{items[0].SourceUserId, items[1].SourceUserId})
  281. }
  282. func TestScanBatch_ExplicitIDs_SelectionSummaryRecorded(t *testing.T) {
  283. db := setupMigrationServiceDB(t)
  284. orig := model.DB
  285. model.DB = db
  286. defer func() {
  287. model.DB = orig
  288. }()
  289. payloadJSON, err := common.Marshal(SelectionPayload{SourceUserIDs: []int{10000021, 10000022, 10000023}})
  290. require.NoError(t, err)
  291. batch := &model.UserMigrationBatch{
  292. Name: "explicit-summary",
  293. SourceRegion: "ov",
  294. TargetRegion: "cn",
  295. SelectionMode: SelectionModeExplicitIDs,
  296. SelectionPayload: string(payloadJSON),
  297. RequestedUserCount: 3,
  298. OperatorId: 1,
  299. }
  300. require.NoError(t, model.CreateUserMigrationBatch(batch))
  301. client := &fakeClient{
  302. queryPages: map[int][]RemoteUserSnapshot{
  303. 0: {
  304. {Id: 10000021, Username: "ov-a", Quota: 10, AffCode: "OVA"},
  305. {Id: 10000023, Username: "ov-b", Quota: 20, AffCode: "OVB"},
  306. },
  307. },
  308. querySummary: &SelectionSummary{
  309. Requested: 3,
  310. Matched: 2,
  311. Excluded: []SelectionExcluded{
  312. {UserID: 10000022, Reason: "already_synced"},
  313. },
  314. },
  315. }
  316. svc := &Service{client: client}
  317. require.NoError(t, svc.ScanBatch(batch.Id))
  318. var refreshed model.UserMigrationBatch
  319. require.NoError(t, db.First(&refreshed, batch.Id).Error)
  320. require.Equal(t, 2, refreshed.MatchedUserCount)
  321. require.Equal(t, 1, refreshed.ExcludedUserCount)
  322. var summary SelectionSummary
  323. require.NoError(t, json.Unmarshal([]byte(refreshed.SelectionSummary), &summary))
  324. require.Equal(t, 3, summary.Requested)
  325. require.Equal(t, 2, summary.Matched)
  326. require.Len(t, summary.Excluded, 1)
  327. require.Equal(t, 10000022, summary.Excluded[0].UserID)
  328. }
  329. func TestScanBatch_ExplicitIDs_ExcludedUsersDoNotCreateItems(t *testing.T) {
  330. db := setupMigrationServiceDB(t)
  331. orig := model.DB
  332. model.DB = db
  333. defer func() {
  334. model.DB = orig
  335. }()
  336. payloadJSON, err := common.Marshal(SelectionPayload{SourceUserIDs: []int{10000031, 10000032, 10000033}})
  337. require.NoError(t, err)
  338. batch := &model.UserMigrationBatch{
  339. Name: "explicit-excluded",
  340. SourceRegion: "ov",
  341. TargetRegion: "cn",
  342. SelectionMode: SelectionModeExplicitIDs,
  343. SelectionPayload: string(payloadJSON),
  344. RequestedUserCount: 3,
  345. OperatorId: 1,
  346. }
  347. require.NoError(t, model.CreateUserMigrationBatch(batch))
  348. client := &fakeClient{
  349. queryPages: map[int][]RemoteUserSnapshot{
  350. 0: {
  351. {Id: 10000031, Username: "ov-a", Quota: 10, AffCode: "OVA"},
  352. },
  353. },
  354. querySummary: &SelectionSummary{
  355. Requested: 3,
  356. Matched: 1,
  357. Excluded: []SelectionExcluded{
  358. {UserID: 10000032, Reason: "root_user_not_migratable"},
  359. {UserID: 10000033, Reason: "already_synced"},
  360. },
  361. },
  362. }
  363. svc := &Service{client: client}
  364. require.NoError(t, svc.ScanBatch(batch.Id))
  365. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  366. require.NoError(t, err)
  367. require.Len(t, items, 1)
  368. require.Equal(t, 10000031, items[0].SourceUserId)
  369. }
  370. func TestScanBatch_OAuthId_DifferentUsers_MultipleMergeTargets(t *testing.T) {
  371. db := setupMigrationServiceDB(t)
  372. orig := model.DB
  373. model.DB = db
  374. defer func() {
  375. model.DB = orig
  376. }()
  377. require.NoError(t, db.Create(&model.User{
  378. Id: 21,
  379. Username: "cn-github-owner",
  380. Password: "hash",
  381. AffCode: "COA1",
  382. GitHubId: "gh-shared",
  383. }).Error)
  384. require.NoError(t, db.Create(&model.User{
  385. Id: 22,
  386. Username: "cn-discord-owner",
  387. Password: "hash",
  388. AffCode: "COA2",
  389. DiscordId: "dc-shared",
  390. }).Error)
  391. batch := &model.UserMigrationBatch{Name: "wave-oauth-conflict", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1}
  392. require.NoError(t, model.CreateUserMigrationBatch(batch))
  393. client := &fakeClient{
  394. listPages: map[int][]RemoteUserSnapshot{
  395. 0: {{
  396. Id: 10000012,
  397. Username: "ov-oauth-conflict",
  398. GitHubId: "gh-shared",
  399. DiscordId: "dc-shared",
  400. Quota: 66,
  401. AffCode: "OVOA1",
  402. }},
  403. },
  404. }
  405. svc := &Service{client: client}
  406. require.NoError(t, svc.ScanBatch(batch.Id))
  407. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  408. require.NoError(t, err)
  409. require.Len(t, items, 1)
  410. require.Equal(t, model.UserMigrationItemStatusConflict, items[0].Status)
  411. require.Contains(t, items[0].ConflictFlags, "github_id")
  412. require.Contains(t, items[0].ConflictFlags, "discord_id")
  413. require.Contains(t, items[0].ConflictFlags, "multiple_merge_targets")
  414. }
  415. func TestScanBatch_CustomOAuthBinding_MatchesExistingUser_RecommendsMerge(t *testing.T) {
  416. db := setupMigrationServiceDB(t)
  417. orig := model.DB
  418. model.DB = db
  419. defer func() {
  420. model.DB = orig
  421. }()
  422. require.NoError(t, db.Create(&model.CustomOAuthProvider{
  423. Id: 31,
  424. Name: "GitHub Enterprise",
  425. Slug: "gh-enterprise",
  426. Enabled: true,
  427. ClientId: "cid",
  428. AuthorizationEndpoint: "https://auth.example.com",
  429. TokenEndpoint: "https://token.example.com",
  430. UserInfoEndpoint: "https://userinfo.example.com",
  431. }).Error)
  432. require.NoError(t, db.Create(&model.User{
  433. Id: 32,
  434. Username: "cn-custom-oauth",
  435. Password: "hash",
  436. AffCode: "CCO1",
  437. }).Error)
  438. require.NoError(t, db.Create(&model.UserOAuthBinding{
  439. UserId: 32,
  440. ProviderId: 31,
  441. ProviderUserId: "provider-user-1",
  442. }).Error)
  443. batch := &model.UserMigrationBatch{Name: "wave-custom-oauth-merge", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1}
  444. require.NoError(t, model.CreateUserMigrationBatch(batch))
  445. client := &fakeClient{
  446. listPages: map[int][]RemoteUserSnapshot{
  447. 0: {{
  448. Id: 10000013,
  449. Username: "ov-custom-oauth",
  450. Quota: 55,
  451. AffCode: "OVCO1",
  452. OAuthBindings: []RemoteOAuthBinding{{
  453. ProviderSlug: "gh-enterprise",
  454. ProviderUserId: "provider-user-1",
  455. }},
  456. }},
  457. },
  458. hasCopyByID: map[int]bool{32: false},
  459. }
  460. svc := &Service{client: client}
  461. require.NoError(t, svc.ScanBatch(batch.Id))
  462. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  463. require.NoError(t, err)
  464. require.Len(t, items, 1)
  465. require.Equal(t, model.UserMigrationStrategyMergeExisting, items[0].ResolutionStrategy)
  466. require.Equal(t, model.UserMigrationItemStatusReady, items[0].Status)
  467. require.Equal(t, 32, items[0].TargetUserId)
  468. require.Contains(t, items[0].ConflictFlags, "oauth:gh-enterprise")
  469. }
  470. func TestScanBatch_CustomOAuthBinding_UnknownSlug_Ignored(t *testing.T) {
  471. db := setupMigrationServiceDB(t)
  472. orig := model.DB
  473. model.DB = db
  474. defer func() {
  475. model.DB = orig
  476. }()
  477. batch := &model.UserMigrationBatch{Name: "wave-custom-oauth-unknown", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1}
  478. require.NoError(t, model.CreateUserMigrationBatch(batch))
  479. client := &fakeClient{
  480. listPages: map[int][]RemoteUserSnapshot{
  481. 0: {{
  482. Id: 10000014,
  483. Username: "ov-unknown-oauth",
  484. Quota: 77,
  485. AffCode: "OVUK1",
  486. OAuthBindings: []RemoteOAuthBinding{{
  487. ProviderSlug: "unknown-provider",
  488. ProviderUserId: "u-1",
  489. }},
  490. }},
  491. },
  492. }
  493. svc := &Service{client: client}
  494. require.NoError(t, svc.ScanBatch(batch.Id))
  495. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  496. require.NoError(t, err)
  497. require.Len(t, items, 1)
  498. require.Equal(t, model.UserMigrationStrategyCreateNew, items[0].ResolutionStrategy)
  499. require.Equal(t, model.UserMigrationItemStatusReady, items[0].Status)
  500. require.NotContains(t, items[0].ConflictFlags, "oauth:")
  501. }
  502. func TestScanBatch_EmailAndOAuth_SameUser_RecommendsMerge(t *testing.T) {
  503. db := setupMigrationServiceDB(t)
  504. orig := model.DB
  505. model.DB = db
  506. defer func() {
  507. model.DB = orig
  508. }()
  509. require.NoError(t, db.Create(&model.User{
  510. Id: 41,
  511. Username: "cn-same-user",
  512. Password: "hash",
  513. Email: "same@example.com",
  514. AffCode: "CSU1",
  515. GitHubId: "gh-same",
  516. }).Error)
  517. batch := &model.UserMigrationBatch{Name: "wave-email-oauth-same", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1}
  518. require.NoError(t, model.CreateUserMigrationBatch(batch))
  519. client := &fakeClient{
  520. listPages: map[int][]RemoteUserSnapshot{
  521. 0: {{
  522. Id: 10000015,
  523. Username: "ov-same-user",
  524. Email: "same@example.com",
  525. GitHubId: "gh-same",
  526. Quota: 91,
  527. AffCode: "OVSU1",
  528. }},
  529. },
  530. hasCopyByID: map[int]bool{41: false},
  531. }
  532. svc := &Service{client: client}
  533. require.NoError(t, svc.ScanBatch(batch.Id))
  534. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  535. require.NoError(t, err)
  536. require.Len(t, items, 1)
  537. require.Equal(t, model.UserMigrationStrategyMergeExisting, items[0].ResolutionStrategy)
  538. require.Equal(t, model.UserMigrationItemStatusReady, items[0].Status)
  539. require.Equal(t, 41, items[0].TargetUserId)
  540. require.Contains(t, items[0].ConflictFlags, "email")
  541. require.Contains(t, items[0].ConflictFlags, "github_id")
  542. require.NotContains(t, items[0].ConflictFlags, "multiple_merge_targets")
  543. }
  544. func TestScanBatch_EmailAndOAuth_DifferentUsers_Conflicts(t *testing.T) {
  545. db := setupMigrationServiceDB(t)
  546. orig := model.DB
  547. model.DB = db
  548. defer func() {
  549. model.DB = orig
  550. }()
  551. require.NoError(t, db.Create(&model.User{
  552. Id: 51,
  553. Username: "cn-email-owner",
  554. Password: "hash",
  555. Email: "combo@example.com",
  556. AffCode: "CEO1",
  557. }).Error)
  558. require.NoError(t, db.Create(&model.User{
  559. Id: 52,
  560. Username: "cn-oauth-owner",
  561. Password: "hash",
  562. AffCode: "COO1",
  563. GitHubId: "gh-combo",
  564. }).Error)
  565. batch := &model.UserMigrationBatch{Name: "wave-email-oauth-diff", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1}
  566. require.NoError(t, model.CreateUserMigrationBatch(batch))
  567. client := &fakeClient{
  568. listPages: map[int][]RemoteUserSnapshot{
  569. 0: {{
  570. Id: 10000016,
  571. Username: "ov-combo-user",
  572. Email: "combo@example.com",
  573. GitHubId: "gh-combo",
  574. Quota: 92,
  575. AffCode: "OVCO2",
  576. }},
  577. },
  578. }
  579. svc := &Service{client: client}
  580. require.NoError(t, svc.ScanBatch(batch.Id))
  581. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  582. require.NoError(t, err)
  583. require.Len(t, items, 1)
  584. require.Equal(t, model.UserMigrationItemStatusConflict, items[0].Status)
  585. require.Contains(t, items[0].ConflictFlags, "email")
  586. require.Contains(t, items[0].ConflictFlags, "github_id")
  587. require.Contains(t, items[0].ConflictFlags, "multiple_merge_targets")
  588. }
  589. func TestScanBatch_UsernameConflict_OverridesMergeCandidate(t *testing.T) {
  590. db := setupMigrationServiceDB(t)
  591. orig := model.DB
  592. model.DB = db
  593. defer func() {
  594. model.DB = orig
  595. }()
  596. require.NoError(t, db.Create(&model.User{
  597. Id: 61,
  598. Username: "same-name",
  599. Password: "hash",
  600. AffCode: "CSN1",
  601. }).Error)
  602. require.NoError(t, db.Create(&model.User{
  603. Id: 62,
  604. Username: "other-user",
  605. Password: "hash",
  606. Email: "mergeable@example.com",
  607. AffCode: "CME1",
  608. }).Error)
  609. batch := &model.UserMigrationBatch{Name: "wave-username-overrides", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1}
  610. require.NoError(t, model.CreateUserMigrationBatch(batch))
  611. client := &fakeClient{
  612. listPages: map[int][]RemoteUserSnapshot{
  613. 0: {{
  614. Id: 10000017,
  615. Username: "same-name",
  616. Email: "mergeable@example.com",
  617. Quota: 93,
  618. AffCode: "OVUN1",
  619. }},
  620. },
  621. hasCopyByID: map[int]bool{62: false},
  622. }
  623. svc := &Service{client: client}
  624. require.NoError(t, svc.ScanBatch(batch.Id))
  625. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  626. require.NoError(t, err)
  627. require.Len(t, items, 1)
  628. require.Equal(t, model.UserMigrationItemStatusConflict, items[0].Status)
  629. require.Contains(t, items[0].ConflictFlags, "username")
  630. }
  631. func TestScanBatch_Paginated(t *testing.T) {
  632. db := setupMigrationServiceDB(t)
  633. orig := model.DB
  634. model.DB = db
  635. defer func() {
  636. model.DB = orig
  637. }()
  638. batch := &model.UserMigrationBatch{Name: "wave-big", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1}
  639. require.NoError(t, model.CreateUserMigrationBatch(batch))
  640. page0 := make([]RemoteUserSnapshot, scanPageSize)
  641. for i := range page0 {
  642. page0[i] = RemoteUserSnapshot{
  643. Id: 10000001 + i,
  644. Username: fmt.Sprintf("u%d", i),
  645. AffCode: fmt.Sprintf("A%d", i),
  646. }
  647. }
  648. page1 := []RemoteUserSnapshot{{
  649. Id: 10000001 + scanPageSize,
  650. Username: "last",
  651. AffCode: "LAST",
  652. }}
  653. client := &fakeClient{
  654. listPages: map[int][]RemoteUserSnapshot{
  655. 0: page0,
  656. 1: page1,
  657. },
  658. }
  659. svc := &Service{client: client}
  660. require.NoError(t, svc.ScanBatch(batch.Id))
  661. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  662. require.NoError(t, err)
  663. require.Len(t, items, scanPageSize+1)
  664. }
  665. func TestScanBatch_Rescan_PreservesHumanStrategy(t *testing.T) {
  666. db := setupMigrationServiceDB(t)
  667. orig := model.DB
  668. model.DB = db
  669. defer func() {
  670. model.DB = orig
  671. }()
  672. batch := &model.UserMigrationBatch{Name: "wave-rescan", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1}
  673. require.NoError(t, model.CreateUserMigrationBatch(batch))
  674. client := &fakeClient{
  675. listPages: map[int][]RemoteUserSnapshot{
  676. 0: {{
  677. Id: 10000001,
  678. Username: "ov-dave",
  679. Email: "dave@example.com",
  680. Quota: 100,
  681. AffCode: "OVD1",
  682. }},
  683. },
  684. hasCopyResult: false,
  685. }
  686. svc := &Service{client: client}
  687. require.NoError(t, svc.ScanBatch(batch.Id))
  688. require.NoError(t, db.Model(&model.UserMigrationItem{}).Where("batch_id = ?", batch.Id).
  689. Update("resolution_strategy", model.UserMigrationStrategySkip).Error)
  690. require.NoError(t, svc.ScanBatch(batch.Id))
  691. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  692. require.NoError(t, err)
  693. require.Len(t, items, 1)
  694. require.Equal(t, model.UserMigrationStrategySkip, items[0].ResolutionStrategy)
  695. }
  696. func TestMigrationFlow_GrayScenarios_EndToEnd(t *testing.T) {
  697. db := setupMigrationServiceDB(t)
  698. orig := model.DB
  699. model.DB = db
  700. defer func() {
  701. model.DB = orig
  702. }()
  703. require.NoError(t, db.Create(&model.User{
  704. Id: 1,
  705. Username: "cn-merge",
  706. Password: "hash",
  707. Email: "merge@example.com",
  708. AffCode: "CNM1",
  709. Quota: 500,
  710. }).Error)
  711. require.NoError(t, db.Create(&model.User{
  712. Id: 2,
  713. Username: "taken-name",
  714. Password: "hash",
  715. Email: "taken@example.com",
  716. AffCode: "CNT2",
  717. Quota: 50,
  718. }).Error)
  719. require.NoError(t, db.Create(&model.User{
  720. Id: 3,
  721. Username: "cn-hard",
  722. Password: "hash",
  723. Email: "synced@example.com",
  724. AffCode: "CNH3",
  725. Quota: 300,
  726. }).Error)
  727. require.NoError(t, db.Create(&model.User{
  728. Id: 4,
  729. Username: "cn-soft",
  730. Password: "hash",
  731. Email: "soft-owner@example.com",
  732. AffCode: "SOFT1",
  733. Quota: 80,
  734. }).Error)
  735. batch := &model.UserMigrationBatch{
  736. Name: "gray-scenarios",
  737. SourceRegion: "ov",
  738. TargetRegion: "cn",
  739. OperatorId: 1,
  740. }
  741. require.NoError(t, model.CreateUserMigrationBatch(batch))
  742. remoteUsers := []RemoteUserSnapshot{
  743. {
  744. Id: 10000001,
  745. Username: "ov-create",
  746. Email: "create@example.com",
  747. Quota: 100,
  748. AffCode: "OVC1",
  749. },
  750. {
  751. Id: 10000002,
  752. Username: "ov-merge",
  753. Email: "merge@example.com",
  754. Quota: 200,
  755. AffCode: "OVM2",
  756. },
  757. {
  758. Id: 10000003,
  759. Username: "ov-hard",
  760. Email: "synced@example.com",
  761. Quota: 300,
  762. AffCode: "OVH3",
  763. },
  764. {
  765. Id: 10000004,
  766. Username: "taken-name",
  767. Email: "username-conflict@example.com",
  768. Quota: 120,
  769. AffCode: "OVU4",
  770. },
  771. {
  772. Id: 10000005,
  773. Username: "ov-soft",
  774. Email: "soft@example.com",
  775. Quota: 150,
  776. AffCode: "SOFT1",
  777. },
  778. }
  779. getUserByID := make(map[int]*RemoteUserSnapshot, len(remoteUsers))
  780. for i := range remoteUsers {
  781. snapshot := remoteUsers[i]
  782. getUserByID[snapshot.Id] = &snapshot
  783. }
  784. client := &fakeClient{
  785. listPages: map[int][]RemoteUserSnapshot{
  786. 0: remoteUsers,
  787. },
  788. getUserByID: getUserByID,
  789. hasCopyByID: map[int]bool{
  790. 1: false,
  791. 3: true,
  792. },
  793. }
  794. svc := &Service{client: client}
  795. require.NoError(t, svc.ScanBatch(batch.Id))
  796. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  797. require.NoError(t, err)
  798. require.Len(t, items, 5)
  799. bySourceUserID := make(map[int]*model.UserMigrationItem, len(items))
  800. for _, item := range items {
  801. bySourceUserID[item.SourceUserId] = item
  802. }
  803. require.Equal(t, model.UserMigrationStrategyCreateNew, bySourceUserID[10000001].ResolutionStrategy)
  804. require.Equal(t, model.UserMigrationItemStatusReady, bySourceUserID[10000001].Status)
  805. require.Equal(t, model.UserMigrationStrategyMergeExisting, bySourceUserID[10000002].ResolutionStrategy)
  806. require.Equal(t, 1, bySourceUserID[10000002].TargetUserId)
  807. require.Equal(t, model.UserMigrationItemStatusReady, bySourceUserID[10000002].Status)
  808. require.Equal(t, model.UserMigrationItemStatusConflict, bySourceUserID[10000003].Status)
  809. require.Contains(t, bySourceUserID[10000003].ConflictFlags, "cn_already_synced_to_ov")
  810. require.Equal(t, model.UserMigrationItemStatusConflict, bySourceUserID[10000004].Status)
  811. require.Contains(t, bySourceUserID[10000004].ConflictFlags, "username")
  812. require.Equal(t, model.UserMigrationStrategyCreateNew, bySourceUserID[10000005].ResolutionStrategy)
  813. require.Equal(t, model.UserMigrationItemStatusReady, bySourceUserID[10000005].Status)
  814. require.Contains(t, bySourceUserID[10000005].ConflictFlags, "aff_code")
  815. require.NoError(t, db.Model(&model.UserMigrationItem{}).
  816. Where("id IN ?", []int{bySourceUserID[10000003].Id, bySourceUserID[10000004].Id}).
  817. Updates(map[string]any{
  818. "resolution_strategy": model.UserMigrationStrategySkip,
  819. "status": model.UserMigrationItemStatusReady,
  820. "error_message": "",
  821. }).Error)
  822. require.NoError(t, model.RefreshUserMigrationBatchStats(batch.Id))
  823. var readyBatch model.UserMigrationBatch
  824. require.NoError(t, db.First(&readyBatch, batch.Id).Error)
  825. require.Equal(t, model.UserMigrationBatchStatusReady, readyBatch.Status)
  826. locked, err := model.TryLockBatchForExecution(batch.Id)
  827. require.NoError(t, err)
  828. require.True(t, locked)
  829. require.NoError(t, svc.ExecuteBatch(batch.Id))
  830. items, err = model.ListUserMigrationItemsByBatch(batch.Id)
  831. require.NoError(t, err)
  832. bySourceUserID = make(map[int]*model.UserMigrationItem, len(items))
  833. for _, item := range items {
  834. bySourceUserID[item.SourceUserId] = item
  835. }
  836. require.Equal(t, model.UserMigrationItemStatusMigrated, bySourceUserID[10000001].Status)
  837. require.Equal(t, model.UserMigrationItemStatusMigrated, bySourceUserID[10000002].Status)
  838. require.Equal(t, model.UserMigrationItemStatusSkipped, bySourceUserID[10000003].Status)
  839. require.Equal(t, model.UserMigrationItemStatusSkipped, bySourceUserID[10000004].Status)
  840. require.Equal(t, model.UserMigrationItemStatusMigrated, bySourceUserID[10000005].Status)
  841. var createdUser model.User
  842. require.NoError(t, db.Where("username = ?", "ov-create").First(&createdUser).Error)
  843. require.Equal(t, 100, createdUser.Quota)
  844. var mergedUser model.User
  845. require.NoError(t, db.First(&mergedUser, 1).Error)
  846. require.Equal(t, 700, mergedUser.Quota)
  847. var softUser model.User
  848. require.NoError(t, db.Where("username = ?", "ov-soft").First(&softUser).Error)
  849. require.Equal(t, 150, softUser.Quota)
  850. require.NotEqual(t, "SOFT1", softUser.AffCode)
  851. require.Equal(t, common.UserSourceSynced, client.getUserByID[10000001].Source)
  852. require.Equal(t, createdUser.Id, client.getUserByID[10000001].RemoteUserId)
  853. require.Equal(t, 100, client.getUserByID[10000001].SyncedQuota)
  854. require.Equal(t, common.UserSourceSynced, client.getUserByID[10000002].Source)
  855. require.Equal(t, 1, client.getUserByID[10000002].RemoteUserId)
  856. require.Equal(t, 700, client.getUserByID[10000002].SyncedQuota)
  857. require.NotEqual(t, common.UserSourceSynced, client.getUserByID[10000003].Source)
  858. require.NotEqual(t, common.UserSourceSynced, client.getUserByID[10000004].Source)
  859. require.Equal(t, common.UserSourceSynced, client.getUserByID[10000005].Source)
  860. require.Equal(t, softUser.Id, client.getUserByID[10000005].RemoteUserId)
  861. require.Equal(t, 150, client.getUserByID[10000005].SyncedQuota)
  862. for _, sourceUserID := range []int{10000001, 10000002, 10000005} {
  863. result, verifyErr := svc.VerifyItem(bySourceUserID[sourceUserID])
  864. require.NoError(t, verifyErr)
  865. require.True(t, result.TargetUserExists)
  866. require.True(t, result.RemoteConverted)
  867. require.True(t, result.QuotaMatched)
  868. }
  869. require.NoError(t, db.Model(&model.UserMigrationBatch{}).Where("id = ?", batch.Id).
  870. Update("status", model.UserMigrationBatchStatusReady).Error)
  871. require.NoError(t, db.Model(&model.UserMigrationItem{}).
  872. Where("id IN ?", []int{
  873. bySourceUserID[10000001].Id,
  874. bySourceUserID[10000002].Id,
  875. bySourceUserID[10000005].Id,
  876. }).
  877. Update("status", model.UserMigrationItemStatusReady).Error)
  878. locked, err = model.TryLockBatchForExecution(batch.Id)
  879. require.NoError(t, err)
  880. require.True(t, locked)
  881. require.NoError(t, svc.ExecuteBatch(batch.Id))
  882. require.Len(t, client.convertCalls, 3)
  883. require.NoError(t, db.First(&mergedUser, 1).Error)
  884. require.Equal(t, 700, mergedUser.Quota)
  885. }
  886. func TestExecuteBatch_CreateNew_CreatesUserAndConvertsRemote(t *testing.T) {
  887. db := setupMigrationServiceDB(t)
  888. orig := model.DB
  889. model.DB = db
  890. defer func() {
  891. model.DB = orig
  892. }()
  893. batch := &model.UserMigrationBatch{
  894. Name: "wave-exec",
  895. Status: model.UserMigrationBatchStatusReady,
  896. }
  897. batch.SourceRegion = "ov"
  898. batch.TargetRegion = "cn"
  899. batch.OperatorId = 1
  900. require.NoError(t, model.CreateUserMigrationBatch(batch))
  901. item := &model.UserMigrationItem{
  902. BatchId: batch.Id,
  903. SourceUserId: 10000001,
  904. SourceUsername: "ov-alice",
  905. SourceEmail: "alice@example.com",
  906. SourceQuota: 300,
  907. ResolutionStrategy: model.UserMigrationStrategyCreateNew,
  908. Status: model.UserMigrationItemStatusReady,
  909. MigrationToken: "batch-1-user-10000001",
  910. }
  911. require.NoError(t, model.UpsertUserMigrationItem(item))
  912. client := &fakeClient{
  913. getUserResp: &RemoteUserSnapshot{
  914. Id: 10000001,
  915. Username: "ov-alice",
  916. Password: "hashed-password",
  917. Email: "alice@example.com",
  918. DisplayName: "Alice",
  919. Status: 1,
  920. Role: 1,
  921. Group: "default",
  922. Quota: 300,
  923. AffCode: "OVA1",
  924. },
  925. }
  926. svc := &Service{client: client}
  927. locked, err := model.TryLockBatchForExecution(batch.Id)
  928. require.NoError(t, err)
  929. require.True(t, locked)
  930. require.NoError(t, svc.ExecuteBatch(batch.Id))
  931. var cnUser model.User
  932. require.NoError(t, db.Where("username = ?", "ov-alice").First(&cnUser).Error)
  933. require.Equal(t, 300, cnUser.Quota)
  934. require.Less(t, cnUser.Id, 10000000)
  935. require.Len(t, client.convertCalls, 1)
  936. require.Equal(t, 10000001, client.convertCalls[0])
  937. }
  938. func TestExecuteBatch_CreateNew_IdempotentOnRetry(t *testing.T) {
  939. db := setupMigrationServiceDB(t)
  940. orig := model.DB
  941. model.DB = db
  942. defer func() {
  943. model.DB = orig
  944. }()
  945. batch := &model.UserMigrationBatch{
  946. Name: "wave-retry",
  947. Status: model.UserMigrationBatchStatusReady,
  948. }
  949. batch.SourceRegion = "ov"
  950. batch.TargetRegion = "cn"
  951. batch.OperatorId = 1
  952. require.NoError(t, model.CreateUserMigrationBatch(batch))
  953. item := &model.UserMigrationItem{
  954. BatchId: batch.Id,
  955. SourceUserId: 10000002,
  956. SourceUsername: "ov-carol",
  957. SourceEmail: "carol@example.com",
  958. SourceQuota: 150,
  959. ResolutionStrategy: model.UserMigrationStrategyCreateNew,
  960. Status: model.UserMigrationItemStatusReady,
  961. MigrationToken: "batch-1-user-10000002",
  962. }
  963. require.NoError(t, model.UpsertUserMigrationItem(item))
  964. snapshot := &RemoteUserSnapshot{
  965. Id: 10000002,
  966. Username: "ov-carol",
  967. Password: "hash",
  968. Email: "carol@example.com",
  969. Quota: 150,
  970. AffCode: "OVC1",
  971. }
  972. client := &fakeClient{getUserResp: snapshot}
  973. svc := &Service{client: client}
  974. locked, err := model.TryLockBatchForExecution(batch.Id)
  975. require.NoError(t, err)
  976. require.True(t, locked)
  977. require.NoError(t, svc.ExecuteBatch(batch.Id))
  978. require.NoError(t, db.Model(&model.UserMigrationBatch{}).Where("id = ?", batch.Id).
  979. Update("status", model.UserMigrationBatchStatusFailed).Error)
  980. require.NoError(t, db.Model(&model.UserMigrationItem{}).Where("batch_id = ?", batch.Id).
  981. Update("status", model.UserMigrationItemStatusReady).Error)
  982. locked, err = model.TryLockBatchForExecution(batch.Id)
  983. require.NoError(t, err)
  984. require.True(t, locked)
  985. require.NoError(t, svc.ExecuteBatch(batch.Id))
  986. var count int64
  987. require.NoError(t, db.Model(&model.User{}).Where("username = ?", "ov-carol").Count(&count).Error)
  988. require.EqualValues(t, 1, count)
  989. require.Len(t, client.convertCalls, 1)
  990. }
  991. func TestExecuteBatch_CreateNew_ExecutionTimeDriftFails(t *testing.T) {
  992. db := setupMigrationServiceDB(t)
  993. orig := model.DB
  994. model.DB = db
  995. defer func() {
  996. model.DB = orig
  997. }()
  998. require.NoError(t, db.Create(&model.User{
  999. Id: 7,
  1000. Username: "cn-alice",
  1001. Password: "hash",
  1002. Email: "alice@example.com",
  1003. AffCode: "C007",
  1004. }).Error)
  1005. batch := &model.UserMigrationBatch{
  1006. Name: "wave-create-drift",
  1007. SourceRegion: "ov",
  1008. TargetRegion: "cn",
  1009. OperatorId: 1,
  1010. Status: model.UserMigrationBatchStatusReady,
  1011. }
  1012. require.NoError(t, model.CreateUserMigrationBatch(batch))
  1013. item := &model.UserMigrationItem{
  1014. BatchId: batch.Id,
  1015. SourceUserId: 10000020,
  1016. SourceUsername: "ov-drift",
  1017. SourceEmail: "old@example.com",
  1018. SourceQuota: 120,
  1019. ResolutionStrategy: model.UserMigrationStrategyCreateNew,
  1020. Status: model.UserMigrationItemStatusReady,
  1021. MigrationToken: "batch-1-user-10000020",
  1022. }
  1023. require.NoError(t, model.UpsertUserMigrationItem(item))
  1024. client := &fakeClient{
  1025. getUserResp: &RemoteUserSnapshot{
  1026. Id: 10000020,
  1027. Username: "ov-drift",
  1028. Password: "hash",
  1029. Email: "alice@example.com",
  1030. Quota: 120,
  1031. AffCode: "OV20",
  1032. },
  1033. hasCopyByID: map[int]bool{7: false},
  1034. }
  1035. svc := &Service{client: client}
  1036. locked, err := model.TryLockBatchForExecution(batch.Id)
  1037. require.NoError(t, err)
  1038. require.True(t, locked)
  1039. require.NoError(t, svc.ExecuteBatch(batch.Id))
  1040. require.Len(t, client.convertCalls, 0)
  1041. var count int64
  1042. require.NoError(t, db.Model(&model.User{}).Where("username = ?", "ov-drift").Count(&count).Error)
  1043. require.EqualValues(t, 0, count)
  1044. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  1045. require.NoError(t, err)
  1046. require.Len(t, items, 1)
  1047. require.Equal(t, model.UserMigrationItemStatusFailed, items[0].Status)
  1048. require.Contains(t, items[0].ErrorMessage, "identity drift")
  1049. }
  1050. func TestExecuteBatch_MergeExisting_IdempotentQuota(t *testing.T) {
  1051. db := setupMigrationServiceDB(t)
  1052. orig := model.DB
  1053. model.DB = db
  1054. defer func() {
  1055. model.DB = orig
  1056. }()
  1057. require.NoError(t, db.Create(&model.User{
  1058. Id: 1,
  1059. Username: "cn-bob",
  1060. Password: "hash",
  1061. Email: "bob@example.com",
  1062. AffCode: "CNB1",
  1063. Quota: 500,
  1064. }).Error)
  1065. batch := &model.UserMigrationBatch{
  1066. Name: "wave-merge",
  1067. Status: model.UserMigrationBatchStatusReady,
  1068. }
  1069. batch.SourceRegion = "ov"
  1070. batch.TargetRegion = "cn"
  1071. batch.OperatorId = 1
  1072. require.NoError(t, model.CreateUserMigrationBatch(batch))
  1073. item := &model.UserMigrationItem{
  1074. BatchId: batch.Id,
  1075. SourceUserId: 10000002,
  1076. TargetUserId: 1,
  1077. SourceUsername: "ov-bob",
  1078. SourceEmail: "bob@example.com",
  1079. SourceQuota: 200,
  1080. ResolutionStrategy: model.UserMigrationStrategyMergeExisting,
  1081. Status: model.UserMigrationItemStatusReady,
  1082. MigrationToken: "batch-1-user-10000002",
  1083. }
  1084. require.NoError(t, model.UpsertUserMigrationItem(item))
  1085. client := &fakeClient{
  1086. getUserResp: &RemoteUserSnapshot{
  1087. Id: 10000002,
  1088. Username: "ov-bob",
  1089. Password: "hashed",
  1090. Email: "bob@example.com",
  1091. Quota: 200,
  1092. },
  1093. }
  1094. svc := &Service{client: client}
  1095. for i := 0; i < 2; i++ {
  1096. require.NoError(t, db.Model(&model.UserMigrationBatch{}).Where("id = ?", batch.Id).
  1097. Update("status", model.UserMigrationBatchStatusReady).Error)
  1098. require.NoError(t, db.Model(&model.UserMigrationItem{}).Where("batch_id = ?", batch.Id).
  1099. Update("status", model.UserMigrationItemStatusReady).Error)
  1100. locked, err := model.TryLockBatchForExecution(batch.Id)
  1101. require.NoError(t, err)
  1102. require.True(t, locked)
  1103. require.NoError(t, svc.ExecuteBatch(batch.Id))
  1104. }
  1105. var saved model.User
  1106. require.NoError(t, db.First(&saved, 1).Error)
  1107. require.Equal(t, 700, saved.Quota)
  1108. require.Len(t, client.convertCalls, 1)
  1109. }
  1110. func TestExecuteBatch_MergeExisting_ExecutionTimeDriftFails(t *testing.T) {
  1111. db := setupMigrationServiceDB(t)
  1112. orig := model.DB
  1113. model.DB = db
  1114. defer func() {
  1115. model.DB = orig
  1116. }()
  1117. require.NoError(t, db.Create(&model.User{
  1118. Id: 1,
  1119. Username: "cn-bob",
  1120. Password: "hash",
  1121. Email: "bob@example.com",
  1122. AffCode: "CNB1",
  1123. Quota: 500,
  1124. }).Error)
  1125. batch := &model.UserMigrationBatch{
  1126. Name: "wave-merge-drift",
  1127. SourceRegion: "ov",
  1128. TargetRegion: "cn",
  1129. OperatorId: 1,
  1130. Status: model.UserMigrationBatchStatusReady,
  1131. }
  1132. require.NoError(t, model.CreateUserMigrationBatch(batch))
  1133. item := &model.UserMigrationItem{
  1134. BatchId: batch.Id,
  1135. SourceUserId: 10000021,
  1136. TargetUserId: 1,
  1137. SourceUsername: "ov-bob",
  1138. SourceEmail: "bob@example.com",
  1139. SourceQuota: 200,
  1140. ResolutionStrategy: model.UserMigrationStrategyMergeExisting,
  1141. Status: model.UserMigrationItemStatusReady,
  1142. MigrationToken: "batch-1-user-10000021",
  1143. }
  1144. require.NoError(t, model.UpsertUserMigrationItem(item))
  1145. client := &fakeClient{
  1146. getUserResp: &RemoteUserSnapshot{
  1147. Id: 10000021,
  1148. Username: "ov-bob",
  1149. Password: "hash",
  1150. Email: "changed@example.com",
  1151. Quota: 200,
  1152. AffCode: "OV21",
  1153. },
  1154. }
  1155. svc := &Service{client: client}
  1156. locked, err := model.TryLockBatchForExecution(batch.Id)
  1157. require.NoError(t, err)
  1158. require.True(t, locked)
  1159. require.NoError(t, svc.ExecuteBatch(batch.Id))
  1160. require.Len(t, client.convertCalls, 0)
  1161. var cnUser model.User
  1162. require.NoError(t, db.First(&cnUser, 1).Error)
  1163. require.Equal(t, 500, cnUser.Quota)
  1164. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  1165. require.NoError(t, err)
  1166. require.Len(t, items, 1)
  1167. require.Equal(t, model.UserMigrationItemStatusFailed, items[0].Status)
  1168. require.Contains(t, items[0].ErrorMessage, "merge target drift")
  1169. }
  1170. func TestExecuteBatch_SkipItem_DoesNotConvert(t *testing.T) {
  1171. db := setupMigrationServiceDB(t)
  1172. orig := model.DB
  1173. model.DB = db
  1174. defer func() {
  1175. model.DB = orig
  1176. }()
  1177. batch := &model.UserMigrationBatch{
  1178. Name: "skip-test",
  1179. SourceRegion: "ov",
  1180. TargetRegion: "cn",
  1181. OperatorId: 1,
  1182. Status: model.UserMigrationBatchStatusReady,
  1183. }
  1184. require.NoError(t, model.CreateUserMigrationBatch(batch))
  1185. require.NoError(t, model.UpsertUserMigrationItem(&model.UserMigrationItem{
  1186. BatchId: batch.Id,
  1187. SourceUserId: 10000099,
  1188. SourceUsername: "skip-user",
  1189. SourceQuota: 50,
  1190. ResolutionStrategy: model.UserMigrationStrategySkip,
  1191. Status: model.UserMigrationItemStatusReady,
  1192. MigrationToken: "batch-1-user-10000099",
  1193. }))
  1194. client := &fakeClient{}
  1195. svc := &Service{client: client}
  1196. locked, err := model.TryLockBatchForExecution(batch.Id)
  1197. require.NoError(t, err)
  1198. require.True(t, locked)
  1199. require.NoError(t, svc.ExecuteBatch(batch.Id))
  1200. require.Len(t, client.convertCalls, 0)
  1201. items, err := model.ListUserMigrationItemsByBatch(batch.Id)
  1202. require.NoError(t, err)
  1203. require.Len(t, items, 1)
  1204. require.Equal(t, model.UserMigrationItemStatusSkipped, items[0].Status)
  1205. }
  1206. func TestVerifyItem_CorrectlyChecksRemoteConverted(t *testing.T) {
  1207. db := setupMigrationServiceDB(t)
  1208. orig := model.DB
  1209. model.DB = db
  1210. defer func() {
  1211. model.DB = orig
  1212. }()
  1213. require.NoError(t, db.Create(&model.User{
  1214. Id: 789,
  1215. Username: "cn-migrated",
  1216. Password: "hash",
  1217. AffCode: "CM1",
  1218. Quota: 300,
  1219. }).Error)
  1220. item := &model.UserMigrationItem{
  1221. BatchId: 1,
  1222. SourceUserId: 10000001,
  1223. TargetUserId: 789,
  1224. SourceQuota: 300,
  1225. Status: model.UserMigrationItemStatusMigrated,
  1226. }
  1227. client := &fakeClient{
  1228. getUserResp: &RemoteUserSnapshot{
  1229. Id: 10000001,
  1230. Source: common.UserSourceSynced,
  1231. RemoteUserId: 789,
  1232. SyncedQuota: 300,
  1233. },
  1234. }
  1235. svc := &Service{client: client}
  1236. result, err := svc.VerifyItem(item)
  1237. require.NoError(t, err)
  1238. require.True(t, result.TargetUserExists)
  1239. require.True(t, result.RemoteConverted)
  1240. require.True(t, result.QuotaMatched)
  1241. }
  1242. func TestVerifyItem_QuotaMismatchDetected(t *testing.T) {
  1243. db := setupMigrationServiceDB(t)
  1244. orig := model.DB
  1245. model.DB = db
  1246. defer func() {
  1247. model.DB = orig
  1248. }()
  1249. require.NoError(t, db.Create(&model.User{
  1250. Id: 790,
  1251. Username: "cn-mismatch",
  1252. Password: "hash",
  1253. AffCode: "CM2",
  1254. Quota: 300,
  1255. }).Error)
  1256. item := &model.UserMigrationItem{
  1257. BatchId: 1,
  1258. SourceUserId: 10000002,
  1259. TargetUserId: 790,
  1260. SourceQuota: 300,
  1261. Status: model.UserMigrationItemStatusMigrated,
  1262. }
  1263. client := &fakeClient{
  1264. getUserResp: &RemoteUserSnapshot{
  1265. Id: 10000002,
  1266. Source: common.UserSourceSynced,
  1267. RemoteUserId: 790,
  1268. SyncedQuota: 250,
  1269. },
  1270. }
  1271. svc := &Service{client: client}
  1272. result, err := svc.VerifyItem(item)
  1273. require.NoError(t, err)
  1274. require.True(t, result.TargetUserExists)
  1275. require.True(t, result.RemoteConverted)
  1276. require.False(t, result.QuotaMatched)
  1277. }
  1278. func TestVerifyItem_AllowsPostMigrationQuotaConsumption(t *testing.T) {
  1279. db := setupMigrationServiceDB(t)
  1280. orig := model.DB
  1281. model.DB = db
  1282. defer func() {
  1283. model.DB = orig
  1284. }()
  1285. require.NoError(t, db.Create(&model.User{
  1286. Id: 791,
  1287. Username: "cn-consumed",
  1288. Password: "hash",
  1289. AffCode: "CM3",
  1290. Quota: 250,
  1291. }).Error)
  1292. item := &model.UserMigrationItem{
  1293. BatchId: 1,
  1294. SourceUserId: 10000003,
  1295. TargetUserId: 791,
  1296. SourceQuota: 200,
  1297. Status: model.UserMigrationItemStatusMigrated,
  1298. }
  1299. client := &fakeClient{
  1300. getUserResp: &RemoteUserSnapshot{
  1301. Id: 10000003,
  1302. Source: common.UserSourceSynced,
  1303. RemoteUserId: 791,
  1304. SyncedQuota: 200,
  1305. },
  1306. }
  1307. svc := &Service{client: client}
  1308. result, err := svc.VerifyItem(item)
  1309. require.NoError(t, err)
  1310. require.True(t, result.TargetUserExists)
  1311. require.True(t, result.RemoteConverted)
  1312. require.True(t, result.QuotaMatched)
  1313. }
  1314. func TestExecuteBatch_CreateNewFastPathRevalidatesAndMarksMigrated(t *testing.T) {
  1315. db := setupMigrationServiceDB(t)
  1316. orig := model.DB
  1317. model.DB = db
  1318. defer func() {
  1319. model.DB = orig
  1320. }()
  1321. require.NoError(t, db.Create(&model.User{
  1322. Id: 900,
  1323. Username: "cn-existing",
  1324. Password: "hash",
  1325. AffCode: "CN900",
  1326. Quota: 300,
  1327. }).Error)
  1328. require.NoError(t, db.Create(&model.UserMigrationItem{
  1329. Id: 1,
  1330. BatchId: 10,
  1331. SourceUserId: 10000010,
  1332. TargetUserId: 900,
  1333. SourceUsername: "ov-existing",
  1334. SourceQuota: 300,
  1335. ResolutionStrategy: model.UserMigrationStrategyCreateNew,
  1336. Status: model.UserMigrationItemStatusFailed,
  1337. MigrationToken: "token-fast-path",
  1338. }).Error)
  1339. require.NoError(t, db.Create(&model.UserMigrationBatch{
  1340. Id: 10,
  1341. Name: "batch-fast-path",
  1342. SourceRegion: "ov",
  1343. TargetRegion: "cn",
  1344. Status: model.UserMigrationBatchStatusFailed,
  1345. OperatorId: 1,
  1346. }).Error)
  1347. client := &fakeClient{
  1348. getUserByID: map[int]*RemoteUserSnapshot{
  1349. 10000010: {
  1350. Id: 10000010,
  1351. Username: "ov-existing",
  1352. Quota: 300,
  1353. Source: common.UserSourceLocal,
  1354. OAuthBindings: nil,
  1355. },
  1356. },
  1357. }
  1358. svc := &Service{client: client}
  1359. require.NoError(t, svc.ExecuteBatch(10))
  1360. require.Len(t, client.convertCalls, 1)
  1361. require.Equal(t, 10000010, client.convertCalls[0])
  1362. items, err := model.ListUserMigrationItemsByBatch(10)
  1363. require.NoError(t, err)
  1364. require.Len(t, items, 1)
  1365. require.Equal(t, model.UserMigrationItemStatusMigrated, items[0].Status)
  1366. }
  1367. func TestExecuteBatch_CreateNewFastPath_DriftFails(t *testing.T) {
  1368. db := setupMigrationServiceDB(t)
  1369. orig := model.DB
  1370. model.DB = db
  1371. defer func() {
  1372. model.DB = orig
  1373. }()
  1374. require.NoError(t, db.Create(&model.User{
  1375. Id: 901,
  1376. Username: "cn-existing-drift",
  1377. Password: "hash",
  1378. AffCode: "CND901",
  1379. Quota: 320,
  1380. }).Error)
  1381. require.NoError(t, db.Create(&model.UserMigrationItem{
  1382. Id: 2,
  1383. BatchId: 11,
  1384. SourceUserId: 10000018,
  1385. TargetUserId: 901,
  1386. SourceUsername: "ov-fast-drift",
  1387. SourceQuota: 320,
  1388. ResolutionStrategy: model.UserMigrationStrategyCreateNew,
  1389. Status: model.UserMigrationItemStatusFailed,
  1390. MigrationToken: "token-fast-path-drift",
  1391. }).Error)
  1392. require.NoError(t, db.Create(&model.UserMigrationBatch{
  1393. Id: 11,
  1394. Name: "batch-fast-path-drift",
  1395. SourceRegion: "ov",
  1396. TargetRegion: "cn",
  1397. Status: model.UserMigrationBatchStatusFailed,
  1398. OperatorId: 1,
  1399. }).Error)
  1400. client := &fakeClient{
  1401. getUserByID: map[int]*RemoteUserSnapshot{
  1402. 10000018: {
  1403. Id: 10000018,
  1404. Username: "ov-fast-drift",
  1405. Quota: 320,
  1406. Source: common.UserSourceSynced,
  1407. RemoteUserId: 999,
  1408. SyncedQuota: 320,
  1409. },
  1410. },
  1411. }
  1412. svc := &Service{client: client}
  1413. require.NoError(t, svc.ExecuteBatch(11))
  1414. require.Empty(t, client.convertCalls)
  1415. items, err := model.ListUserMigrationItemsByBatch(11)
  1416. require.NoError(t, err)
  1417. require.Len(t, items, 1)
  1418. require.Equal(t, model.UserMigrationItemStatusFailed, items[0].Status)
  1419. require.Contains(t, items[0].ErrorMessage, "already synced")
  1420. }
  1421. func TestExecuteBatch_CreateNewFastPath_AlreadySyncedToSameTargetSucceeds(t *testing.T) {
  1422. db := setupMigrationServiceDB(t)
  1423. orig := model.DB
  1424. model.DB = db
  1425. defer func() {
  1426. model.DB = orig
  1427. }()
  1428. require.NoError(t, db.Create(&model.User{
  1429. Id: 902,
  1430. Username: "cn-existing-same",
  1431. Password: "hash",
  1432. AffCode: "CND902",
  1433. Quota: 321,
  1434. }).Error)
  1435. require.NoError(t, db.Create(&model.UserMigrationItem{
  1436. Id: 3,
  1437. BatchId: 12,
  1438. SourceUserId: 10000019,
  1439. TargetUserId: 902,
  1440. SourceUsername: "ov-fast-same",
  1441. SourceQuota: 321,
  1442. ResolutionStrategy: model.UserMigrationStrategyCreateNew,
  1443. Status: model.UserMigrationItemStatusFailed,
  1444. MigrationToken: "token-fast-path-same",
  1445. }).Error)
  1446. require.NoError(t, db.Create(&model.UserMigrationBatch{
  1447. Id: 12,
  1448. Name: "batch-fast-path-same",
  1449. SourceRegion: "ov",
  1450. TargetRegion: "cn",
  1451. Status: model.UserMigrationBatchStatusFailed,
  1452. OperatorId: 1,
  1453. }).Error)
  1454. client := &fakeClient{
  1455. getUserByID: map[int]*RemoteUserSnapshot{
  1456. 10000019: {
  1457. Id: 10000019,
  1458. Username: "ov-fast-same",
  1459. Quota: 321,
  1460. Source: common.UserSourceSynced,
  1461. RemoteUserId: 902,
  1462. SyncedQuota: 321,
  1463. },
  1464. },
  1465. }
  1466. svc := &Service{client: client}
  1467. require.NoError(t, svc.ExecuteBatch(12))
  1468. require.Empty(t, client.convertCalls)
  1469. items, err := model.ListUserMigrationItemsByBatch(12)
  1470. require.NoError(t, err)
  1471. require.Len(t, items, 1)
  1472. require.Equal(t, model.UserMigrationItemStatusMigrated, items[0].Status)
  1473. }