Browse Source

test(user-migration): complete migration coverage and e2e flow

Co-Authored-By: Codex <noreply@anthropic.com>
master
fengsilin 4 days ago
parent
commit
ca3f8765bb
38 changed files with 5185 additions and 55 deletions
  1. +8
    -2
      controller/internal_user_migration.go
  2. +218
    -0
      controller/internal_user_migration_test.go
  3. +9
    -3
      controller/user.go
  4. +9
    -1
      controller/user_migration.go
  5. +289
    -0
      controller/user_migration_test.go
  6. +146
    -0
      controller/user_sync_guard_test.go
  7. +131
    -0
      docs/testing/2026-06-09-user-migration-delivery-plan.md
  8. +591
    -0
      docs/testing/user-migration-e2e-execution-plan.md
  9. +176
    -0
      docs/testing/user-migration-e2e.md
  10. +60
    -1
      model/region_migration_user_test.go
  11. +13
    -2
      model/user.go
  12. +5
    -1
      model/user_migration.go
  13. +33
    -0
      model/user_migration_test.go
  14. +155
    -0
      router/user_migration_router_test.go
  15. +63
    -0
      scripts/run-user-migration-e2e.ps1
  16. +47
    -21
      service/user_migration/executor.go
  17. +548
    -0
      service/user_migration/service_test.go
  18. +1
    -1
      service/user_migration/verifier.go
  19. +79
    -0
      test/e2e/cmd/user_migration_ui_server/main.go
  20. +215
    -0
      test/e2e/helpers/assert.go
  21. +75
    -0
      test/e2e/helpers/auth.go
  22. +397
    -0
      test/e2e/helpers/cluster.go
  23. +119
    -0
      test/e2e/helpers/http_client.go
  24. +115
    -0
      test/e2e/helpers/options.go
  25. +304
    -0
      test/e2e/helpers/seed.go
  26. +683
    -0
      test/e2e/user_migration_test.go
  27. +60
    -0
      web/e2e/fixtures/cluster.ts
  28. +306
    -0
      web/e2e/global-setup.ts
  29. +44
    -0
      web/e2e/global-teardown.ts
  30. +141
    -0
      web/e2e/user-migration.spec.ts
  31. +1
    -0
      web/package.json
  32. +35
    -0
      web/playwright.config.ts
  33. +14
    -4
      web/src/components/table/users/migration/BatchDetail.jsx
  34. +26
    -4
      web/src/components/table/users/migration/BatchList.jsx
  35. +22
    -4
      web/src/components/table/users/migration/ConflictResolver.jsx
  36. +34
    -7
      web/src/components/table/users/migration/ExecutionPanel.jsx
  37. +7
    -1
      web/src/components/table/users/migration/index.jsx
  38. +6
    -3
      web/src/pages/User/index.jsx

+ 8
- 2
controller/internal_user_migration.go View File

@@ -1,10 +1,11 @@
package controller

import (
"errors"
"net/http"
"strconv"
"strings"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
user_migration "github.com/QuantumNous/new-api/service/user_migration"
"github.com/gin-gonic/gin"
@@ -43,6 +44,10 @@ func GetMigrationUser(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "user not found"})
return
}
if user.Role == common.RoleRootUser {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "user not found"})
return
}

bindings, _ := model.GetUserOAuthBindingsByUserId(userId)
c.JSON(http.StatusOK, gin.H{"success": true, "data": buildRemoteUserSnapshot(user, bindings)})
@@ -62,7 +67,8 @@ func ConvertMigrationUserToSynced(c *gin.Context) {
}

if err := model.ConvertUserToSynced(userId, req.RemoteUserId, req.SyncedQuota); err != nil {
if strings.Contains(err.Error(), "already synced") || strings.Contains(err.Error(), "cannot change") {
if errors.Is(err, model.ErrConvertRootUserToSynced) ||
errors.Is(err, model.ErrSyncedUserRemoteUserIDImmutable) {
c.JSON(http.StatusConflict, gin.H{"success": false, "error": err.Error()})
return
}


+ 218
- 0
controller/internal_user_migration_test.go View File

@@ -60,6 +60,14 @@ func TestListMigrationUsers_ReturnsLocalOnly(t *testing.T) {
Source: common.UserSourceSynced,
RemoteUserId: 1,
}).Error)
require.NoError(t, db.Create(&model.User{
Id: 10000003,
Username: "root",
Password: "hash",
AffCode: "A3",
Source: common.UserSourceLocal,
Role: common.RoleRootUser,
}).Error)

req := httptest.NewRequest(http.MethodGet, "/api/internal/migration/users?page=0&page_size=20", nil)
w := httptest.NewRecorder()
@@ -72,6 +80,82 @@ func TestListMigrationUsers_ReturnsLocalOnly(t *testing.T) {
require.EqualValues(t, 1, body["total"])
}

func TestGetMigrationUser_RootExcluded(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testSyncAuthMiddleware())
router.GET("/api/internal/migration/users/:id", GetMigrationUser)

db := setupInternalMigrationDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

require.NoError(t, db.Create(&model.User{
Id: 10000011,
Username: "root",
Password: "hash",
AffCode: "ROOT1",
Source: common.UserSourceLocal,
Role: common.RoleRootUser,
}).Error)

req := httptest.NewRequest(http.MethodGet, "/api/internal/migration/users/10000011", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusNotFound, w.Code)
}

func TestGetMigrationUser_SyncedUserStillReadableForVerify(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testSyncAuthMiddleware())
router.GET("/api/internal/migration/users/:id", GetMigrationUser)

db := setupInternalMigrationDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

require.NoError(t, db.Create(&model.User{
Id: 10000021,
Username: "synced-user",
Password: "hash",
AffCode: "SYNC1",
Source: common.UserSourceSynced,
RemoteUserId: 88,
SyncedQuota: 123,
Role: common.RoleCommonUser,
}).Error)

req := httptest.NewRequest(http.MethodGet, "/api/internal/migration/users/10000021", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusOK, w.Code)
var body map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.True(t, body["success"].(bool))
}

func TestGetMigrationUser_InvalidID(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testSyncAuthMiddleware())
router.GET("/api/internal/migration/users/:id", GetMigrationUser)

req := httptest.NewRequest(http.MethodGet, "/api/internal/migration/users/not-a-number", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusBadRequest, w.Code)
}

func TestConvertMigrationUserToSynced_Idempotent(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
@@ -138,6 +222,140 @@ func TestConvertMigrationUserToSynced_ConflictRemoteId(t *testing.T) {
require.Equal(t, http.StatusConflict, w.Code)
}

func TestConvertMigrationUserToSynced_ConflictUsesSentinelErrors(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testSyncAuthMiddleware())
router.POST("/api/internal/migration/users/:id/convert-to-synced", ConvertMigrationUserToSynced)

db := setupInternalMigrationDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

require.NoError(t, db.Create(&model.User{
Id: 10000013,
Username: "ov-13",
Password: "hash",
AffCode: "A13",
Source: common.UserSourceSynced,
RemoteUserId: 5001,
}).Error)

body := `{"remote_user_id":9999,"synced_quota":100}`
req := httptest.NewRequest(http.MethodPost,
"/api/internal/migration/users/10000013/convert-to-synced",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusConflict, w.Code)
}

func TestConvertMigrationUserToSynced_RejectsRootUser(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testSyncAuthMiddleware())
router.POST("/api/internal/migration/users/:id/convert-to-synced", ConvertMigrationUserToSynced)

db := setupInternalMigrationDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

require.NoError(t, db.Create(&model.User{
Id: 10000012,
Username: "root",
Password: "hash",
AffCode: "ROOT2",
Source: common.UserSourceLocal,
Role: common.RoleRootUser,
}).Error)

body := `{"remote_user_id":9999,"synced_quota":100}`
req := httptest.NewRequest(http.MethodPost,
"/api/internal/migration/users/10000012/convert-to-synced",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusConflict, w.Code)
}

func TestConvertMigrationUserToSynced_InvalidRemoteUserID(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testSyncAuthMiddleware())
router.POST("/api/internal/migration/users/:id/convert-to-synced", ConvertMigrationUserToSynced)

db := setupInternalMigrationDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

require.NoError(t, db.Create(&model.User{
Id: 10000014,
Username: "ov-14",
Password: "hash",
AffCode: "A14",
Source: common.UserSourceLocal,
}).Error)

body := `{"remote_user_id":0,"synced_quota":100}`
req := httptest.NewRequest(http.MethodPost,
"/api/internal/migration/users/10000014/convert-to-synced",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
}

func TestConvertMigrationUserToSynced_PersistsSyncedFields(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testSyncAuthMiddleware())
router.POST("/api/internal/migration/users/:id/convert-to-synced", ConvertMigrationUserToSynced)

db := setupInternalMigrationDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

require.NoError(t, db.Create(&model.User{
Id: 10000015,
Username: "ov-15",
Password: "hash",
AffCode: "A15",
Source: common.UserSourceLocal,
Quota: 88,
}).Error)

body := `{"remote_user_id":2015,"synced_quota":166}`
req := httptest.NewRequest(http.MethodPost,
"/api/internal/migration/users/10000015/convert-to-synced",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)

var saved model.User
require.NoError(t, db.First(&saved, 10000015).Error)
require.Equal(t, common.UserSourceSynced, saved.Source)
require.Equal(t, 2015, saved.RemoteUserId)
require.Equal(t, 166, saved.SyncedQuota)
require.Greater(t, saved.LastSyncAt, int64(0))
}

func TestCheckSyncedCopy(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()


+ 9
- 3
controller/user.go View File

@@ -562,9 +562,15 @@ func UpdateUser(c *gin.Context) {
common.ApiError(c, err)
return
}
if originUser.IsSyncedUser() && originUser.Quota != updatedUser.Quota {
common.ApiErrorI18n(c, i18n.MsgSyncedUserQuotaCannotModify)
return
if originUser.IsSyncedUser() {
// 对同步用户而言,界面展示和用户感知的“余额”是 synced_quota,
// 不能用本地 quota 列做比较,否则在 quota / synced_quota 漂移后会误判。
if originUser.SyncedQuota != updatedUser.Quota {
common.ApiErrorI18n(c, i18n.MsgSyncedUserQuotaCannotModify)
return
}
// 提交其他字段编辑时,保留本地 quota 原值,避免把展示用 synced_quota 回写到 quota 列。
updatedUser.Quota = originUser.Quota
}
myRole := c.GetInt("role")
if myRole <= originUser.Role && myRole != common.RoleRootUser {


+ 9
- 1
controller/user_migration.go View File

@@ -10,6 +10,14 @@ import (
"github.com/gin-gonic/gin"
)

type migrationVerifier interface {
VerifyItem(item *model.UserMigrationItem) (*user_migration.VerifyResult, error)
}

var newMigrationVerifier = func() migrationVerifier {
return newMigrationService()
}

func CreateUserMigrationBatch(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
@@ -202,7 +210,7 @@ func VerifyUserMigrationBatch(c *gin.Context) {
var items []model.UserMigrationItem
model.DB.Where("batch_id = ? AND status = ?", batchID, model.UserMigrationItemStatusMigrated).Find(&items)

svc := newMigrationService()
svc := newMigrationVerifier()
results := make([]map[string]any, 0, len(items))
for _, item := range items {
result, err := svc.VerifyItem(&item)


+ 289
- 0
controller/user_migration_test.go View File

@@ -3,11 +3,13 @@ package controller
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/model"
user_migration "github.com/QuantumNous/new-api/service/user_migration"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
@@ -26,6 +28,8 @@ func setupUserMigrationControllerDB(t *testing.T) *gorm.DB {

require.NoError(t, db.AutoMigrate(
&model.User{},
&model.CustomOAuthProvider{},
&model.UserOAuthBinding{},
&model.UserMigrationBatch{},
&model.UserMigrationItem{},
&model.MigrationQuotaGrant{},
@@ -42,6 +46,21 @@ func testRootAuthMiddleware() gin.HandlerFunc {
}
}

type fakeMigrationVerifier struct {
results map[int]*user_migration.VerifyResult
errs map[int]error
}

func (f *fakeMigrationVerifier) VerifyItem(item *model.UserMigrationItem) (*user_migration.VerifyResult, error) {
if err, ok := f.errs[item.SourceUserId]; ok {
return nil, err
}
if result, ok := f.results[item.SourceUserId]; ok {
return result, nil
}
return nil, errors.New("unexpected source user")
}

func TestCreateUserMigrationBatch_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
@@ -75,6 +94,105 @@ func TestCreateUserMigrationBatch_Success(t *testing.T) {
require.NotNil(t, resp["data"])
}

func TestListUserMigrationBatches_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())
router.GET("/api/user-migrations/batches", ListUserMigrationBatches)

db := setupUserMigrationControllerDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

require.NoError(t, db.Create(&model.UserMigrationBatch{
Id: 1,
Name: "batch-1",
SourceRegion: "ov",
TargetRegion: "cn",
OperatorId: 1,
}).Error)
require.NoError(t, db.Create(&model.UserMigrationBatch{
Id: 2,
Name: "batch-2",
SourceRegion: "ov",
TargetRegion: "cn",
OperatorId: 1,
}).Error)

req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/batches", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.True(t, resp["success"].(bool))
require.Len(t, resp["data"], 2)
}

func TestGetUserMigrationBatch_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())
router.GET("/api/user-migrations/batches/:id", GetUserMigrationBatch)

db := setupUserMigrationControllerDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

require.NoError(t, db.Create(&model.UserMigrationBatch{
Id: 1,
Name: "detail-batch",
SourceRegion: "ov",
TargetRegion: "cn",
OperatorId: 1,
}).Error)
require.NoError(t, db.Create(&model.UserMigrationItem{
BatchId: 1,
SourceUserId: 1001,
SourceUsername: "ov-u1",
Status: model.UserMigrationItemStatusReady,
}).Error)

req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/batches/1?page=0&page_size=50", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.True(t, resp["success"].(bool))
require.NotNil(t, resp["data"])
require.Len(t, resp["items"], 1)
require.EqualValues(t, 1, resp["total"])
}

func TestGetUserMigrationBatch_NotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())
router.GET("/api/user-migrations/batches/:id", GetUserMigrationBatch)

db := setupUserMigrationControllerDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/batches/999", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusNotFound, w.Code)
}

func TestExecuteUserMigrationBatch_AlreadyRunning(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
@@ -104,6 +222,37 @@ func TestExecuteUserMigrationBatch_AlreadyRunning(t *testing.T) {
require.Equal(t, http.StatusConflict, w.Code)
}

func TestExecuteUserMigrationBatch_InvalidStateRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())
router.POST("/api/user-migrations/batches/:id/execute", ExecuteUserMigrationBatch)

db := setupUserMigrationControllerDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

for _, status := range []string{model.UserMigrationBatchStatusDraft, model.UserMigrationBatchStatusScanned} {
require.NoError(t, db.Exec("DELETE FROM user_migration_batches").Error)
require.NoError(t, db.Create(&model.UserMigrationBatch{
Id: 1,
Name: "invalid-exec",
SourceRegion: "ov",
TargetRegion: "cn",
OperatorId: 1,
Status: status,
}).Error)

req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/execute", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusConflict, w.Code)
}
}

func TestResolveUserMigrationItem_InvalidStrategy(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
@@ -179,3 +328,143 @@ func TestResolveUserMigrationItem_RefreshesBatchStatusToReady(t *testing.T) {
require.NoError(t, db.First(&refreshed, batch.Id).Error)
require.Equal(t, model.UserMigrationBatchStatusReady, refreshed.Status)
}

func TestRetryUserMigrationBatch_OnlyFailedItemsReset(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())
router.POST("/api/user-migrations/batches/:id/retry", RetryUserMigrationBatch)

db := setupUserMigrationControllerDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

require.NoError(t, db.Create(&model.UserMigrationBatch{
Id: 1,
Name: "retry-batch",
SourceRegion: "ov",
TargetRegion: "cn",
OperatorId: 1,
Status: model.UserMigrationBatchStatusRunning,
}).Error)
require.NoError(t, db.Create(&model.UserMigrationItem{
Id: 1,
BatchId: 1,
SourceUserId: 1001,
SourceUsername: "failed-user",
Status: model.UserMigrationItemStatusFailed,
ErrorMessage: "boom",
}).Error)
require.NoError(t, db.Create(&model.UserMigrationItem{
Id: 2,
BatchId: 1,
SourceUserId: 1002,
SourceUsername: "migrated-user",
Status: model.UserMigrationItemStatusMigrated,
}).Error)
require.NoError(t, db.Create(&model.UserMigrationItem{
Id: 3,
BatchId: 1,
SourceUserId: 1003,
SourceUsername: "skipped-user",
Status: model.UserMigrationItemStatusSkipped,
}).Error)

req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/retry", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusConflict, w.Code)

var failedItem, migratedItem, skippedItem model.UserMigrationItem
require.NoError(t, db.First(&failedItem, 1).Error)
require.NoError(t, db.First(&migratedItem, 2).Error)
require.NoError(t, db.First(&skippedItem, 3).Error)

require.Equal(t, model.UserMigrationItemStatusReady, failedItem.Status)
require.Empty(t, failedItem.ErrorMessage)
require.Equal(t, model.UserMigrationItemStatusMigrated, migratedItem.Status)
require.Equal(t, model.UserMigrationItemStatusSkipped, skippedItem.Status)
}

func TestVerifyUserMigrationBatch_AggregatesResult(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())
router.POST("/api/user-migrations/batches/:id/verify", VerifyUserMigrationBatch)

db := setupUserMigrationControllerDB(t)
orig := model.DB
origFactory := newMigrationVerifier
model.DB = db
defer func() {
model.DB = orig
newMigrationVerifier = origFactory
}()

require.NoError(t, db.Create(&model.UserMigrationItem{
Id: 1,
BatchId: 1,
SourceUserId: 1001,
TargetUserId: 1,
Status: model.UserMigrationItemStatusMigrated,
}).Error)
require.NoError(t, db.Create(&model.UserMigrationItem{
Id: 2,
BatchId: 1,
SourceUserId: 1002,
TargetUserId: 2,
Status: model.UserMigrationItemStatusMigrated,
}).Error)
require.NoError(t, db.Create(&model.UserMigrationItem{
Id: 3,
BatchId: 1,
SourceUserId: 1003,
TargetUserId: 3,
Status: model.UserMigrationItemStatusMigrated,
}).Error)

newMigrationVerifier = func() migrationVerifier {
return &fakeMigrationVerifier{
results: map[int]*user_migration.VerifyResult{
1001: {
TargetUserExists: true,
RemoteConverted: true,
QuotaMatched: true,
},
1002: {
TargetUserExists: false,
RemoteConverted: true,
QuotaMatched: false,
},
},
errs: map[int]error{
1003: errors.New("remote user not found"),
},
}
}

req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/verify", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)

var resp struct {
Success bool `json:"success"`
Data []map[string]any `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.True(t, resp.Success)
require.Len(t, resp.Data, 3)

bySource := make(map[int]map[string]any, len(resp.Data))
for _, entry := range resp.Data {
bySource[int(entry["source_user_id"].(float64))] = entry
}

require.NotNil(t, bySource[1001]["result"])
require.NotNil(t, bySource[1002]["result"])
require.Equal(t, "remote user not found", bySource[1003]["error"])
}

+ 146
- 0
controller/user_sync_guard_test.go View File

@@ -0,0 +1,146 @@
package controller

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func setupUserControllerDB(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{}))
return db
}

func TestUpdateUser_SyncedUserAllowsNonQuotaEditsWithoutOverwritingQuota(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())
router.PUT("/api/user/", UpdateUser)

db := setupUserControllerDB(t)
orig := model.DB
origRedisEnabled := common.RedisEnabled
model.DB = db
defer func() {
model.DB = orig
common.RedisEnabled = origRedisEnabled
}()
common.RedisEnabled = false

require.NoError(t, db.Create(&model.User{
Id: 1001,
Username: "synced-user",
Password: "hashed-password",
DisplayName: "Old Name",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
Quota: 0,
SyncedQuota: 100000,
Source: common.UserSourceSynced,
AffCode: "SYNC1",
}).Error)

body := map[string]any{
"id": 1001,
"username": "synced-user",
"display_name": "New Name",
"password": "",
"group": "default",
"quota": 100000,
}
bodyBytes, err := json.Marshal(body)
require.NoError(t, err)

req := httptest.NewRequest(http.MethodPut, "/api/user/", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.True(t, resp["success"].(bool))

var saved model.User
require.NoError(t, db.First(&saved, 1001).Error)
require.Equal(t, "New Name", saved.DisplayName)
require.Equal(t, 0, saved.Quota)
require.Equal(t, 100000, saved.SyncedQuota)
}

func TestUpdateUser_SyncedUserQuotaChangeRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())
router.PUT("/api/user/", UpdateUser)

db := setupUserControllerDB(t)
orig := model.DB
origRedisEnabled := common.RedisEnabled
model.DB = db
defer func() {
model.DB = orig
common.RedisEnabled = origRedisEnabled
}()
common.RedisEnabled = false

require.NoError(t, db.Create(&model.User{
Id: 1002,
Username: "synced-user-2",
Password: "hashed-password",
DisplayName: "Synced User 2",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
Quota: 0,
SyncedQuota: 100000,
Source: common.UserSourceSynced,
AffCode: "SYNC2",
}).Error)

body := map[string]any{
"id": 1002,
"username": "synced-user-2",
"display_name": "Synced User 2",
"password": "",
"group": "default",
"quota": 100001,
}
bodyBytes, err := json.Marshal(body)
require.NoError(t, err)

req := httptest.NewRequest(http.MethodPut, "/api/user/", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.False(t, resp["success"].(bool))
require.Equal(t, "user.synced_user_quota_cannot_modify", resp["message"])

var saved model.User
require.NoError(t, db.First(&saved, 1002).Error)
require.Equal(t, 0, saved.Quota)
require.Equal(t, 100000, saved.SyncedQuota)
}

+ 131
- 0
docs/testing/2026-06-09-user-migration-delivery-plan.md View File

@@ -0,0 +1,131 @@
# User Migration Delivery Plan

## 背景

当前 `feat-overseas-user-migration` worktree 已经完成海外用户迁移主链路实现,并补齐了:

- 模型层迁移表与状态机
- 海外内部迁移接口
- 国内管理端迁移 API
- 迁移中心前端
- 单元测试 / 控制器测试 / 路由测试
- API E2E
- Playwright UI E2E

本计划的目标不是继续扩功能,而是把当前能力收口到“可交付、可审阅、可部署”的状态。

## 当前已验证通过的内容

以下能力已经通过真实测试验证:

- `go test ./service/user_migration/... -v`
- `go test ./controller -run "Test.*Migration|Test.*Synced" -v`
- `go test ./model -run "Test.*Migration|Test.*Synced" -v`
- `go test ./router -run "TestUserMigrationRoutes" -v`
- `go test -v ./test/e2e -count=1`
- `cd web && bunx playwright test e2e/user-migration.spec.ts`
- `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/run-user-migration-e2e.ps1 -All`

这些验证已经覆盖:

- 扫描层冲突分析
- create_new / merge / skip
- fast path 恢复与 drift
- root-only 访问控制
- verify 聚合结果
- 内部迁移 API 边界
- UI 迁移中心主流程

## 剩余工作目标

把当前 worktree 收敛为:

1. 改动范围清晰
2. 非主线噪音最小
3. 文档完整
4. 提交路径明确

## 需要处理的剩余事项

### 1. 清理非主线前端脏 diff

当前与迁移主线无强耦合、但会影响审阅质量的内容:

- `web/src/i18n/locales/en.json`
- `web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx`

目标:

- 只保留本轮确有必要的改动
- 避免大面积格式化/编码噪音
- 确保 `bun run build` 继续通过

### 2. 产出测试覆盖矩阵文档

新增一份文档,明确:

- 迁移功能需求点
- 对应自动化测试名
- 测试层级(unit / controller / router / api-e2e / ui-e2e)

建议文件:

- `docs/testing/user-migration-test-matrix.md`

### 3. 收敛提交范围

建议把当前 worktree 的改动按下面 3 组看待:

#### A. 核心后端与测试

- `model/*migration*`
- `service/user_migration/*`
- `controller/internal_user_migration*`
- `controller/user_migration*`
- `router/user_migration_router_test.go`

#### B. 前端迁移中心

- `web/src/components/table/users/migration/*`
- `web/src/hooks/users/useUserMigrationData.jsx`
- `web/src/pages/User/index.jsx`
- `web/e2e/*`
- `web/playwright.config.ts`

#### C. E2E 基础设施与文档

- `test/e2e/*`
- `scripts/run-user-migration-e2e.ps1`
- `docs/testing/*`

### 4. 最终交付前整体验证

在准备提交前至少再次执行:

```bash
go test ./service/user_migration/... -v
go test ./controller -run "Test.*Migration|Test.*Synced" -v
go test ./model -run "Test.*Migration|Test.*Synced" -v
go test ./router -run "TestUserMigrationRoutes" -v
go test -v ./test/e2e -count=1
cd web && bun run build
cd web && bunx playwright test e2e/user-migration.spec.ts
```

## 建议执行顺序

1. 清理 `en.json` 和 `ModelRationNotSetEditor.jsx`
2. 产出测试覆盖矩阵文档
3. 再跑一轮最终回归
4. 审查 `git diff --stat`
5. 进入提交阶段

## 完成标准

只有同时满足以下条件,才算本轮迁移能力真正收尾:

- 迁移主链路代码无新增功能性缺口
- Go 测试、API E2E、UI E2E 全绿
- worktree 中无明显无关脏改动
- 测试覆盖矩阵可供审阅
- 提交范围已经可清晰切分

+ 591
- 0
docs/testing/user-migration-e2e-execution-plan.md View File

@@ -0,0 +1,591 @@
# 用户迁移 E2E 执行计划

## 背景

本计划用于为“海外历史用户迁移到国内主站”能力补齐完整的 E2E 测试体系。目标不是补几条临时脚本,而是形成一套可以本地稳定复跑、能定位失败原因、后续可接 CI 的测试资产。

本计划覆盖四个层面:

- 双节点真实运行环境
- API 黑盒 E2E
- UI Smoke E2E
- 一键执行脚本、失败产物与文档

## 当前仓库现状

| 项目 | 现状 |
|------|------|
| 浏览器 E2E 框架 | 仓库内暂无 Playwright/Cypress |
| 现有 E2E 风格 | 只有零散的 `test/e2e_*.go`、`test/e2e_*.py` |
| 迁移相关单测/集成测 | 已较完整,`service/user_migration/service_test.go` 已覆盖主链路 |
| 自动初始化 | 已支持 `POST /api/setup` 创建 root |
| 自动登录 | 已支持 `POST /api/user/login` |
| region_sync 配置 | 走 root `option` API 写入数据库 |
| CI 工作流 | 当前 worktree 下未见 `.github/workflows/` |
| 现成 `test/e2e/` 目录 | 尚未建立 |

## 固定约定

以下约定在第一版中固定,不在实施过程中反复修改:

| 项目 | 固定值 |
|------|--------|
| CN 节点端口 | `3100` |
| OV 节点端口 | `3101` |
| CN 数据库 | `test-artifacts/user-migration-e2e/cn.db` |
| OV 数据库 | `test-artifacts/user-migration-e2e/ov.db` |
| 产物目录 | `test-artifacts/user-migration-e2e/` |
| CN session 名 | `cn_session` |
| OV session 名 | `ov_session` |
| Sync API Key | `e2e-sync-key` |
| 第一版运行方式 | 双进程 + 双 SQLite,不先上 Docker |
| 第一版 UI 覆盖 | Playwright smoke,不做重业务断言 |

## 非目标

本计划明确不做以下事情:

- 第一版不引入 Docker Compose
- 第一版不把所有迁移正确性断言都放到浏览器层
- 第一版不先接 CI,再回头补本地一键执行
- 第一版不做全站级别的通用 E2E 基建重构

## 交付物

本计划完成后,仓库中应至少新增以下内容:

- `test/e2e/` Go API 黑盒 E2E
- `test/e2e/helpers/` 编排、登录、配置、seed、断言 helper
- `web/e2e/` Playwright UI smoke 测试
- `web/playwright.config.ts`
- `scripts/run-user-migration-e2e.ps1`
- `test-artifacts/user-migration-e2e/.gitignore`
- `docs/testing/user-migration-e2e.md`

## 总体执行顺序

严格按以下顺序推进,避免返工:

1. 建目录与产物骨架
2. 做双节点进程编排
3. 做 HTTP / root setup / login / option 配置 helper
4. 做 seed 和断言 helper
5. 做 API E2E 主链路
6. 做 API E2E 边界场景
7. 再上 Playwright 基础设施
8. 做 UI smoke
9. 做一键执行脚本
10. 做文档
11. 做全量回归验证
12. 视情况接 CI

## 阶段 0:冻结基础决策

**目标**:锁定实现边界,避免中途改架构。

**任务**

- [ ] 确认 API E2E 使用 Go
- [ ] 确认 UI E2E 使用 Playwright
- [ ] 确认第一版仅做 UI smoke
- [ ] 确认双节点端口、数据库文件路径、session 名称
- [ ] 确认第一版不做 Docker 化

**验收**

- 后续阶段不再调整端口、数据库路径、session 命名

## 阶段 1:目录与文档骨架

**目标**:先把结构搭起来。

**文件**

- Create: `test/e2e/`
- Create: `test/e2e/helpers/`
- Create: `web/e2e/`
- Create: `test-artifacts/user-migration-e2e/.gitignore`
- Create: `docs/testing/user-migration-e2e.md`

**任务**

- [ ] 建立 `test/e2e/` 和 `test/e2e/helpers/`
- [ ] 建立 `web/e2e/`
- [ ] 建立 `test-artifacts/user-migration-e2e/`
- [ ] 写入产物目录 `.gitignore`
- [ ] 创建 E2E 文档骨架

**验收**

- 所有目录和骨架文件已存在

**建议提交**

- `chore(e2e): scaffold user migration e2e directories`

## 阶段 2:双节点进程编排

**目标**:自动拉起 `cn` 和 `ov` 两个真实后端实例。

**文件**

- Create: `test/e2e/helpers/cluster.go`

**任务**

- [ ] 定义 `NodeProcess`
- [ ] 定义 `Cluster`
- [ ] 实现 `StartNode`
- [ ] 实现 `StartCluster`
- [ ] 实现 `WaitReady`
- [ ] 实现 `Stop`
- [ ] 将 stdout/stderr 重定向到 `cn.log`、`ov.log`

**强制环境变量**

- `PORT`
- `SQLITE_PATH`
- `SESSION_SECRET`
- `SESSION_NAME`
- `SESSION_SECURE=false`
- `SESSION_SAMESITE=lax`
- `GLOBAL_API_RATE_LIMIT_ENABLE=false`
- `GLOBAL_WEB_RATE_LIMIT_ENABLE=false`
- `CRITICAL_RATE_LIMIT_ENABLE=false`
- `GIN_MODE=release`

**节点差异**

- CN:`NODE_TYPE=master`
- OV:`NODE_TYPE=slave`

**验收**

- `3100` 和 `3101` 均能返回 `/api/status`
- 测试退出时双进程都能被回收

**建议提交**

- `test(e2e): add dual-node cluster runner for migration tests`

## 阶段 3:HTTP 客户端与认证 helper

**目标**:封装请求、会话、root 初始化和登录。

**文件**

- Create: `test/e2e/helpers/http_client.go`
- Create: `test/e2e/helpers/auth.go`

**任务**

- [ ] 定义 `APIClient`
- [ ] 实现 `GetJSON`
- [ ] 实现 `PostJSON`
- [ ] 实现 `PutJSON`
- [ ] 实现 `DeleteJSON`
- [ ] 支持 cookie jar
- [ ] 失败时落请求/响应 dump
- [ ] 实现 `SetupRoot`
- [ ] 实现 `Login`
- [ ] 实现 root 登录快捷函数

**验收**

- 可通过 `POST /api/setup` 自动创建 root
- 可通过 `POST /api/user/login` 自动拿到会话

**建议提交**

- `test(e2e): add setup and session helpers for migration tests`

## 阶段 4:region_sync 配置 helper

**目标**:通过 root `option` API 配好双节点联通。

**文件**

- Create: `test/e2e/helpers/options.go`

**任务**

- [ ] 实现 `SetOption`
- [ ] 实现 `ConfigureCNRegionSync`
- [ ] 实现 `ConfigureOVRegionSync`
- [ ] 实现 `WaitOptionApplied`

**需要写入的配置**

CN 侧:

- `region_sync.enabled = true`
- `region_sync.region_id = "cn"`
- `region_sync.is_master = true`
- `region_sync.master_endpoint = "http://127.0.0.1:3100"`
- `region_sync.slave_endpoints = ["http://127.0.0.1:3101"]`
- `region_sync.sync_api_key = "e2e-sync-key"`

OV 侧:

- `region_sync.enabled = true`
- `region_sync.region_id = "ov"`
- `region_sync.is_master = false`
- `region_sync.master_endpoint = "http://127.0.0.1:3100"`
- `region_sync.slave_endpoints = []`
- `region_sync.sync_api_key = "e2e-sync-key"`

**注意**

- `slave_endpoints` 通过 JSON 字符串写入,不要直接写 Go slice 的字符串表示

**验收**

- CN 能通过真实 HTTP 访问 OV 的内部迁移接口

**建议提交**

- `test(e2e): add region sync configuration helpers`

## 阶段 5:测试 seed helper

**目标**:每次测试从固定数据出发,结果稳定。

**文件**

- Create: `test/e2e/helpers/seed.go`

**任务**

- [ ] 实现 `ResetDB`
- [ ] 实现 `OpenSQLite`
- [ ] 实现 `SeedCNData`
- [ ] 实现 `SeedOVData`
- [ ] 实现 `SeedOVSyncedCopy`
- [ ] 可选:实现 `SeedCustomOAuthData`

**固定数据集**

CN:

- `cn-merge-target`
- `cn-synced-copy-owner`
- `taken-username`
- `aff-owner`

OV:

- `ov-create`
- `ov-merge`
- `ov-hard-synced`
- `ov-username-conflict`
- `ov-aff-soft`

额外:

- 一条 OV `source=synced` 用户,`remote_user_id` 指向 `cn-synced-copy-owner`

**验收**

- 重复跑测试时,扫描结果和执行结果稳定一致

**建议提交**

- `test(e2e): add migration seed data for cn and ov nodes`

## 阶段 6:断言与轮询 helper

**目标**:把复杂轮询和 DB 断言收敛到 helper,主测试保持清晰。

**文件**

- Create: `test/e2e/helpers/assert.go`

**任务**

- [ ] 实现 `WaitBatchStatus`
- [ ] 实现 `GetBatchDetail`
- [ ] 实现 `FindItemBySourceUserID`
- [ ] 实现 `AssertCNUserExists`
- [ ] 实现 `AssertOVUserSynced`
- [ ] 实现 `AssertQuota`
- [ ] 实现 `AssertNoDuplicateQuotaGrant`

**验收**

- 主测试文件中不再出现重复的轮询和 JSON 解包样板

**建议提交**

- `test(e2e): add assertion helpers for migration flow`

## 阶段 7:API E2E 主测试骨架

**目标**:建立统一生命周期和测试入口。

**文件**

- Create: `test/e2e/user_migration_test.go`

**任务**

- [ ] 实现 `TestMain`
- [ ] 在 `TestMain` 中启动 cluster
- [ ] 在 `TestMain` 中 setup root
- [ ] 在 `TestMain` 中 login root
- [ ] 在 `TestMain` 中配置 region sync
- [ ] 为每个测试实现 DB 重建与 seed

**建议**

- 为保证稳定性,每个测试单独重建数据库并重启双节点

**验收**

- 测试环境可完全自动初始化,无人工前置

**建议提交**

- `test(e2e): add user migration test main lifecycle`

## 阶段 8:API E2E P0 主链路

**目标**:先证明迁移全链路真实可用。

**文件**

- Modify: `test/e2e/user_migration_test.go`

**必须实现的用例**

- [ ] `TestUserMigrationE2E_FullFlow_GrayScenarios`
- [ ] `TestUserMigrationE2E_IdempotentRetry`
- [ ] `TestUserMigrationE2E_ExecutionTimeDriftFails`

**`FullFlow_GrayScenarios` 覆盖点**

- 创建批次
- 调 `scan`
- 检查 5 类用户扫描结果
- 冲突项人工 `resolve`
- 执行 `execute`
- 调 `verify`
- 校验 CN 用户状态
- 校验 OV `source=synced`
- 校验 `remote_user_id`
- 校验 `synced_quota == cn.quota`

**验收命令**

- `go test -v ./test/e2e -run TestUserMigrationE2E_FullFlow_GrayScenarios -count=1`
- `go test -v ./test/e2e -run TestUserMigrationE2E_IdempotentRetry -count=1`
- `go test -v ./test/e2e -run TestUserMigrationE2E_ExecutionTimeDriftFails -count=1`

**建议提交**

- `test(e2e): add core migration flow tests`

## 阶段 9:API E2E P1 边界与稳定性

**目标**:补齐重扫、分页、权限、异常输入等场景。

**文件**

- Modify: `test/e2e/user_migration_test.go`

**必须实现的用例**

- [ ] `TestUserMigrationE2E_RescanPreservesHumanDecision`
- [ ] `TestUserMigrationE2E_ScanPagination`
- [ ] `TestUserMigrationE2E_RootPermission`
- [ ] `TestUserMigrationE2E_ResolveInvalidPayload`
- [ ] `TestUserMigrationE2E_ScanConflict_WhenAlreadyScanning`
- [ ] `TestUserMigrationE2E_ExecuteConflict_WhenAlreadyRunning`
- [ ] `TestUserMigrationE2E_VerifyQuotaMismatchDetected`
- [ ] `TestUserMigrationE2E_SkipNeverConvertsRemote`

**验收命令**

- `go test -v ./test/e2e -count=1`

**建议提交**

- `test(e2e): add migration edge case coverage`

## 阶段 10:Playwright 基础设施

**目标**:搭 UI smoke 运行底座。

**文件**

- Create: `web/playwright.config.ts`
- Create: `web/e2e/global-setup.ts`
- Create: `web/e2e/global-teardown.ts`
- Create: `web/e2e/fixtures/cluster.ts`

**任务**

- [ ] 安装 `@playwright/test`
- [ ] 配置 `testDir`
- [ ] 配置 `trace`
- [ ] 配置 `screenshot`
- [ ] 配置 `video`
- [ ] 在 `globalSetup` 中启动双节点
- [ ] 在 `globalSetup` 中 setup root
- [ ] 在 `globalSetup` 中创建普通用户
- [ ] 在 `globalSetup` 中 seed UI 测试数据
- [ ] 生成 `root-storage.json`
- [ ] 生成 `user-storage.json`
- [ ] 在 `globalTeardown` 中回收进程

**验收**

- `cd web && bunx playwright test --list` 可正常执行

**建议提交**

- `test(ui): add playwright infrastructure for migration smoke tests`

## 阶段 11:UI Smoke 测试

**目标**:验证 root 迁移中心页面真正可用。

**文件**

- Create: `web/e2e/user-migration.spec.ts`
- Modify: `web/src/components/table/users/migration/*`(如需补 `data-testid`)

**必须实现的用例**

- [ ] `root sees migration tab`
- [ ] `non-root cannot see migration tab`
- [ ] `can create batch`
- [ ] `scan updates status with polling`
- [ ] `can resolve conflict item`
- [ ] `execute shows freeze-window confirmation`
- [ ] `verify results table is rendered`
- [ ] `page refresh preserves data loading`

**规则**

- UI 层只断言页面行为,不做 DB 最终值断言
- 重业务正确性仍由 API E2E 兜底

**验收**

- `cd web && bunx playwright test`

**建议提交**

- `test(ui): add migration center smoke tests`

## 阶段 12:一键执行脚本

**目标**:本地单命令跑完全部 E2E。

**文件**

- Create: `scripts/run-user-migration-e2e.ps1`

**任务**

- [ ] 支持 `-ApiOnly`
- [ ] 支持 `-UiOnly`
- [ ] 支持 `-All`
- [ ] 先清理旧产物
- [ ] 跑 API E2E
- [ ] 跑 UI E2E
- [ ] 汇总结果
- [ ] 失败时保留日志、DB、trace

**验收**

- 一条命令可以跑完整套测试

**建议提交**

- `chore(e2e): add user migration e2e runner`

## 阶段 13:文档补全

**目标**:让别人能跑、能排查、能扩展。

**文件**

- Modify: `docs/testing/user-migration-e2e.md`

**任务**

- [ ] 写明测试架构
- [ ] 写明覆盖范围
- [ ] 写明本地运行命令
- [ ] 写明失败产物位置
- [ ] 写明常见失败与排查方式
- [ ] 写明如何新增迁移场景

**验收**

- 新同事可按文档独立跑通

**建议提交**

- `docs(e2e): document user migration end-to-end tests`

## 阶段 14:总体验证

**目标**:确认新增 E2E 不破坏现有代码。

**必须执行**

- [ ] `go test ./...`
- [ ] `go test -v ./test/e2e -count=1`
- [ ] `cd web && bun run build`
- [ ] `cd web && bunx playwright test`

**验收**

- 后端单测全绿
- API E2E 全绿
- 前端构建通过
- UI smoke 全绿

## 阶段 15:可选 CI 接入

**目标**:将 E2E 纳入自动化执行。

**说明**

当前仓库中尚未看到 `.github/workflows/`,本阶段默认后置,不作为本轮硬性前置条件。

**任务**

- [ ] 新增 API E2E job
- [ ] 新增 UI smoke job
- [ ] 上传 `test-artifacts/user-migration-e2e/`

**验收**

- CI 可独立稳定执行,失败时能下载产物

## 开始前仍需要的最少步骤

如果要立即开始实施,正式开工前只需要做这些最小动作:

- [ ] 确认第一批只做 API E2E 基础设施
- [ ] 确认第一版进程启动方式是 `go run .`
- [ ] 确认允许在迁移前端组件中补 `data-testid`
- [ ] 确认第一版先不接 CI

确认完后,直接从**阶段 1**开始执行,不需要额外准备。

## 最终完成定义

只有同时满足以下条件,才算该 E2E 计划完成:

- `test/e2e` API E2E 可稳定运行
- `web/e2e` UI smoke 可稳定运行
- 一键执行脚本可用
- 失败产物可保留
- 文档完整
- `go test ./...` 通过
- `cd web && bun run build` 通过


+ 176
- 0
docs/testing/user-migration-e2e.md View File

@@ -0,0 +1,176 @@
# 用户迁移 E2E

## 范围

当前已落地两层 E2E:

- API 黑盒 E2E:真实双节点、真实 HTTP、真实 SQLite、真实 `region_sync`
- UI Smoke E2E:Playwright 驱动真实浏览器访问 `/console/user` 的迁移中心

对应代码位置:

- `test/e2e/user_migration_test.go`
- `test/e2e/helpers/`
- `test/e2e/cmd/user_migration_ui_server/main.go`
- `web/playwright.config.ts`
- `web/e2e/`
- `scripts/run-user-migration-e2e.ps1`

## 已覆盖用例

### API E2E

- `TestUserMigrationE2E_FullFlow_GrayScenarios`
- `TestUserMigrationE2E_IdempotentRetry`
- `TestUserMigrationE2E_ExecutionTimeDriftFails`
- `TestUserMigrationE2E_RescanPreservesHumanDecision`
- `TestUserMigrationE2E_ScanPagination`
- `TestUserMigrationE2E_RootPermission`
- `TestUserMigrationE2E_ResolveInvalidPayload`
- `TestUserMigrationE2E_ScanConflict_WhenAlreadyScanning`
- `TestUserMigrationE2E_ExecuteConflict_WhenAlreadyRunning`
- `TestUserMigrationE2E_VerifyQuotaMismatchDetected`
- `TestUserMigrationE2E_SkipNeverConvertsRemote`

### UI Smoke E2E

- 非 root 管理员看不到“用户迁移”页签
- root 可以进入迁移中心并创建批次
- 扫描状态能通过页面轮询从 `scanning` 进入 `scanned`
- 冲突项可以在页面中执行人工决策
- 执行前会出现冻结窗口确认
- 执行完成后可以触发 verify 并看到结果表
- 页面刷新后,批次列表和详情仍可重新加载

## 固定约定

- CN 端口:`3100`
- OV 端口:`3101`
- CN session:`cn_session`
- OV session:`ov_session`
- Sync API Key:`e2e-sync-key`
- API E2E 运行目录:`test-artifacts/user-migration-e2e/runs/<test-name>/`
- UI E2E 运行目录:`test-artifacts/user-migration-e2e/runs/playwright-ui/`

## 运行方式

只跑 API E2E:

```powershell
go test -v ./test/e2e -count=1
```

只跑 UI E2E:

```powershell
cd web
bunx playwright test
```

通过统一脚本执行:

```powershell
./scripts/run-user-migration-e2e.ps1 -ApiOnly
./scripts/run-user-migration-e2e.ps1 -UiOnly
./scripts/run-user-migration-e2e.ps1 -All
```

## UI E2E 依赖

Playwright 当前使用系统 Edge:

- `web/playwright.config.ts` 中固定 `channel: 'msedge'`
- 不需要额外下载 Chromium 浏览器

首次在新机器上跑 UI E2E,需要安装 Playwright 的 `ffmpeg`,否则失败视频无法生成:

```powershell
cd web
bunx playwright install ffmpeg
```

## UI E2E 启动流程

`bunx playwright test` 会自动执行以下流程:

1. `global-setup.ts` 先清理 `3100/3101` 残留监听进程
2. 构建前端 `web/dist`
3. 构建 `test/e2e/cmd/user_migration_ui_server`
4. 启动 UI helper,helper 内部调用 `helpers.PrepareEnvironment(...)`
5. helper 完成双节点准备后写入 `test-artifacts/user-migration-e2e/playwright/server-ready.json`
6. `global-setup.ts` 读取 ready 标记,再登录 root,并生成两套 storage state
- `root-storage.json`
- `user-storage.json`(当前实际保存的是“非 root 管理员”会话,因为 `/console/user` 受 `AdminRoute` 保护)
7. 测试结束后由 `global-teardown.ts` 回收 UI helper 进程树

## 产物位置

### API 产物

每个 API 用例的产物都在:

```text
test-artifacts/user-migration-e2e/runs/<test-name>/
```

常见文件:

- `cn.db`
- `ov.db`
- `cn.log`
- `ov.log`
- `system-logs/cn/*`
- `system-logs/ov/*`

### UI 产物

Playwright 相关产物在:

```text
test-artifacts/user-migration-e2e/playwright/
```

常见文件:

- `metadata.json`
- `server-ready.json`
- `ui-server.log`
- `auth/root-storage.json`
- `auth/user-storage.json`
- `html-report/`
- `test-output/<case>/trace.zip`
- `test-output/<case>/video.webm`
- `test-output/<case>/test-failed-1.png`

## 排查建议

如果 API E2E 失败,优先看:

- `test-artifacts/user-migration-e2e/runs/<test-name>/cn.log`
- `test-artifacts/user-migration-e2e/runs/<test-name>/ov.log`
- `cn.db` 中的 `user_migration_batches`
- `cn.db` 中的 `user_migration_items`
- `ov.db` 中的 `users`

如果 UI E2E 失败,优先看:

- `test-artifacts/user-migration-e2e/playwright/ui-server.log`
- `test-artifacts/user-migration-e2e/playwright/test-output/<case>/error-context.md`
- `trace.zip`
- 失败截图和视频

## 如何新增迁移场景

新增 API 迁移场景时,优先按下面顺序扩展:

1. 在 `test/e2e/helpers/seed.go` 里补固定 seed 或新的冲突样本
2. 在 `test/e2e/helpers/assert.go` 里补通用断言或轮询逻辑
3. 在 `test/e2e/user_migration_test.go` 里新增独立测试,尽量复用 `prepareGrayScenarioBatch` 一类的局部 helper
4. 如果是跨节点准备逻辑变化,优先改 `test/e2e/helpers/cluster.go` 或 `options.go`

新增 UI smoke 场景时,优先按下面顺序扩展:

1. 先判断是否需要新的稳定选择器;需要时只在迁移中心组件上补 `data-testid`
2. 在 `web/e2e/user-migration.spec.ts` 中新增页面行为断言,不做 DB 最终值断言
3. 如果需要新的登录态或前置环境,改 `web/e2e/global-setup.ts`
4. 若新增场景改变运行产物或依赖,再同步更新本文档

+ 60
- 1
model/region_migration_user_test.go View File

@@ -1,6 +1,7 @@
package model

import (
"errors"
"testing"

"github.com/QuantumNous/new-api/common"
@@ -152,7 +153,7 @@ func TestConvertUserToSynced_DifferentRemoteUserIdFails(t *testing.T) {

err := ConvertUserToSynced(10000002, 9999, 100)
require.Error(t, err)
require.Contains(t, err.Error(), "already synced")
require.ErrorIs(t, err, ErrSyncedUserRemoteUserIDImmutable)
}

func TestListLocalUsersForMigration_FiltersLocalOnly(t *testing.T) {
@@ -178,6 +179,14 @@ func TestListLocalUsersForMigration_FiltersLocalOnly(t *testing.T) {
Source: common.UserSourceSynced,
RemoteUserId: 1,
}).Error)
require.NoError(t, db.Create(&User{
Id: 10000003,
Username: "root",
Password: "h",
AffCode: "R1",
Source: common.UserSourceLocal,
Role: common.RoleRootUser,
}).Error)

users, total, err := ListLocalUsersForMigration(0, 20)
require.NoError(t, err)
@@ -185,3 +194,53 @@ func TestListLocalUsersForMigration_FiltersLocalOnly(t *testing.T) {
require.Len(t, users, 1)
require.Equal(t, "local-1", users[0].Username)
}

func TestConvertUserToSynced_RejectsRootUser(t *testing.T) {
db := setupRegionMigrationUserDB(t)
orig := DB
DB = db
defer func() {
DB = orig
}()

require.NoError(t, db.Create(&User{
Id: 10000010,
Username: "root",
Password: "hash",
AffCode: "ROOT1",
Source: common.UserSourceLocal,
Role: common.RoleRootUser,
}).Error)

err := ConvertUserToSynced(10000010, 2001, 88)
require.Error(t, err)
require.ErrorIs(t, err, ErrConvertRootUserToSynced)

var saved User
require.NoError(t, db.First(&saved, 10000010).Error)
require.Equal(t, common.UserSourceLocal, saved.Source)
require.Equal(t, 0, saved.RemoteUserId)
}

func TestConvertUserToSynced_SentinelErrorsRemainDistinct(t *testing.T) {
db := setupRegionMigrationUserDB(t)
orig := DB
DB = db
defer func() {
DB = orig
}()

require.NoError(t, db.Create(&User{
Id: 10000020,
Username: "already-synced",
Password: "hash",
AffCode: "SYNC20",
Source: common.UserSourceSynced,
RemoteUserId: 3001,
}).Error)

err := ConvertUserToSynced(10000020, 9999, 100)
require.Error(t, err)
require.True(t, errors.Is(err, ErrSyncedUserRemoteUserIDImmutable))
require.False(t, errors.Is(err, ErrConvertRootUserToSynced))
}

+ 13
- 2
model/user.go View File

@@ -1108,7 +1108,9 @@ func ListLocalUsersForMigration(page, pageSize int) ([]*User, int64, error) {

var users []*User
var total int64
query := DB.Model(&User{}).Where("source = ?", common.UserSourceLocal)
query := DB.Model(&User{}).
Where("source = ?", common.UserSourceLocal).
Where("role <> ?", common.RoleRootUser)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
@@ -1124,16 +1126,25 @@ func HasOVSyncedCopyByRemoteId(cnUserId int) (bool, error) {
return count > 0, err
}

var (
ErrConvertRootUserToSynced = errors.New("user.convert_root_to_synced_denied")
ErrSyncedUserRemoteUserIDImmutable = errors.New("user.synced_remote_user_id_immutable")
)

func ConvertUserToSynced(userId, remoteUserId, syncedQuota int) error {
var user User
if err := DB.Where("id = ?", userId).First(&user).Error; err != nil {
return err
}
if user.Role == common.RoleRootUser {
return ErrConvertRootUserToSynced
}

now := common.GetTimestamp()
if user.Source == common.UserSourceSynced {
if user.RemoteUserId != remoteUserId {
return fmt.Errorf("user %d is already synced to remote_user_id=%d, cannot change to %d",
return fmt.Errorf("%w: user %d is already synced to remote_user_id=%d, cannot change to %d",
ErrSyncedUserRemoteUserIDImmutable,
userId, user.RemoteUserId, remoteUserId)
}
if user.SyncedQuota == syncedQuota {


+ 5
- 1
model/user_migration.go View File

@@ -182,13 +182,14 @@ func RefreshUserMigrationBatchStats(batchId int) error {
}

func RefreshUserMigrationBatchExecutionStats(batchId int) error {
var migrated, failed, pending int64
var migrated, failed, pending, migrating int64
DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status = ?", batchId, UserMigrationItemStatusMigrated).Count(&migrated)
DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status = ?", batchId, UserMigrationItemStatusFailed).Count(&failed)
DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status IN ?", batchId, []string{
UserMigrationItemStatusReady,
UserMigrationItemStatusMigrating,
}).Count(&pending)
DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status = ?", batchId, UserMigrationItemStatusMigrating).Count(&migrating)

status := UserMigrationBatchStatusCompleted
if failed > 0 {
@@ -197,6 +198,9 @@ func RefreshUserMigrationBatchExecutionStats(batchId int) error {
if pending > 0 {
status = UserMigrationBatchStatusRunning
}
if pending == migrating && migrating > 0 {
status = UserMigrationBatchStatusFailed
}

return DB.Model(&UserMigrationBatch{}).Where("id = ?", batchId).Updates(map[string]any{
"migrated_users": int(migrated),


+ 33
- 0
model/user_migration_test.go View File

@@ -179,3 +179,36 @@ func TestRefreshBatchStats_ReadyOnlyWhenNoPendingScan(t *testing.T) {
require.NoError(t, DB.First(&refreshed, batch.Id).Error)
require.Equal(t, UserMigrationBatchStatusScanned, refreshed.Status)
}

func TestRefreshUserMigrationBatchExecutionStats_MigratingLeakHandled(t *testing.T) {
db := setupUserMigrationTestDB(t)
orig := DB
DB = db
defer func() {
DB = orig
}()

batch := &UserMigrationBatch{
Name: "migrating-leak",
SourceRegion: "ov",
TargetRegion: "cn",
OperatorId: 1,
Status: UserMigrationBatchStatusRunning,
}
require.NoError(t, CreateUserMigrationBatch(batch))

require.NoError(t, DB.Create(&UserMigrationItem{
BatchId: batch.Id,
SourceUserId: 101,
SourceUsername: "stuck-user",
Status: UserMigrationItemStatusMigrating,
}).Error)

require.NoError(t, RefreshUserMigrationBatchExecutionStats(batch.Id))

var refreshed UserMigrationBatch
require.NoError(t, DB.First(&refreshed, batch.Id).Error)
require.Equal(t, UserMigrationBatchStatusFailed, refreshed.Status)
require.Equal(t, 0, refreshed.MigratedUsers)
require.Equal(t, 0, refreshed.FailedUsers)
}

+ 155
- 0
router/user_migration_router_test.go View File

@@ -0,0 +1,155 @@
package router

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func setupUserMigrationRouterDB(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 setupUserMigrationRootRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
store := cookie.NewStore([]byte("test-session-secret"))
store.Options(sessions.Options{
Path: "/",
MaxAge: 3600,
HttpOnly: true,
Secure: false,
})
r.Use(sessions.Sessions("session", store))

userMigrationRoute := r.Group("/api/user-migrations")
userMigrationRoute.Use(func(c *gin.Context) {
session := sessions.Default(c)
session.Set("username", "root")
session.Set("role", common.RoleRootUser)
session.Set("id", 1)
session.Set("status", common.UserStatusEnabled)
session.Save()
c.Request.Header.Set("New-Api-User", "1")
c.Next()
}, middleware.RootAuth())
{
userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches)
userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch)
}

return r
}

func setupUserMigrationAdminRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
store := cookie.NewStore([]byte("test-session-secret"))
store.Options(sessions.Options{
Path: "/",
MaxAge: 3600,
HttpOnly: true,
Secure: false,
})
r.Use(sessions.Sessions("session", store))

userMigrationRoute := r.Group("/api/user-migrations")
userMigrationRoute.Use(func(c *gin.Context) {
session := sessions.Default(c)
session.Set("username", "admin")
session.Set("role", common.RoleAdminUser)
session.Set("id", 2)
session.Set("status", common.UserStatusEnabled)
session.Save()
c.Request.Header.Set("New-Api-User", "2")
c.Next()
}, middleware.RootAuth())
{
userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches)
userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch)
}

return r
}

func TestUserMigrationRoutes_RootCanAccess(t *testing.T) {
db := setupUserMigrationRouterDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

require.NoError(t, db.Create(&model.UserMigrationBatch{
Id: 1,
Name: "router-batch",
SourceRegion: "ov",
TargetRegion: "cn",
OperatorId: 1,
Status: model.UserMigrationBatchStatusDraft,
}).Error)

router := setupUserMigrationRootRouter()

req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/batches", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)

req = httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/execute", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
require.NotEqual(t, http.StatusUnauthorized, w.Code)
}

func TestUserMigrationRoutes_AdminForbidden(t *testing.T) {
db := setupUserMigrationRouterDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

router := setupUserMigrationAdminRouter()

req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/batches", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), "success\":false")

req = httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/execute", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), "success\":false")
}

+ 63
- 0
scripts/run-user-migration-e2e.ps1 View File

@@ -0,0 +1,63 @@
param(
[switch]$ApiOnly,
[switch]$UiOnly,
[switch]$All
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$selectedModes = @(@($ApiOnly, $UiOnly, $All) | Where-Object { $_ })
if ($selectedModes.Count -gt 1) {
throw 'Cannot combine -ApiOnly, -UiOnly, and -All.'
}

if (-not $ApiOnly -and -not $UiOnly -and -not $All) {
$ApiOnly = $true
}

$repoRoot = Split-Path -Parent $PSScriptRoot
$artifactRoot = Join-Path $repoRoot 'test-artifacts/user-migration-e2e'
$runsDir = Join-Path $artifactRoot 'runs'

if ($ApiOnly -or $All) {
if (Test-Path $runsDir) {
Remove-Item -Recurse -Force $runsDir
}
}

Push-Location $repoRoot
try {
if ($ApiOnly -or $All) {
Write-Host '[user-migration-e2e] running API E2E...'
go test -v ./test/e2e -count=1
}

if ($All) {
Write-Host '[user-migration-e2e] running full Go test suite...'
go test ./...

Write-Host '[user-migration-e2e] building frontend...'
Push-Location (Join-Path $repoRoot 'web')
try {
bun run build
}
finally {
Pop-Location
}
}

if ($UiOnly -or $All) {
Write-Host '[user-migration-e2e] running Playwright UI E2E...'
Push-Location (Join-Path $repoRoot 'web')
try {
bunx playwright test
}
finally {
Pop-Location
}
}
}
finally {
Pop-Location
}

+ 47
- 21
service/user_migration/executor.go View File

@@ -14,12 +14,16 @@ func (s *Service) ExecuteBatch(batchId int) error {
if err != nil {
return err
}
providerSlugMap, err := buildProviderSlugMap()
if err != nil {
return err
}

for _, item := range items {
if item.Status == model.UserMigrationItemStatusMigrated {
continue
}
if err := s.executeItem(item); err != nil {
if err := s.executeItem(item, providerSlugMap); err != nil {
_ = model.MarkUserMigrationItemFailed(item.Id, err.Error())
}
}
@@ -27,12 +31,12 @@ func (s *Service) ExecuteBatch(batchId int) error {
return model.RefreshUserMigrationBatchExecutionStats(batchId)
}

func (s *Service) executeItem(item *model.UserMigrationItem) error {
func (s *Service) executeItem(item *model.UserMigrationItem, providerSlugMap map[string]int) error {
switch item.ResolutionStrategy {
case model.UserMigrationStrategyCreateNew:
return s.executeCreateNew(item)
return s.executeCreateNew(item, providerSlugMap)
case model.UserMigrationStrategyMergeExisting:
return s.executeMergeExisting(item)
return s.executeMergeExisting(item, providerSlugMap)
case model.UserMigrationStrategySkip:
return model.MarkUserMigrationItemSkipped(item.Id)
default:
@@ -40,8 +44,18 @@ func (s *Service) executeItem(item *model.UserMigrationItem) error {
}
}

func (s *Service) executeCreateNew(item *model.UserMigrationItem) error {
func (s *Service) executeCreateNew(item *model.UserMigrationItem, providerSlugMap map[string]int) error {
if item.TargetUserId != 0 {
snapshot, err := s.client.GetUser(item.SourceUserId)
if err != nil {
return fmt.Errorf("get remote user %d: %w", item.SourceUserId, err)
}
if err := s.revalidateCreateNew(item, snapshot, providerSlugMap); err != nil {
return err
}
if err := markItemMigrating(item.Id); err != nil {
return err
}
cnUser, err := model.GetUserById(item.TargetUserId, true)
if err != nil {
return err
@@ -53,7 +67,7 @@ func (s *Service) executeCreateNew(item *model.UserMigrationItem) error {
if err != nil {
return fmt.Errorf("get remote user %d: %w", item.SourceUserId, err)
}
if err := s.revalidateCreateNew(item, snapshot); err != nil {
if err := s.revalidateCreateNew(item, snapshot, providerSlugMap); err != nil {
return err
}
if err := markItemMigrating(item.Id); err != nil {
@@ -113,12 +127,12 @@ func (s *Service) executeCreateNew(item *model.UserMigrationItem) error {
return s.ensureRemoteConverted(item, cnUser.Quota)
}

func (s *Service) executeMergeExisting(item *model.UserMigrationItem) error {
func (s *Service) executeMergeExisting(item *model.UserMigrationItem, providerSlugMap map[string]int) error {
snapshot, err := s.client.GetUser(item.SourceUserId)
if err != nil {
return fmt.Errorf("get remote user %d: %w", item.SourceUserId, err)
}
if err := s.revalidateMergeExisting(item, snapshot); err != nil {
if err := s.revalidateMergeExisting(item, snapshot, providerSlugMap); err != nil {
return err
}
if err := markItemMigrating(item.Id); err != nil {
@@ -197,24 +211,40 @@ func (s *Service) ensureRemoteConverted(item *model.UserMigrationItem, syncedQuo
return markItemMigrated(item.Id)
}

func (s *Service) revalidateCreateNew(item *model.UserMigrationItem, snapshot *RemoteUserSnapshot) error {
func (s *Service) revalidateCreateNew(item *model.UserMigrationItem, snapshot *RemoteUserSnapshot, providerSlugMap map[string]int) error {
if snapshot.Source == common.UserSourceSynced {
if item.TargetUserId != 0 && snapshot.RemoteUserId == item.TargetUserId {
return nil
}
return fmt.Errorf("execution-time identity drift detected, remote user %d is already synced, rescan required", item.SourceUserId)
}

result, err := s.analyzeExecutionSnapshot(snapshot)
result, err := s.analyzeExecutionSnapshot(snapshot, providerSlugMap)
if err != nil {
return err
}
if result.Status != model.UserMigrationItemStatusReady ||
result.Strategy != model.UserMigrationStrategyCreateNew ||
result.TargetCNUserID != 0 {
if item.TargetUserId == 0 {
if result.Status != model.UserMigrationItemStatusReady ||
result.Strategy != model.UserMigrationStrategyCreateNew ||
result.TargetCNUserID != 0 {
return fmt.Errorf("execution-time identity drift detected, rescan required")
}
return nil
}

if result.Status != model.UserMigrationItemStatusReady {
return fmt.Errorf("execution-time identity drift detected, rescan required")
}
return nil
if result.Strategy == model.UserMigrationStrategyCreateNew && result.TargetCNUserID == 0 {
return nil
}
if result.Strategy == model.UserMigrationStrategyMergeExisting && result.TargetCNUserID == item.TargetUserId {
return nil
}
return fmt.Errorf("execution-time identity drift detected, rescan required")
}

func (s *Service) revalidateMergeExisting(item *model.UserMigrationItem, snapshot *RemoteUserSnapshot) error {
func (s *Service) revalidateMergeExisting(item *model.UserMigrationItem, snapshot *RemoteUserSnapshot, providerSlugMap map[string]int) error {
if snapshot.Source == common.UserSourceSynced {
if snapshot.RemoteUserId == item.TargetUserId {
return nil
@@ -222,7 +252,7 @@ func (s *Service) revalidateMergeExisting(item *model.UserMigrationItem, snapsho
return fmt.Errorf("execution-time identity drift detected, remote user %d is already synced to CN user %d", item.SourceUserId, snapshot.RemoteUserId)
}

result, err := s.analyzeExecutionSnapshot(snapshot)
result, err := s.analyzeExecutionSnapshot(snapshot, providerSlugMap)
if err != nil {
return err
}
@@ -234,11 +264,7 @@ func (s *Service) revalidateMergeExisting(item *model.UserMigrationItem, snapsho
return nil
}

func (s *Service) analyzeExecutionSnapshot(snapshot *RemoteUserSnapshot) (*conflictResult, error) {
providerSlugMap, err := buildProviderSlugMap()
if err != nil {
return nil, err
}
func (s *Service) analyzeExecutionSnapshot(snapshot *RemoteUserSnapshot, providerSlugMap map[string]int) (*conflictResult, error) {
result, err := analyzeConflicts(snapshot, providerSlugMap, s.client)
if err != nil {
return nil, fmt.Errorf("revalidate conflicts for user %d: %w", snapshot.Id, err)


+ 548
- 0
service/user_migration/service_test.go View File

@@ -210,6 +210,339 @@ func TestScanBatch_EmailConflict_CNHasSyncedCopy_MarksHardConflict(t *testing.T)
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
@@ -952,3 +1285,218 @@ func TestVerifyItem_QuotaMismatchDetected(t *testing.T) {
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)
}

+ 1
- 1
service/user_migration/verifier.go View File

@@ -28,6 +28,6 @@ func (s *Service) VerifyItem(item *model.UserMigrationItem) (*VerifyResult, erro
return &VerifyResult{
TargetUserExists: targetUser != nil,
RemoteConverted: remoteConverted,
QuotaMatched: targetUser != nil && remoteConverted && remoteUser.SyncedQuota == targetUser.Quota,
QuotaMatched: targetUser != nil && remoteConverted && remoteUser.SyncedQuota >= item.SourceQuota,
}, nil
}

+ 79
- 0
test/e2e/cmd/user_migration_ui_server/main.go View File

@@ -0,0 +1,79 @@
package main

import (
"context"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"

"github.com/QuantumNous/new-api/common"
helpers "github.com/QuantumNous/new-api/test/e2e/helpers"
)

func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

func run() error {
repoRoot, err := os.Getwd()
if err != nil {
return fmt.Errorf("get working directory: %w", err)
}

artifactRoot := filepath.Join(repoRoot, "test-artifacts", "user-migration-e2e")
if err := os.MkdirAll(artifactRoot, 0o755); err != nil {
return fmt.Errorf("create artifact root: %w", err)
}

binaryPath, err := helpers.BuildBinary(repoRoot, artifactRoot)
if err != nil {
return fmt.Errorf("build api binary: %w", err)
}

env, err := helpers.PrepareEnvironment(repoRoot, artifactRoot, binaryPath, "playwright-ui")
if err != nil {
return fmt.Errorf("prepare playwright environment: %w", err)
}
defer func() {
_ = env.Close()
}()

playwrightArtifactRoot := filepath.Join(artifactRoot, "playwright")
if err := os.MkdirAll(playwrightArtifactRoot, 0o755); err != nil {
return fmt.Errorf("create playwright artifact root: %w", err)
}

readyPayload, err := common.Marshal(map[string]any{
"run_dir": env.RunDir,
"base_url": env.Cluster.CN.BaseURL,
"ov_base_url": env.Cluster.OV.BaseURL,
})
if err != nil {
return fmt.Errorf("marshal ready payload: %w", err)
}
if err := os.WriteFile(
filepath.Join(playwrightArtifactRoot, "server-ready.json"),
readyPayload,
0o644,
); err != nil {
return fmt.Errorf("write ready marker: %w", err)
}

fmt.Printf(
"user migration ui e2e ready: cn=%s ov=%s run_dir=%s\n",
env.Cluster.CN.BaseURL,
env.Cluster.OV.BaseURL,
env.RunDir,
)

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

<-ctx.Done()
return nil
}

+ 215
- 0
test/e2e/helpers/assert.go View File

@@ -0,0 +1,215 @@
package helpers

import (
"fmt"
"net/http"
"testing"
"time"

"github.com/QuantumNous/new-api/model"
userMigration "github.com/QuantumNous/new-api/service/user_migration"
"github.com/stretchr/testify/require"
)

type BatchDetail struct {
Batch model.UserMigrationBatch
Items []model.UserMigrationItem
Total int64
}

type batchDetailResponse struct {
Success bool `json:"success"`
Data model.UserMigrationBatch `json:"data"`
Items []model.UserMigrationItem `json:"items"`
Total int64 `json:"total"`
}

type VerifyEntry struct {
SourceUserID int `json:"source_user_id"`
TargetUserID int `json:"target_user_id"`
Result *userMigration.VerifyResult `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}

type verifyResponse struct {
Success bool `json:"success"`
Data []VerifyEntry `json:"data"`
}

type remoteUserResponse struct {
Success bool `json:"success"`
Data userMigration.RemoteUserSnapshot `json:"data"`
}

func GetBatchDetail(client *APIClient, batchID int) (*BatchDetail, error) {
var resp batchDetailResponse
if err := client.GetJSON(fmt.Sprintf("/api/user-migrations/batches/%d?page=0&page_size=100", batchID), http.StatusOK, &resp); err != nil {
return nil, err
}
if !resp.Success {
return nil, fmt.Errorf("get batch detail %d failed", batchID)
}
return &BatchDetail{
Batch: resp.Data,
Items: resp.Items,
Total: resp.Total,
}, nil
}

func WaitBatchStatus(client *APIClient, batchID int, expectedStatus string, timeout time.Duration) (*BatchDetail, error) {
deadline := time.Now().Add(timeout)
lastStatus := ""

for time.Now().Before(deadline) {
detail, err := GetBatchDetail(client, batchID)
if err == nil {
lastStatus = detail.Batch.Status
if detail.Batch.Status == expectedStatus {
return detail, nil
}
}
time.Sleep(250 * time.Millisecond)
}

return nil, fmt.Errorf("batch %d did not reach status %q within %s, last status=%q", batchID, expectedStatus, timeout, lastStatus)
}

func VerifyBatch(client *APIClient, batchID int) ([]VerifyEntry, error) {
var resp verifyResponse
if err := client.PostJSON(fmt.Sprintf("/api/user-migrations/batches/%d/verify", batchID), map[string]any{}, http.StatusOK, &resp); err != nil {
return nil, err
}
if !resp.Success {
return nil, fmt.Errorf("verify batch %d failed", batchID)
}
return resp.Data, nil
}

func FindItemBySourceUserID(items []model.UserMigrationItem, sourceUserID int) *model.UserMigrationItem {
for i := range items {
if items[i].SourceUserId == sourceUserID {
return &items[i]
}
}
return nil
}

func GetRemoteUserSnapshot(baseURL string, userID int) (*userMigration.RemoteUserSnapshot, error) {
client, err := NewAPIClient(baseURL)
if err != nil {
return nil, err
}

var resp remoteUserResponse
if err := client.DoJSONWithHeaders(
http.MethodGet,
fmt.Sprintf("/api/internal/migration/users/%d", userID),
nil,
http.StatusOK,
map[string]string{
"X-Sync-API-Key": DefaultSyncAPIKey,
"X-Sync-Node": "e2e-assert",
},
&resp,
); err != nil {
return nil, err
}
if !resp.Success {
return nil, fmt.Errorf("get remote user snapshot %d failed", userID)
}
return &resp.Data, nil
}

func AssertCNUserExists(t *testing.T, dbPath, username string) *model.User {
t.Helper()

db, err := OpenSQLite(dbPath)
require.NoError(t, err)
defer func() {
require.NoError(t, CloseSQLite(db))
}()

var user model.User
require.NoError(t, db.Where("username = ?", username).First(&user).Error)
return &user
}

func LoadCNUserByEmail(t *testing.T, dbPath, email string) *model.User {
t.Helper()

db, err := OpenSQLite(dbPath)
require.NoError(t, err)
defer func() {
require.NoError(t, CloseSQLite(db))
}()

var user model.User
require.NoError(t, db.Where("email = ?", email).First(&user).Error)
return &user
}

func AssertOVUserSynced(t *testing.T, ovBaseURL string, userID, targetUserID, syncedQuota int) {
t.Helper()

snapshot, err := GetRemoteUserSnapshot(ovBaseURL, userID)
require.NoError(t, err)
require.Equal(t, "synced", snapshot.Source)
require.Equal(t, targetUserID, snapshot.RemoteUserId)
require.Equal(t, syncedQuota, snapshot.SyncedQuota)
}

func AssertQuota(t *testing.T, dbPath string, userID, quota int) {
t.Helper()

db, err := OpenSQLite(dbPath)
require.NoError(t, err)
defer func() {
require.NoError(t, CloseSQLite(db))
}()

var user model.User
require.NoError(t, db.Select("id", "quota").First(&user, userID).Error)
require.Equal(t, quota, user.Quota)
}

func AssertNoDuplicateQuotaGrant(t *testing.T, dbPath, migrationToken string) {
t.Helper()

db, err := OpenSQLite(dbPath)
require.NoError(t, err)
defer func() {
require.NoError(t, CloseSQLite(db))
}()

var count int64
require.NoError(t, db.Model(&model.MigrationQuotaGrant{}).Where("migration_token = ?", migrationToken).Count(&count).Error)
require.EqualValues(t, 1, count)
}

func CountUsersByUsername(t *testing.T, dbPath, username string) int64 {
t.Helper()

db, err := OpenSQLite(dbPath)
require.NoError(t, err)
defer func() {
require.NoError(t, CloseSQLite(db))
}()

var count int64
require.NoError(t, db.Model(&model.User{}).Where("username = ?", username).Count(&count).Error)
return count
}

func CountUsersByEmail(t *testing.T, dbPath, email string) int64 {
t.Helper()

db, err := OpenSQLite(dbPath)
require.NoError(t, err)
defer func() {
require.NoError(t, CloseSQLite(db))
}()

var count int64
require.NoError(t, db.Model(&model.User{}).Where("email = ?", email).Count(&count).Error)
return count
}

+ 75
- 0
test/e2e/helpers/auth.go View File

@@ -0,0 +1,75 @@
package helpers

import "fmt"

type setupStatusResponse struct {
Success bool `json:"success"`
Data struct {
Status bool `json:"status"`
RootInit bool `json:"root_init"`
} `json:"data"`
Message string `json:"message,omitempty"`
}

type setupResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}

type loginResponse struct {
Success bool `json:"success"`
Data struct {
ID int `json:"id"`
} `json:"data"`
Message string `json:"message,omitempty"`
}

func SetupRoot(client *APIClient, username, password string) error {
var setupStatus setupStatusResponse
if err := client.GetJSON("/api/setup", 200, &setupStatus); err != nil {
return err
}
if setupStatus.Data.Status {
return nil
}

var resp setupResponse
if err := client.PostJSON("/api/setup", map[string]any{
"username": username,
"password": password,
"confirmPassword": password,
"SelfUseModeEnabled": false,
"DemoSiteEnabled": false,
}, 200, &resp); err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("setup root failed: %s", resp.Message)
}
return nil
}

func Login(client *APIClient, username, password string) error {
var resp loginResponse
if err := client.PostJSON("/api/user/login", map[string]any{
"username": username,
"password": password,
}, 200, &resp); err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("login failed: %s", resp.Message)
}
if resp.Data.ID == 0 {
return fmt.Errorf("login succeeded but user id is empty")
}
client.UserID = resp.Data.ID
return nil
}

func SetupAndLoginRoot(client *APIClient, username, password string) error {
if err := SetupRoot(client, username, password); err != nil {
return err
}
return Login(client, username, password)
}

+ 397
- 0
test/e2e/helpers/cluster.go View File

@@ -0,0 +1,397 @@
package helpers

import (
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)

const (
NodeTypeMaster = "master"
NodeTypeSlave = "slave"

DefaultCNPort = 3100
DefaultOVPort = 3101
DefaultSessionSecret = "user-migration-e2e-session-secret"
DefaultCNDBFileName = "cn.db"
DefaultOVDBFileName = "ov.db"
DefaultCNLogFileName = "cn.log"
DefaultOVLogFileName = "ov.log"
DefaultCNSessionName = "cn_session"
DefaultOVSessionName = "ov_session"
DefaultSyncAPIKey = "e2e-sync-key"
DefaultRootUsername = "root"
DefaultRootPassword = "Password123!"
defaultNodeReadyTimout = 45 * time.Second
)

type NodeProcess struct {
Name string
Port int
BaseURL string
DBPath string
SessionName string
NodeType string
LogPath string

cmd *exec.Cmd
logFile *os.File
waitCh chan error
}

type Cluster struct {
RepoRoot string
RunDir string
BinaryPath string
CNDBPath string
OVDBPath string
CN *NodeProcess
OV *NodeProcess
}

type Environment struct {
RepoRoot string
ArtifactRoot string
RunDir string
BinaryPath string
CNDBPath string
OVDBPath string
Cluster *Cluster
CNClient *APIClient
OVClient *APIClient
}

func BuildBinary(repoRoot, artifactRoot string) (string, error) {
binDir := filepath.Join(artifactRoot, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
return "", fmt.Errorf("create binary dir: %w", err)
}

binaryName := "new-api-e2e"
if runtime.GOOS == "windows" {
binaryName += ".exe"
}
binaryPath := filepath.Join(binDir, binaryName)

cmd := exec.Command("go", "build", "-o", binaryPath, ".")
cmd.Dir = repoRoot
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("build e2e binary: %w\n%s", err, string(output))
}
return binaryPath, nil
}

func PrepareEnvironment(repoRoot, artifactRoot, binaryPath, scenarioName string) (*Environment, error) {
runDir := filepath.Join(artifactRoot, "runs", sanitizeName(scenarioName))
if err := os.RemoveAll(runDir); err != nil {
return nil, fmt.Errorf("reset run dir: %w", err)
}
if err := os.MkdirAll(runDir, 0o755); err != nil {
return nil, fmt.Errorf("create run dir: %w", err)
}

cluster := NewCluster(repoRoot, runDir, binaryPath)
if err := cluster.Start(NodeTypeMaster, NodeTypeMaster); err != nil {
_ = cluster.Stop()
return nil, err
}

cnClient, err := NewAPIClient(cluster.CN.BaseURL)
if err != nil {
_ = cluster.Stop()
return nil, err
}
ovClient, err := NewAPIClient(cluster.OV.BaseURL)
if err != nil {
_ = cluster.Stop()
return nil, err
}

if err := SetupRoot(cnClient, DefaultRootUsername, DefaultRootPassword); err != nil {
_ = cluster.Stop()
return nil, fmt.Errorf("setup CN root: %w", err)
}
if err := SetupRoot(ovClient, DefaultRootUsername, DefaultRootPassword); err != nil {
_ = cluster.Stop()
return nil, fmt.Errorf("setup OV root: %w", err)
}

if err := cluster.Stop(); err != nil {
return nil, fmt.Errorf("stop bootstrap cluster: %w", err)
}

if err := SeedCNData(cluster.CNDBPath); err != nil {
return nil, fmt.Errorf("seed CN data: %w", err)
}
if err := SeedOVData(cluster.OVDBPath); err != nil {
return nil, fmt.Errorf("seed OV data: %w", err)
}

if err := cluster.Start(NodeTypeMaster, NodeTypeSlave); err != nil {
_ = cluster.Stop()
return nil, err
}

cnClient, err = NewAPIClient(cluster.CN.BaseURL)
if err != nil {
_ = cluster.Stop()
return nil, err
}
ovClient, err = NewAPIClient(cluster.OV.BaseURL)
if err != nil {
_ = cluster.Stop()
return nil, err
}

if err := Login(cnClient, DefaultRootUsername, DefaultRootPassword); err != nil {
_ = cluster.Stop()
return nil, fmt.Errorf("login CN root: %w", err)
}
if err := Login(ovClient, DefaultRootUsername, DefaultRootPassword); err != nil {
_ = cluster.Stop()
return nil, fmt.Errorf("login OV root: %w", err)
}

if err := ConfigureCNRegionSync(cnClient, cluster.CN.BaseURL, cluster.OV.BaseURL, DefaultSyncAPIKey); err != nil {
_ = cluster.Stop()
return nil, fmt.Errorf("configure CN region sync: %w", err)
}
if err := ConfigureOVRegionSync(ovClient, cluster.CN.BaseURL, DefaultSyncAPIKey); err != nil {
_ = cluster.Stop()
return nil, fmt.Errorf("configure OV region sync: %w", err)
}

if err := WaitOptionApplied(cluster.OV.BaseURL, DefaultSyncAPIKey, 5, 20*time.Second); err != nil {
_ = cluster.Stop()
return nil, fmt.Errorf("wait OV migration API ready: %w", err)
}

return &Environment{
RepoRoot: repoRoot,
ArtifactRoot: artifactRoot,
RunDir: runDir,
BinaryPath: binaryPath,
CNDBPath: cluster.CNDBPath,
OVDBPath: cluster.OVDBPath,
Cluster: cluster,
CNClient: cnClient,
OVClient: ovClient,
}, nil
}

func (e *Environment) Close() error {
if e == nil || e.Cluster == nil {
return nil
}
return e.Cluster.Stop()
}

func NewCluster(repoRoot, runDir, binaryPath string) *Cluster {
return &Cluster{
RepoRoot: repoRoot,
RunDir: runDir,
BinaryPath: binaryPath,
CNDBPath: filepath.Join(runDir, DefaultCNDBFileName),
OVDBPath: filepath.Join(runDir, DefaultOVDBFileName),
}
}

func (c *Cluster) Start(cnNodeType, ovNodeType string) error {
if err := os.MkdirAll(c.RunDir, 0o755); err != nil {
return fmt.Errorf("create run dir: %w", err)
}

cn, err := c.startNode("cn", DefaultCNPort, c.CNDBPath, DefaultCNSessionName, cnNodeType, DefaultCNLogFileName)
if err != nil {
return err
}
c.CN = cn

ov, err := c.startNode("ov", DefaultOVPort, c.OVDBPath, DefaultOVSessionName, ovNodeType, DefaultOVLogFileName)
if err != nil {
_ = c.CN.Stop()
c.CN = nil
return err
}
c.OV = ov

if err := c.CN.WaitReady(defaultNodeReadyTimout); err != nil {
_ = c.Stop()
return err
}
if err := c.OV.WaitReady(defaultNodeReadyTimout); err != nil {
_ = c.Stop()
return err
}
return nil
}

func (c *Cluster) Stop() error {
var errs []string
if c.OV != nil {
if err := c.OV.Stop(); err != nil {
errs = append(errs, err.Error())
}
c.OV = nil
}
if c.CN != nil {
if err := c.CN.Stop(); err != nil {
errs = append(errs, err.Error())
}
c.CN = nil
}
if len(errs) == 0 {
return nil
}
return errors.New(strings.Join(errs, "; "))
}

func (c *Cluster) startNode(name string, port int, dbPath, sessionName, nodeType, logFileName string) (*NodeProcess, error) {
logPath := filepath.Join(c.RunDir, logFileName)
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return nil, fmt.Errorf("open %s log file: %w", name, err)
}

systemLogDir := filepath.Join(c.RunDir, "system-logs", name)
if err := os.MkdirAll(systemLogDir, 0o755); err != nil {
_ = logFile.Close()
return nil, fmt.Errorf("create %s system log dir: %w", name, err)
}

cmd := exec.Command(c.BinaryPath, "--log-dir", systemLogDir)
cmd.Dir = c.RepoRoot
cmd.Env = mergeEnv(os.Environ(), map[string]string{
"PORT": fmt.Sprintf("%d", port),
"SQLITE_PATH": dbPath,
"SESSION_SECRET": DefaultSessionSecret,
"SESSION_NAME": sessionName,
"SESSION_SECURE": "false",
"SESSION_SAMESITE": "strict",
"GLOBAL_API_RATE_LIMIT_ENABLE": "false",
"GLOBAL_WEB_RATE_LIMIT_ENABLE": "false",
"CRITICAL_RATE_LIMIT_ENABLE": "false",
"GIN_MODE": "release",
"NODE_TYPE": nodeType,
})
cmd.Stdout = logFile
cmd.Stderr = logFile

waitCh := make(chan error, 1)
if err := cmd.Start(); err != nil {
_ = logFile.Close()
return nil, fmt.Errorf("start %s node: %w", name, err)
}
go func() {
waitCh <- cmd.Wait()
}()

return &NodeProcess{
Name: name,
Port: port,
BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
DBPath: dbPath,
SessionName: sessionName,
NodeType: nodeType,
LogPath: logPath,
cmd: cmd,
logFile: logFile,
waitCh: waitCh,
}, nil
}

func (n *NodeProcess) WaitReady(timeout time.Duration) error {
client := &http.Client{Timeout: 1 * time.Second}
deadline := time.Now().Add(timeout)

for time.Now().Before(deadline) {
select {
case err := <-n.waitCh:
if err == nil {
return fmt.Errorf("%s node exited before ready; see %s", n.Name, n.LogPath)
}
return fmt.Errorf("%s node exited before ready: %w; see %s", n.Name, err, n.LogPath)
default:
}

resp, err := client.Get(n.BaseURL + "/api/status")
if err == nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
time.Sleep(250 * time.Millisecond)
}

return fmt.Errorf("%s node did not become ready within %s; see %s", n.Name, timeout, n.LogPath)
}

func (n *NodeProcess) Stop() error {
if n == nil {
return nil
}

var errs []string
killRequested := false
if n.cmd != nil && n.cmd.Process != nil {
if err := n.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
errs = append(errs, fmt.Sprintf("kill %s node: %v", n.Name, err))
} else {
killRequested = true
}
select {
case err := <-n.waitCh:
if err != nil && !killRequested && !strings.Contains(strings.ToLower(err.Error()), "signal: killed") {
errs = append(errs, fmt.Sprintf("wait %s node: %v", n.Name, err))
}
case <-time.After(5 * time.Second):
errs = append(errs, fmt.Sprintf("wait %s node timeout", n.Name))
}
}

if n.logFile != nil {
if err := n.logFile.Close(); err != nil {
errs = append(errs, fmt.Sprintf("close %s log: %v", n.Name, err))
}
n.logFile = nil
}
n.cmd = nil

if len(errs) == 0 {
return nil
}
return errors.New(strings.Join(errs, "; "))
}

func sanitizeName(name string) string {
replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_")
return replacer.Replace(name)
}

func mergeEnv(base []string, overrides map[string]string) []string {
filtered := make([]string, 0, len(base)+len(overrides))
for _, entry := range base {
parts := strings.SplitN(entry, "=", 2)
if len(parts) != 2 {
continue
}
if _, ok := overrides[parts[0]]; ok {
continue
}
filtered = append(filtered, entry)
}
for key, value := range overrides {
filtered = append(filtered, key+"="+value)
}
return filtered
}

+ 119
- 0
test/e2e/helpers/http_client.go View File

@@ -0,0 +1,119 @@
package helpers

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"strings"
"time"

"github.com/QuantumNous/new-api/common"
)

type APIClient struct {
BaseURL string
UserID int
client *http.Client
}

func NewAPIClient(baseURL string) (*APIClient, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, fmt.Errorf("create cookie jar: %w", err)
}
return &APIClient{
BaseURL: strings.TrimRight(baseURL, "/"),
client: &http.Client{
Jar: jar,
Timeout: 15 * time.Second,
},
}, nil
}

func (c *APIClient) DoJSON(method, path string, requestBody any, expectedStatus int, responseBody any) error {
return c.DoJSONWithHeaders(method, path, requestBody, expectedStatus, nil, responseBody)
}

func (c *APIClient) DoJSONWithHeaders(method, path string, requestBody any, expectedStatus int, headers map[string]string, responseBody any) error {
var bodyReader io.Reader
var requestDump string
if requestBody != nil {
data, err := common.Marshal(requestBody)
if err != nil {
return fmt.Errorf("marshal request body for %s %s: %w", method, path, err)
}
bodyReader = bytes.NewReader(data)
requestDump = string(data)
}

req, err := http.NewRequest(method, c.BaseURL+path, bodyReader)
if err != nil {
return fmt.Errorf("create request %s %s: %w", method, path, err)
}
req.Header.Set("Accept", "application/json")
if requestBody != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.UserID != 0 {
req.Header.Set("New-Api-User", fmt.Sprintf("%d", c.UserID))
}
for key, value := range headers {
req.Header.Set(key, value)
}

resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("do request %s %s: %w", method, path, err)
}
defer resp.Body.Close()

respData, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response %s %s: %w", method, path, err)
}

if resp.StatusCode != expectedStatus {
return fmt.Errorf(
"%s %s: expected status %d, got %d, request=%s, response=%s",
method, path, expectedStatus, resp.StatusCode, requestDump, string(respData),
)
}

if responseBody == nil || len(respData) == 0 {
return nil
}

if err := common.Unmarshal(respData, responseBody); err != nil {
return fmt.Errorf(
"unmarshal response for %s %s: %w, raw=%s",
method, path, err, string(respData),
)
}
return nil
}

func (c *APIClient) GetJSON(path string, expectedStatus int, responseBody any) error {
return c.DoJSON(http.MethodGet, path, nil, expectedStatus, responseBody)
}

func (c *APIClient) PostJSON(path string, requestBody any, expectedStatus int, responseBody any) error {
return c.DoJSON(http.MethodPost, path, requestBody, expectedStatus, responseBody)
}

func (c *APIClient) PutJSON(path string, requestBody any, expectedStatus int, responseBody any) error {
return c.DoJSON(http.MethodPut, path, requestBody, expectedStatus, responseBody)
}

func (c *APIClient) DeleteJSON(path string, expectedStatus int, responseBody any) error {
return c.DoJSON(http.MethodDelete, path, nil, expectedStatus, responseBody)
}

type Envelope struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}

+ 115
- 0
test/e2e/helpers/options.go View File

@@ -0,0 +1,115 @@
package helpers

import (
"fmt"
"net/http"
"time"

"github.com/QuantumNous/new-api/common"
)

type optionResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
}

type internalListUsersResponse struct {
Success bool `json:"success"`
Total int `json:"total"`
}

func SetOption(client *APIClient, key string, value any) error {
var resp optionResponse
if err := client.PutJSON("/api/option/", map[string]any{
"key": key,
"value": value,
}, http.StatusOK, &resp); err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("set option %s failed: %s", key, resp.Message)
}
return nil
}

func ConfigureCNRegionSync(client *APIClient, cnBaseURL, ovBaseURL, apiKey string) error {
slaveEndpoints, err := common.Marshal([]string{ovBaseURL})
if err != nil {
return fmt.Errorf("marshal slave endpoints: %w", err)
}

options := []struct {
Key string
Value any
}{
{Key: "region_sync.enabled", Value: true},
{Key: "region_sync.region_id", Value: "cn"},
{Key: "region_sync.is_master", Value: true},
{Key: "region_sync.master_endpoint", Value: cnBaseURL},
{Key: "region_sync.slave_endpoints", Value: string(slaveEndpoints)},
{Key: "region_sync.sync_api_key", Value: apiKey},
}
for _, option := range options {
if err := SetOption(client, option.Key, option.Value); err != nil {
return err
}
}
return nil
}

func ConfigureOVRegionSync(client *APIClient, cnBaseURL, apiKey string) error {
emptyEndpoints, err := common.Marshal([]string{})
if err != nil {
return fmt.Errorf("marshal empty slave endpoints: %w", err)
}

options := []struct {
Key string
Value any
}{
{Key: "region_sync.enabled", Value: true},
{Key: "region_sync.region_id", Value: "ov"},
{Key: "region_sync.is_master", Value: false},
{Key: "region_sync.master_endpoint", Value: cnBaseURL},
{Key: "region_sync.slave_endpoints", Value: string(emptyEndpoints)},
{Key: "region_sync.sync_api_key", Value: apiKey},
}
for _, option := range options {
if err := SetOption(client, option.Key, option.Value); err != nil {
return err
}
}
return nil
}

func WaitOptionApplied(baseURL, apiKey string, expectedTotal int, timeout time.Duration) error {
return WaitInternalMigrationReady(baseURL, apiKey, expectedTotal, timeout)
}

func WaitInternalMigrationReady(baseURL, apiKey string, expectedTotal int, timeout time.Duration) error {
client, err := NewAPIClient(baseURL)
if err != nil {
return err
}

deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
var resp internalListUsersResponse
err := client.DoJSONWithHeaders(
http.MethodGet,
"/api/internal/migration/users?page=0&page_size=1",
nil,
http.StatusOK,
map[string]string{
"X-Sync-API-Key": apiKey,
"X-Sync-Node": "e2e-ready-check",
},
&resp,
)
if err == nil && resp.Success && resp.Total == expectedTotal {
return nil
}
time.Sleep(250 * time.Millisecond)
}
return fmt.Errorf("internal migration API at %s not ready within %s", baseURL, timeout)
}

+ 304
- 0
test/e2e/helpers/seed.go View File

@@ -0,0 +1,304 @@
package helpers

import (
"fmt"
"os"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)

const (
CNMergeTargetUserID = 11
CNSyncedCopyOwnerUserID = 12
CNTakenUsernameUserID = 13
CNSoftAffOwnerUserID = 14
OVCreateUserID = 10000001
OVMergeUserID = 10000002
OVHardSyncedUserID = 10000003
OVUsernameConflictUserID = 10000004
OVSoftAffUserID = 10000005
OVSyncedCopyUserID = 10000100

UsernameCNMergeTarget = "cn-merge-target"
UsernameCNSyncedCopyOwner = "cn-synced-copy-owner"
UsernameCNTakenUsername = "taken-username"
UsernameCNSoftAffOwner = "aff-owner"
UsernameOVCreate = "ov-create"
UsernameOVMerge = "ov-merge"
UsernameOVHardSynced = "ov-hard-synced"
UsernameOVUsernameConflict = "taken-username"
UsernameOVSoftAff = "ov-aff-soft"
UsernameOVSyncedCopy = "ov-existing-synced-copy"

EmailCNMergeTarget = "merge@example.com"
EmailCNSyncedCopyOwner = "synced@example.com"
EmailCNTakenUsername = "taken@example.com"
EmailCNSoftAffOwner = "soft-owner@example.com"
EmailOVCreate = "create@example.com"
EmailOVMerge = "merge@example.com"
EmailOVHardSynced = "synced@example.com"
EmailOVUsernameConflict = "username-conflict@example.com"
EmailOVSoftAff = "soft@example.com"
EmailOVSyncedCopy = "copy@example.com"

AffCodeCNMergeTarget = "CNM1"
AffCodeCNSyncedCopyOwner = "CNH3"
AffCodeCNTakenUsername = "CNT1"
AffCodeCNSoftAffOwner = "SOFT1"
AffCodeOVCreate = "OVC1"
AffCodeOVMerge = "OVM2"
AffCodeOVHardSynced = "OVH3"
AffCodeOVUsername = "OVU4"
AffCodeOVSyncedCopy = "OVS1"
)

func ResetDB(path string) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

func OpenSQLite(path string) (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(1)
return db, nil
}

func CloseSQLite(db *gorm.DB) error {
if db == nil {
return nil
}
sqlDB, err := db.DB()
if err != nil {
return err
}
return sqlDB.Close()
}

func SeedCNData(dbPath string) error {
db, err := OpenSQLite(dbPath)
if err != nil {
return fmt.Errorf("open CN sqlite: %w", err)
}
defer func() {
_ = CloseSQLite(db)
}()

passwordHash, err := common.Password2Hash("fixture-password")
if err != nil {
return fmt.Errorf("hash fixture password: %w", err)
}
now := common.GetTimestamp()

users := []*model.User{
{
Id: CNMergeTargetUserID,
Username: UsernameCNMergeTarget,
Password: passwordHash,
DisplayName: "CN Merge Target",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Email: EmailCNMergeTarget,
Group: "default",
Quota: 500,
AffCode: AffCodeCNMergeTarget,
Source: common.UserSourceLocal,
CreatedAt: now,
},
{
Id: CNSyncedCopyOwnerUserID,
Username: UsernameCNSyncedCopyOwner,
Password: passwordHash,
DisplayName: "CN Synced Copy Owner",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Email: EmailCNSyncedCopyOwner,
Group: "default",
Quota: 300,
AffCode: AffCodeCNSyncedCopyOwner,
Source: common.UserSourceLocal,
CreatedAt: now,
},
{
Id: CNTakenUsernameUserID,
Username: UsernameCNTakenUsername,
Password: passwordHash,
DisplayName: "CN Username Conflict",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Email: EmailCNTakenUsername,
Group: "default",
Quota: 50,
AffCode: AffCodeCNTakenUsername,
Source: common.UserSourceLocal,
CreatedAt: now,
},
{
Id: CNSoftAffOwnerUserID,
Username: UsernameCNSoftAffOwner,
Password: passwordHash,
DisplayName: "CN Soft Aff Owner",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Email: EmailCNSoftAffOwner,
Group: "default",
Quota: 80,
AffCode: AffCodeCNSoftAffOwner,
Source: common.UserSourceLocal,
CreatedAt: now,
},
}

for _, user := range users {
if err := db.Create(user).Error; err != nil {
return fmt.Errorf("seed CN user %s: %w", user.Username, err)
}
}
return nil
}

func SeedOVData(dbPath string) error {
db, err := OpenSQLite(dbPath)
if err != nil {
return fmt.Errorf("open OV sqlite: %w", err)
}
defer func() {
_ = CloseSQLite(db)
}()

passwordHash, err := common.Password2Hash("fixture-password")
if err != nil {
return fmt.Errorf("hash fixture password: %w", err)
}
now := common.GetTimestamp()

users := []*model.User{
{
Id: OVCreateUserID,
Username: UsernameOVCreate,
Password: passwordHash,
DisplayName: "OV Create",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Email: EmailOVCreate,
Group: "default",
Quota: 100,
AffCode: AffCodeOVCreate,
Source: common.UserSourceLocal,
CreatedAt: now,
},
{
Id: OVMergeUserID,
Username: UsernameOVMerge,
Password: passwordHash,
DisplayName: "OV Merge",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Email: EmailOVMerge,
Group: "default",
Quota: 200,
AffCode: AffCodeOVMerge,
Source: common.UserSourceLocal,
CreatedAt: now,
},
{
Id: OVHardSyncedUserID,
Username: UsernameOVHardSynced,
Password: passwordHash,
DisplayName: "OV Hard Synced",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Email: EmailOVHardSynced,
Group: "default",
Quota: 300,
AffCode: AffCodeOVHardSynced,
Source: common.UserSourceLocal,
CreatedAt: now,
},
{
Id: OVUsernameConflictUserID,
Username: UsernameOVUsernameConflict,
Password: passwordHash,
DisplayName: "OV Username Conflict",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Email: EmailOVUsernameConflict,
Group: "default",
Quota: 120,
AffCode: AffCodeOVUsername,
Source: common.UserSourceLocal,
CreatedAt: now,
},
{
Id: OVSoftAffUserID,
Username: UsernameOVSoftAff,
Password: passwordHash,
DisplayName: "OV Soft Aff",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Email: EmailOVSoftAff,
Group: "default",
Quota: 150,
AffCode: AffCodeCNSoftAffOwner,
Source: common.UserSourceLocal,
CreatedAt: now,
},
}

for _, user := range users {
if err := db.Create(user).Error; err != nil {
return fmt.Errorf("seed OV user %s: %w", user.Username, err)
}
}

return SeedOVSyncedCopy(dbPath)
}

func SeedOVSyncedCopy(dbPath string) error {
db, err := OpenSQLite(dbPath)
if err != nil {
return fmt.Errorf("open OV sqlite for synced copy: %w", err)
}
defer func() {
_ = CloseSQLite(db)
}()

passwordHash, err := common.Password2Hash("fixture-password")
if err != nil {
return fmt.Errorf("hash fixture password for synced copy: %w", err)
}
now := common.GetTimestamp()

user := &model.User{
Id: OVSyncedCopyUserID,
Username: UsernameOVSyncedCopy,
Password: passwordHash,
DisplayName: "OV Existing Synced Copy",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Email: EmailOVSyncedCopy,
Group: "default",
Quota: 0,
AffCode: AffCodeOVSyncedCopy,
Source: common.UserSourceSynced,
RemoteUserId: CNSyncedCopyOwnerUserID,
SyncedQuota: 300,
LastSyncAt: now,
CreatedAt: now,
}
if err := db.Create(user).Error; err != nil {
return fmt.Errorf("seed OV synced copy %s: %w", user.Username, err)
}
return nil
}

+ 683
- 0
test/e2e/user_migration_test.go View File

@@ -0,0 +1,683 @@
package e2e

import (
"fmt"
"os"
"path/filepath"
"testing"
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
helpers "github.com/QuantumNous/new-api/test/e2e/helpers"
"github.com/stretchr/testify/require"
)

var (
repoRoot string
artifactRoot string
binaryPath string
)

type simpleResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}

type createBatchResponse struct {
Success bool `json:"success"`
Data model.UserMigrationBatch `json:"data"`
}

type adminUserResponse struct {
Success bool `json:"success"`
Data model.User `json:"data"`
}

type batchPageResponse struct {
Success bool `json:"success"`
Data model.UserMigrationBatch `json:"data"`
Items []model.UserMigrationItem `json:"items"`
Total int64 `json:"total"`
}

type preparedBatch struct {
BatchID int
Detail *helpers.BatchDetail
Items map[int]*model.UserMigrationItem
}

type executedBatch struct {
Prepared *preparedBatch
Detail *helpers.BatchDetail
CreatedUser *model.User
SoftAffUser *model.User
VerifyResult map[int]helpers.VerifyEntry
}

func TestMain(m *testing.M) {
var err error
wd, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

repoRoot = filepath.Clean(filepath.Join(wd, "..", ".."))
artifactRoot = filepath.Join(repoRoot, "test-artifacts", "user-migration-e2e")
if err := os.MkdirAll(artifactRoot, 0o755); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

binaryPath, err = helpers.BuildBinary(repoRoot, artifactRoot)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

os.Exit(m.Run())
}

func TestUserMigrationE2E_FullFlow_GrayScenarios(t *testing.T) {
env := newEnvironment(t)
result := runFullFlow(t, env)

require.Equal(t, model.UserMigrationBatchStatusCompleted, result.Detail.Batch.Status)
require.Equal(t, 3, result.Detail.Batch.MigratedUsers)
require.Equal(t, 0, result.Detail.Batch.FailedUsers)
require.Len(t, result.VerifyResult, 3)
}

func TestUserMigrationE2E_IdempotentRetry(t *testing.T) {
env := newEnvironment(t)
result := runFullFlow(t, env)

forceRetryableState(t, env.CNDBPath, result.Prepared.BatchID, []int{
result.Prepared.Items[helpers.OVCreateUserID].Id,
result.Prepared.Items[helpers.OVMergeUserID].Id,
result.Prepared.Items[helpers.OVSoftAffUserID].Id,
})

var retryResp simpleResponse
require.NoError(t, env.CNClient.PostJSON(
fmt.Sprintf("/api/user-migrations/batches/%d/retry", result.Prepared.BatchID),
nil,
202,
&retryResp,
))
require.True(t, retryResp.Success)

retried, err := helpers.WaitBatchStatus(env.CNClient, result.Prepared.BatchID, model.UserMigrationBatchStatusCompleted, 25*time.Second)
require.NoError(t, err)

retriedItems := indexItemsBySourceUserID(retried.Items)
require.Equal(t, model.UserMigrationItemStatusMigrated, retriedItems[helpers.OVCreateUserID].Status)
require.Equal(t, model.UserMigrationItemStatusMigrated, retriedItems[helpers.OVMergeUserID].Status)
require.Equal(t, model.UserMigrationItemStatusMigrated, retriedItems[helpers.OVSoftAffUserID].Status)

require.EqualValues(t, 1, helpers.CountUsersByUsername(t, env.CNDBPath, helpers.UsernameOVCreate))
require.EqualValues(t, 1, helpers.CountUsersByUsername(t, env.CNDBPath, helpers.UsernameOVSoftAff))
helpers.AssertQuota(t, env.CNDBPath, helpers.CNMergeTargetUserID, 700)
helpers.AssertNoDuplicateQuotaGrant(t, env.CNDBPath, result.Prepared.Items[helpers.OVMergeUserID].MigrationToken)

verifyEntries, err := helpers.VerifyBatch(env.CNClient, result.Prepared.BatchID)
require.NoError(t, err)
require.Len(t, verifyEntries, 3)
for _, entry := range verifyEntries {
require.Empty(t, entry.Error)
require.NotNil(t, entry.Result)
require.True(t, entry.Result.TargetUserExists)
require.True(t, entry.Result.RemoteConverted)
require.True(t, entry.Result.QuotaMatched)
}
}

func TestUserMigrationE2E_ExecutionTimeDriftFails(t *testing.T) {
env := newEnvironment(t)
prepared := prepareGrayScenarioBatch(t, env)

renameOVUser(t, env, helpers.OVCreateUserID, helpers.UsernameCNMergeTarget)

var executeResp simpleResponse
require.NoError(t, env.CNClient.PostJSON(
fmt.Sprintf("/api/user-migrations/batches/%d/execute", prepared.BatchID),
nil,
202,
&executeResp,
))
require.True(t, executeResp.Success)

failedDetail, err := helpers.WaitBatchStatus(env.CNClient, prepared.BatchID, model.UserMigrationBatchStatusFailed, 25*time.Second)
require.NoError(t, err)

items := indexItemsBySourceUserID(failedDetail.Items)
require.Equal(t, model.UserMigrationItemStatusFailed, items[helpers.OVCreateUserID].Status)
require.Contains(t, items[helpers.OVCreateUserID].ErrorMessage, "drift")
require.Equal(t, model.UserMigrationItemStatusMigrated, items[helpers.OVMergeUserID].Status)
require.Equal(t, model.UserMigrationItemStatusMigrated, items[helpers.OVSoftAffUserID].Status)
require.Equal(t, model.UserMigrationItemStatusSkipped, items[helpers.OVHardSyncedUserID].Status)
require.Equal(t, model.UserMigrationItemStatusSkipped, items[helpers.OVUsernameConflictUserID].Status)

require.EqualValues(t, 0, helpers.CountUsersByEmail(t, env.CNDBPath, helpers.EmailOVCreate))

driftedRemote, err := helpers.GetRemoteUserSnapshot(env.Cluster.OV.BaseURL, helpers.OVCreateUserID)
require.NoError(t, err)
require.NotEqual(t, common.UserSourceSynced, driftedRemote.Source)
}

func TestUserMigrationE2E_RescanPreservesHumanDecision(t *testing.T) {
env := newEnvironment(t)
prepared := prepareGrayScenarioBatch(t, env)

var scanResp simpleResponse
require.NoError(t, env.CNClient.PostJSON(
fmt.Sprintf("/api/user-migrations/batches/%d/scan", prepared.BatchID),
nil,
202,
&scanResp,
))
require.True(t, scanResp.Success)

rescannedDetail, err := helpers.WaitBatchStatus(env.CNClient, prepared.BatchID, model.UserMigrationBatchStatusScanned, 20*time.Second)
require.NoError(t, err)

items := indexItemsBySourceUserID(rescannedDetail.Items)
require.Equal(t, model.UserMigrationStrategySkip, items[helpers.OVHardSyncedUserID].ResolutionStrategy)
require.Equal(t, model.UserMigrationItemStatusConflict, items[helpers.OVHardSyncedUserID].Status)
require.Equal(t, model.UserMigrationStrategySkip, items[helpers.OVUsernameConflictUserID].ResolutionStrategy)
require.Equal(t, model.UserMigrationItemStatusConflict, items[helpers.OVUsernameConflictUserID].Status)
require.Equal(t, model.UserMigrationStrategyMergeExisting, items[helpers.OVMergeUserID].ResolutionStrategy)
require.Equal(t, helpers.CNMergeTargetUserID, items[helpers.OVMergeUserID].TargetUserId)
}

func TestUserMigrationE2E_ScanPagination(t *testing.T) {
env := newEnvironment(t)

createManagedUsers(t, env.OVClient, "ov-page", 97, common.RoleCommonUser)

batchID := createBatch(t, env, "pagination")

var scanResp simpleResponse
require.NoError(t, env.CNClient.PostJSON(
fmt.Sprintf("/api/user-migrations/batches/%d/scan", batchID),
nil,
202,
&scanResp,
))
require.True(t, scanResp.Success)

detail, err := helpers.WaitBatchStatus(env.CNClient, batchID, model.UserMigrationBatchStatusScanned, 60*time.Second)
require.NoError(t, err)
require.EqualValues(t, 102, detail.Total)
require.Equal(t, 102, detail.Batch.TotalUsers)

page0 := getBatchPage(t, env.CNClient, batchID, 0, 100)
require.Len(t, page0.Items, 100)
page1 := getBatchPage(t, env.CNClient, batchID, 1, 100)
require.Len(t, page1.Items, 2)
require.EqualValues(t, 102, page1.Total)
}

func TestUserMigrationE2E_RootPermission(t *testing.T) {
env := newEnvironment(t)

adminClient := createAndLoginManagedUser(t, env.Cluster.CN.BaseURL, env.CNClient, "migration-admin", helpers.DefaultRootPassword, common.RoleAdminUser)

var listResp simpleResponse
require.NoError(t, adminClient.GetJSON("/api/user-migrations/batches", 200, &listResp))
require.False(t, listResp.Success)
require.NotEmpty(t, listResp.Message)

var createResp simpleResponse
require.NoError(t, adminClient.PostJSON("/api/user-migrations/batches", map[string]any{
"name": "no-access",
"source_region": "ov",
"target_region": "cn",
}, 200, &createResp))
require.False(t, createResp.Success)
require.NotEmpty(t, createResp.Message)
}

func TestUserMigrationE2E_ResolveInvalidPayload(t *testing.T) {
env := newEnvironment(t)
scanned := scanGrayScenarioBatch(t, env, "resolve-invalid")

conflictItem := scanned.Items[helpers.OVHardSyncedUserID]

var invalidStrategyResp simpleResponse
require.NoError(t, env.CNClient.PostJSON(
fmt.Sprintf("/api/user-migrations/items/%d/resolve", conflictItem.Id),
map[string]any{
"resolution_strategy": "invalid",
},
400,
&invalidStrategyResp,
))
require.False(t, invalidStrategyResp.Success)
require.Contains(t, invalidStrategyResp.Error, "invalid resolution_strategy")

var mergeWithoutTargetResp simpleResponse
require.NoError(t, env.CNClient.PostJSON(
fmt.Sprintf("/api/user-migrations/items/%d/resolve", conflictItem.Id),
map[string]any{
"resolution_strategy": model.UserMigrationStrategyMergeExisting,
},
400,
&mergeWithoutTargetResp,
))
require.False(t, mergeWithoutTargetResp.Success)
require.Contains(t, mergeWithoutTargetResp.Error, "target_user_id")
}

func TestUserMigrationE2E_ScanConflict_WhenAlreadyScanning(t *testing.T) {
env := newEnvironment(t)

batchID := createBatch(t, env, "scan-conflict")
setBatchStatus(t, env.CNDBPath, batchID, model.UserMigrationBatchStatusScanning)

var resp simpleResponse
require.NoError(t, env.CNClient.PostJSON(
fmt.Sprintf("/api/user-migrations/batches/%d/scan", batchID),
nil,
409,
&resp,
))
require.False(t, resp.Success)
require.Contains(t, resp.Error, "already being scanned")
}

func TestUserMigrationE2E_ExecuteConflict_WhenAlreadyRunning(t *testing.T) {
env := newEnvironment(t)
prepared := prepareGrayScenarioBatch(t, env)

setBatchStatus(t, env.CNDBPath, prepared.BatchID, model.UserMigrationBatchStatusRunning)

var resp simpleResponse
require.NoError(t, env.CNClient.PostJSON(
fmt.Sprintf("/api/user-migrations/batches/%d/execute", prepared.BatchID),
nil,
409,
&resp,
))
require.False(t, resp.Success)
require.Contains(t, resp.Error, "already running")
}

func TestUserMigrationE2E_VerifyQuotaMismatchDetected(t *testing.T) {
env := newEnvironment(t)
result := runFullFlow(t, env)

convertRemoteUserToSynced(t, env.Cluster.OV.BaseURL, helpers.OVMergeUserID, helpers.CNMergeTargetUserID, 199)

verifyEntries, err := helpers.VerifyBatch(env.CNClient, result.Prepared.BatchID)
require.NoError(t, err)

verifyBySource := make(map[int]helpers.VerifyEntry, len(verifyEntries))
for _, entry := range verifyEntries {
verifyBySource[entry.SourceUserID] = entry
}

require.NotNil(t, verifyBySource[helpers.OVMergeUserID].Result)
require.False(t, verifyBySource[helpers.OVMergeUserID].Result.QuotaMatched)
require.True(t, verifyBySource[helpers.OVCreateUserID].Result.QuotaMatched)
require.True(t, verifyBySource[helpers.OVSoftAffUserID].Result.QuotaMatched)
}

func TestUserMigrationE2E_SkipNeverConvertsRemote(t *testing.T) {
env := newEnvironment(t)
prepared := prepareGrayScenarioBatch(t, env)

resolveItem(t, env, prepared.Items[helpers.OVCreateUserID].Id, model.UserMigrationStrategySkip, 0)

readyDetail, err := helpers.WaitBatchStatus(env.CNClient, prepared.BatchID, model.UserMigrationBatchStatusReady, 10*time.Second)
require.NoError(t, err)

var executeResp simpleResponse
require.NoError(t, env.CNClient.PostJSON(
fmt.Sprintf("/api/user-migrations/batches/%d/execute", prepared.BatchID),
nil,
202,
&executeResp,
))
require.True(t, executeResp.Success)

completedDetail, err := helpers.WaitBatchStatus(env.CNClient, prepared.BatchID, model.UserMigrationBatchStatusCompleted, 25*time.Second)
require.NoError(t, err)

items := indexItemsBySourceUserID(completedDetail.Items)
require.Equal(t, model.UserMigrationItemStatusSkipped, items[helpers.OVCreateUserID].Status)
require.EqualValues(t, 0, helpers.CountUsersByEmail(t, env.CNDBPath, helpers.EmailOVCreate))

remoteUser, err := helpers.GetRemoteUserSnapshot(env.Cluster.OV.BaseURL, helpers.OVCreateUserID)
require.NoError(t, err)
require.NotEqual(t, common.UserSourceSynced, remoteUser.Source)
require.Equal(t, readyDetail.Items[0].BatchId, items[helpers.OVCreateUserID].BatchId)
}

func newEnvironment(t *testing.T) *helpers.Environment {
t.Helper()

env, err := helpers.PrepareEnvironment(repoRoot, artifactRoot, binaryPath, t.Name())
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, env.Close())
})
return env
}

func runFullFlow(t *testing.T, env *helpers.Environment) *executedBatch {
t.Helper()

prepared := prepareGrayScenarioBatch(t, env)

var executeResp simpleResponse
require.NoError(t, env.CNClient.PostJSON(
fmt.Sprintf("/api/user-migrations/batches/%d/execute", prepared.BatchID),
nil,
202,
&executeResp,
))
require.True(t, executeResp.Success)

completedDetail, err := helpers.WaitBatchStatus(env.CNClient, prepared.BatchID, model.UserMigrationBatchStatusCompleted, 25*time.Second)
require.NoError(t, err)
require.EqualValues(t, 5, completedDetail.Total)

items := indexItemsBySourceUserID(completedDetail.Items)
require.Equal(t, model.UserMigrationItemStatusMigrated, items[helpers.OVCreateUserID].Status)
require.Equal(t, model.UserMigrationItemStatusMigrated, items[helpers.OVMergeUserID].Status)
require.Equal(t, model.UserMigrationItemStatusMigrated, items[helpers.OVSoftAffUserID].Status)
require.Equal(t, model.UserMigrationItemStatusSkipped, items[helpers.OVHardSyncedUserID].Status)
require.Equal(t, model.UserMigrationItemStatusSkipped, items[helpers.OVUsernameConflictUserID].Status)

createdUser := helpers.AssertCNUserExists(t, env.CNDBPath, helpers.UsernameOVCreate)
require.Equal(t, 100, createdUser.Quota)

helpers.AssertQuota(t, env.CNDBPath, helpers.CNMergeTargetUserID, 700)

softAffUser := helpers.AssertCNUserExists(t, env.CNDBPath, helpers.UsernameOVSoftAff)
require.Equal(t, 150, softAffUser.Quota)
require.NotEqual(t, helpers.AffCodeCNSoftAffOwner, softAffUser.AffCode)

helpers.AssertOVUserSynced(t, env.Cluster.OV.BaseURL, helpers.OVCreateUserID, createdUser.Id, 100)
helpers.AssertOVUserSynced(t, env.Cluster.OV.BaseURL, helpers.OVMergeUserID, helpers.CNMergeTargetUserID, 700)
helpers.AssertOVUserSynced(t, env.Cluster.OV.BaseURL, helpers.OVSoftAffUserID, softAffUser.Id, 150)

hardSnapshot, err := helpers.GetRemoteUserSnapshot(env.Cluster.OV.BaseURL, helpers.OVHardSyncedUserID)
require.NoError(t, err)
require.NotEqual(t, common.UserSourceSynced, hardSnapshot.Source)

usernameSnapshot, err := helpers.GetRemoteUserSnapshot(env.Cluster.OV.BaseURL, helpers.OVUsernameConflictUserID)
require.NoError(t, err)
require.NotEqual(t, common.UserSourceSynced, usernameSnapshot.Source)

verifyEntries, err := helpers.VerifyBatch(env.CNClient, prepared.BatchID)
require.NoError(t, err)
require.Len(t, verifyEntries, 3)

verifyBySource := make(map[int]helpers.VerifyEntry, len(verifyEntries))
for _, entry := range verifyEntries {
require.Empty(t, entry.Error)
require.NotNil(t, entry.Result)
require.True(t, entry.Result.TargetUserExists)
require.True(t, entry.Result.RemoteConverted)
require.True(t, entry.Result.QuotaMatched)
verifyBySource[entry.SourceUserID] = entry
}

return &executedBatch{
Prepared: prepared,
Detail: completedDetail,
CreatedUser: createdUser,
SoftAffUser: softAffUser,
VerifyResult: verifyBySource,
}
}

func prepareGrayScenarioBatch(t *testing.T, env *helpers.Environment) *preparedBatch {
t.Helper()

scanned := scanGrayScenarioBatch(t, env, "gray-scenarios")
scannedItems := scanned.Items

require.Equal(t, model.UserMigrationStrategyCreateNew, scannedItems[helpers.OVCreateUserID].ResolutionStrategy)
require.Equal(t, model.UserMigrationItemStatusReady, scannedItems[helpers.OVCreateUserID].Status)

require.Equal(t, model.UserMigrationStrategyMergeExisting, scannedItems[helpers.OVMergeUserID].ResolutionStrategy)
require.Equal(t, model.UserMigrationItemStatusReady, scannedItems[helpers.OVMergeUserID].Status)
require.Equal(t, helpers.CNMergeTargetUserID, scannedItems[helpers.OVMergeUserID].TargetUserId)

require.Equal(t, model.UserMigrationItemStatusConflict, scannedItems[helpers.OVHardSyncedUserID].Status)
require.Contains(t, decodeFlags(t, scannedItems[helpers.OVHardSyncedUserID].ConflictFlags), "cn_already_synced_to_ov")

require.Equal(t, model.UserMigrationItemStatusConflict, scannedItems[helpers.OVUsernameConflictUserID].Status)
require.Contains(t, decodeFlags(t, scannedItems[helpers.OVUsernameConflictUserID].ConflictFlags), "username")

require.Equal(t, model.UserMigrationStrategyCreateNew, scannedItems[helpers.OVSoftAffUserID].ResolutionStrategy)
require.Equal(t, model.UserMigrationItemStatusReady, scannedItems[helpers.OVSoftAffUserID].Status)
require.Contains(t, decodeFlags(t, scannedItems[helpers.OVSoftAffUserID].ConflictFlags), "aff_code")

resolveItem(t, env, scannedItems[helpers.OVHardSyncedUserID].Id, model.UserMigrationStrategySkip, 0)
resolveItem(t, env, scannedItems[helpers.OVUsernameConflictUserID].Id, model.UserMigrationStrategySkip, 0)

readyDetail, err := helpers.WaitBatchStatus(env.CNClient, scanned.BatchID, model.UserMigrationBatchStatusReady, 10*time.Second)
require.NoError(t, err)

readyItems := indexItemsBySourceUserID(readyDetail.Items)
require.Equal(t, model.UserMigrationStrategySkip, readyItems[helpers.OVHardSyncedUserID].ResolutionStrategy)
require.Equal(t, model.UserMigrationItemStatusReady, readyItems[helpers.OVHardSyncedUserID].Status)
require.Equal(t, model.UserMigrationStrategySkip, readyItems[helpers.OVUsernameConflictUserID].ResolutionStrategy)
require.Equal(t, model.UserMigrationItemStatusReady, readyItems[helpers.OVUsernameConflictUserID].Status)

return &preparedBatch{
BatchID: scanned.BatchID,
Detail: readyDetail,
Items: readyItems,
}
}

func scanGrayScenarioBatch(t *testing.T, env *helpers.Environment, name string) *preparedBatch {
t.Helper()

batchID := createBatch(t, env, name)

var scanResp simpleResponse
require.NoError(t, env.CNClient.PostJSON(
fmt.Sprintf("/api/user-migrations/batches/%d/scan", batchID),
nil,
202,
&scanResp,
))
require.True(t, scanResp.Success)

scannedDetail, err := helpers.WaitBatchStatus(env.CNClient, batchID, model.UserMigrationBatchStatusScanned, 20*time.Second)
require.NoError(t, err)
require.EqualValues(t, 5, scannedDetail.Total)

scannedItems := indexItemsBySourceUserID(scannedDetail.Items)
require.Len(t, scannedItems, 5)

return &preparedBatch{
BatchID: batchID,
Detail: scannedDetail,
Items: scannedItems,
}
}

func createBatch(t *testing.T, env *helpers.Environment, name string) int {
t.Helper()

var resp createBatchResponse
require.NoError(t, env.CNClient.PostJSON("/api/user-migrations/batches", map[string]any{
"name": name,
"source_region": "ov",
"target_region": "cn",
}, 200, &resp))
require.True(t, resp.Success)
require.NotZero(t, resp.Data.Id)
return resp.Data.Id
}

func resolveItem(t *testing.T, env *helpers.Environment, itemID int, strategy string, targetUserID int) {
t.Helper()

var resp simpleResponse
require.NoError(t, env.CNClient.PostJSON(
fmt.Sprintf("/api/user-migrations/items/%d/resolve", itemID),
map[string]any{
"resolution_strategy": strategy,
"target_user_id": targetUserID,
},
200,
&resp,
))
require.True(t, resp.Success)
}

func renameOVUser(t *testing.T, env *helpers.Environment, userID int, newUsername string) {
t.Helper()

var getResp adminUserResponse
require.NoError(t, env.OVClient.GetJSON(fmt.Sprintf("/api/user/%d", userID), 200, &getResp))
require.True(t, getResp.Success)

var updateResp simpleResponse
require.NoError(t, env.OVClient.PutJSON("/api/user", map[string]any{
"id": getResp.Data.Id,
"username": newUsername,
"display_name": getResp.Data.DisplayName,
"group": getResp.Data.Group,
"quota": getResp.Data.Quota,
"remark": getResp.Data.Remark,
"capture_relay": getResp.Data.CaptureRelay,
}, 200, &updateResp))
require.True(t, updateResp.Success)
}

func createAndLoginManagedUser(t *testing.T, baseURL string, rootClient *helpers.APIClient, username, password string, role int) *helpers.APIClient {
t.Helper()

var resp simpleResponse
require.NoError(t, rootClient.PostJSON("/api/user/", map[string]any{
"username": username,
"password": password,
"display_name": username,
"role": role,
}, 200, &resp))
require.True(t, resp.Success)

client, err := helpers.NewAPIClient(baseURL)
require.NoError(t, err)
require.NoError(t, helpers.Login(client, username, password))
return client
}

func createManagedUsers(t *testing.T, client *helpers.APIClient, prefix string, count int, role int) {
t.Helper()

for i := 0; i < count; i++ {
username := prefix
email := fmt.Sprintf("%s@example.com", prefix)
displayName := prefix
if count > 1 {
username = fmt.Sprintf("%s-%03d", prefix, i)
email = fmt.Sprintf("%s-%03d@example.com", prefix, i)
displayName = fmt.Sprintf("%s %03d", prefix, i)
}

var resp simpleResponse
require.NoError(t, client.PostJSON("/api/user/", map[string]any{
"username": username,
"password": helpers.DefaultRootPassword,
"display_name": displayName,
"email": email,
"role": role,
}, 200, &resp))
require.True(t, resp.Success)
}
}

func forceRetryableState(t *testing.T, dbPath string, batchID int, itemIDs []int) {
t.Helper()

db, err := helpers.OpenSQLite(dbPath)
require.NoError(t, err)
defer func() {
require.NoError(t, helpers.CloseSQLite(db))
}()

require.NoError(t, db.Model(&model.UserMigrationBatch{}).Where("id = ?", batchID).
Update("status", model.UserMigrationBatchStatusFailed).Error)
require.NoError(t, db.Model(&model.UserMigrationItem{}).Where("id IN ?", itemIDs).
Updates(map[string]any{
"status": model.UserMigrationItemStatusFailed,
"error_message": "forced retry for e2e",
}).Error)
}

func setBatchStatus(t *testing.T, dbPath string, batchID int, status string) {
t.Helper()

db, err := helpers.OpenSQLite(dbPath)
require.NoError(t, err)
defer func() {
require.NoError(t, helpers.CloseSQLite(db))
}()

require.NoError(t, db.Model(&model.UserMigrationBatch{}).Where("id = ?", batchID).Update("status", status).Error)
}

func getBatchPage(t *testing.T, client *helpers.APIClient, batchID, page, pageSize int) *batchPageResponse {
t.Helper()

var resp batchPageResponse
require.NoError(t, client.GetJSON(
fmt.Sprintf("/api/user-migrations/batches/%d?page=%d&page_size=%d", batchID, page, pageSize),
200,
&resp,
))
require.True(t, resp.Success)
return &resp
}

func convertRemoteUserToSynced(t *testing.T, ovBaseURL string, sourceUserID, remoteUserID, syncedQuota int) {
t.Helper()

client, err := helpers.NewAPIClient(ovBaseURL)
require.NoError(t, err)

var resp simpleResponse
require.NoError(t, client.DoJSONWithHeaders(
"POST",
fmt.Sprintf("/api/internal/migration/users/%d/convert-to-synced", sourceUserID),
map[string]any{
"remote_user_id": remoteUserID,
"synced_quota": syncedQuota,
},
200,
map[string]string{
"X-Sync-API-Key": helpers.DefaultSyncAPIKey,
"X-Sync-Node": "e2e-test",
},
&resp,
))
require.True(t, resp.Success)
}

func indexItemsBySourceUserID(items []model.UserMigrationItem) map[int]*model.UserMigrationItem {
indexed := make(map[int]*model.UserMigrationItem, len(items))
for i := range items {
indexed[items[i].SourceUserId] = &items[i]
}
return indexed
}

func decodeFlags(t *testing.T, flagsJSON string) []string {
t.Helper()

var flags []string
require.NoError(t, common.UnmarshalJsonStr(flagsJSON, &flags))
return flags
}

+ 60
- 0
web/e2e/fixtures/cluster.ts View File

@@ -0,0 +1,60 @@
import { expect, test as base } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const currentDir = path.dirname(fileURLToPath(import.meta.url));
export const webRoot = path.resolve(currentDir, '..', '..');
export const repoRoot = path.resolve(webRoot, '..');
export const artifactRoot = path.join(
repoRoot,
'test-artifacts',
'user-migration-e2e',
);
export const playwrightArtifactRoot = path.join(artifactRoot, 'playwright');
export const authDir = path.join(playwrightArtifactRoot, 'auth');
export const metadataPath = path.join(playwrightArtifactRoot, 'metadata.json');
export const readyMarkerPath = path.join(
playwrightArtifactRoot,
'server-ready.json',
);
export const serverLogPath = path.join(playwrightArtifactRoot, 'ui-server.log');
export const serverBinaryDir = path.join(playwrightArtifactRoot, 'bin');
export const serverBinaryPath = path.join(
serverBinaryDir,
process.platform === 'win32'
? 'user-migration-ui-server.exe'
: 'user-migration-ui-server',
);
export const rootStorageStatePath = path.join(authDir, 'root-storage.json');
export const userStorageStatePath = path.join(authDir, 'user-storage.json');
export const adminStorageStatePath = userStorageStatePath;
export const clusterRunDir = path.join(artifactRoot, 'runs', 'playwright-ui');
export const cnBaseURL = 'http://127.0.0.1:3100';
export const ovBaseURL = 'http://127.0.0.1:3101';
export const rootUsername = 'root';
export const rootPassword = 'Password123!';
export const adminUsername = 'playwright-admin';
export const adminPassword = 'Password123!';

export type ClusterMetadata = {
serverPid: number;
serverBinaryPath: string;
serverLogPath: string;
runDir: string;
baseURL: string;
ovBaseURL: string;
};

export async function readClusterMetadata(): Promise<ClusterMetadata> {
const raw = await readFile(metadataPath, 'utf8');
return JSON.parse(raw) as ClusterMetadata;
}

export const test = base.extend<{ cluster: ClusterMetadata }>({
cluster: async ({}, use) => {
await use(await readClusterMetadata());
},
});

export { expect };

+ 306
- 0
web/e2e/global-setup.ts View File

@@ -0,0 +1,306 @@
import { request } from '@playwright/test';
import { spawn } from 'node:child_process';
import { access, open, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { setTimeout as delay } from 'node:timers/promises';
import {
adminPassword,
adminStorageStatePath,
adminUsername,
authDir,
clusterRunDir,
cnBaseURL,
metadataPath,
ovBaseURL,
playwrightArtifactRoot,
readyMarkerPath,
repoRoot,
rootPassword,
rootStorageStatePath,
rootUsername,
serverBinaryDir,
serverBinaryPath,
serverLogPath,
webRoot,
} from './fixtures/cluster';

type LoginResponse = {
success: boolean;
message?: string;
data?: {
id: number;
username: string;
display_name: string;
role: number;
status: number;
group: string;
};
};

type SimpleResponse = {
success: boolean;
message?: string;
error?: string;
};

async function runCommand(command: string, args: string[], cwd: string) {
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
shell: false,
});

let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(
new Error(
`${command} ${args.join(' ')} failed with code ${code}\n${stdout}${stderr}`,
),
);
});
});
}

async function killPreviousServer() {
try {
const raw = await readFile(metadataPath, 'utf8');
const metadata = JSON.parse(raw) as { serverPid?: number };
if (!metadata.serverPid) {
return;
}
await killProcessTree(metadata.serverPid);
} catch {
// ignore stale or missing metadata
}
}

async function killProcessTree(pid: number) {
if (process.platform === 'win32') {
await runCommand('taskkill', ['/PID', String(pid), '/T', '/F'], repoRoot);
return;
}
process.kill(pid, 'SIGTERM');
}

async function cleanupListenerPorts() {
if (process.platform !== 'win32') {
return;
}

await runCommand(
'powershell',
[
'-NoProfile',
'-Command',
"$ports = 3100,3101; " +
"Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | " +
"Where-Object { $ports -contains $_.LocalPort } | " +
'Select-Object -ExpandProperty OwningProcess -Unique | ' +
'ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }',
],
repoRoot,
);
}

async function waitForServerReady() {
const deadline = Date.now() + 180_000;

while (Date.now() < deadline) {
try {
await access(readyMarkerPath);
const response = await fetch(`${cnBaseURL}/api/status`, {
headers: { 'Cache-Control': 'no-store' },
});
if (response.ok) {
return;
}
} catch {
// retry
}
await delay(500);
}

throw new Error(
`user migration UI server did not become ready in time, see ${serverLogPath}`,
);
}

async function login(username: string, password: string) {
const api = await request.newContext({
baseURL: cnBaseURL,
extraHTTPHeaders: {
'Cache-Control': 'no-store',
},
});

const response = await api.post('/api/user/login', {
data: {
username,
password,
},
});
if (!response.ok()) {
throw new Error(`login request for ${username} failed: ${response.status()}`);
}

const payload = (await response.json()) as LoginResponse;
if (!payload.success || !payload.data) {
throw new Error(`login failed for ${username}: ${payload.message ?? 'unknown error'}`);
}

return { api, user: payload.data };
}

async function loginWithRetry(
username: string,
password: string,
timeoutMs = 60_000,
) {
const deadline = Date.now() + timeoutMs;
let lastError = `login failed for ${username}`;

while (Date.now() < deadline) {
try {
return await login(username, password);
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
await delay(500);
}
}

throw new Error(lastError);
}

async function writeStorageState(
api: Awaited<ReturnType<typeof request.newContext>>,
user: LoginResponse['data'],
outputPath: string,
) {
const state = await api.storageState();
await writeFile(
outputPath,
JSON.stringify(
{
cookies: state.cookies,
origins: [
{
origin: cnBaseURL,
localStorage: [
{
name: 'user',
value: JSON.stringify(user),
},
],
},
],
},
null,
2,
),
'utf8',
);
}

export default async function globalSetup() {
await killPreviousServer();
await cleanupListenerPorts();
await rm(playwrightArtifactRoot, { recursive: true, force: true });
await mkdir(authDir, { recursive: true });
await mkdir(serverBinaryDir, { recursive: true });

await runCommand('bun', ['run', 'build'], webRoot);
await runCommand(
'go',
['build', '-o', serverBinaryPath, './test/e2e/cmd/user_migration_ui_server'],
repoRoot,
);

const logFile = await open(serverLogPath, 'a');
const child = spawn(serverBinaryPath, [], {
cwd: repoRoot,
stdio: ['ignore', logFile.fd, logFile.fd],
});
await logFile.close();

await writeFile(
metadataPath,
JSON.stringify(
{
serverPid: child.pid,
serverBinaryPath,
serverLogPath,
runDir: clusterRunDir,
baseURL: cnBaseURL,
ovBaseURL,
},
null,
2,
),
'utf8',
);

await waitForServerReady();

const rootSession = await loginWithRetry(rootUsername, rootPassword);
try {
const createAdminResponse = await rootSession.api.post('/api/user/', {
headers: {
'New-API-User': String(rootSession.user.id),
},
data: {
username: adminUsername,
password: adminPassword,
display_name: 'Playwright Admin',
role: 10,
},
});
if (!createAdminResponse.ok()) {
throw new Error(
`create admin request failed: ${createAdminResponse.status()} ${createAdminResponse.statusText()}`,
);
}

const createAdminPayload = (await createAdminResponse.json()) as SimpleResponse;
const createAdminMessage =
createAdminPayload.message ?? createAdminPayload.error ?? 'unknown error';
const adminAlreadyExists =
createAdminMessage.includes('UNIQUE constraint failed: users.username') ||
createAdminMessage.includes('Duplicate entry');
if (!createAdminPayload.success && !adminAlreadyExists) {
throw new Error(
`create admin failed: ${createAdminMessage}`,
);
}

await writeStorageState(
rootSession.api,
rootSession.user,
rootStorageStatePath,
);
} finally {
await rootSession.api.dispose();
}

const adminSession = await loginWithRetry(adminUsername, adminPassword, 20_000);
try {
await writeStorageState(
adminSession.api,
adminSession.user,
adminStorageStatePath,
);
} finally {
await adminSession.api.dispose();
}
}

+ 44
- 0
web/e2e/global-teardown.ts View File

@@ -0,0 +1,44 @@
import { rm } from 'node:fs/promises';
import { metadataPath, playwrightArtifactRoot, repoRoot } from './fixtures/cluster';
import { readClusterMetadata } from './fixtures/cluster';
import { spawn } from 'node:child_process';

async function runCommand(command: string, args: string[], cwd: string) {
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: 'ignore',
shell: false,
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${command} ${args.join(' ')} failed with code ${code}`));
});
});
}

async function killProcessTree(pid: number) {
if (process.platform === 'win32') {
await runCommand('taskkill', ['/PID', String(pid), '/T', '/F'], repoRoot);
return;
}
process.kill(pid, 'SIGTERM');
}

export default async function globalTeardown() {
try {
const metadata = await readClusterMetadata();
await killProcessTree(metadata.serverPid);
} catch {
// ignore missing metadata or already-exited processes
}

await rm(metadataPath, { force: true });
await rm(playwrightArtifactRoot, { recursive: false, force: false }).catch(() => {
// keep artifacts when directory is not empty
});
}

+ 141
- 0
web/e2e/user-migration.spec.ts View File

@@ -0,0 +1,141 @@
import type { Page } from '@playwright/test';
import {
adminStorageStatePath,
expect,
rootStorageStatePath,
test,
} from './fixtures/cluster';

function nextBatchName(prefix: string) {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}

async function gotoUserPage(page: Page) {
await page.goto('/console/user');
await expect(page.getByTestId('user-page')).toBeVisible();
}

async function openMigrationTab(page: Page) {
const migrationTab = page.getByTestId('user-migration-tab');
await expect(migrationTab).toBeVisible();
await migrationTab.click();
await expect(page.getByTestId('user-migration-center')).toBeVisible();
}

async function createBatch(page: Page, batchName: string) {
await page.getByTestId('migration-create-batch-button').click();

const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();

const nameField = dialog.getByRole('textbox').first();
await expect(nameField).toBeVisible();
await nameField.fill(batchName);

await dialog
.getByRole('button', { name: /confirm|确定/i })
.click();
await expect(page.getByTestId('migration-batch-table')).toContainText(batchName);
await page.getByTestId('migration-batch-table').getByText(batchName).click();
await expect(page.getByTestId('migration-execution-panel')).toBeVisible();
await expect(page.getByTestId('migration-batch-detail')).toBeVisible();
}

test.describe('User Migration Access', () => {
test.use({ storageState: adminStorageStatePath });

test('non-root admin cannot see migration tab', async ({ page }) => {
await gotoUserPage(page);
await expect(page.getByTestId('user-migration-tab')).toHaveCount(0);
});
});

test.describe.serial('User Migration Smoke', () => {
test.use({ storageState: rootStorageStatePath });

test('root can open migration center and create a batch', async ({ page }) => {
const batchName = nextBatchName('ui-create');

await gotoUserPage(page);
await openMigrationTab(page);
await createBatch(page, batchName);

await expect(page.getByTestId('migration-batch-status')).toHaveText('draft');
});

test('root can scan, resolve conflicts, execute, and verify a batch', async ({
page,
}) => {
const batchName = nextBatchName('ui-flow');

await gotoUserPage(page);
await openMigrationTab(page);
await createBatch(page, batchName);

await page.getByTestId('migration-scan-button').click();
await expect(page.getByTestId('migration-batch-status')).toHaveText(
/scanning|scanned/,
);
await expect(page.getByTestId('migration-batch-status')).toHaveText(
'scanned',
{
timeout: 25_000,
},
);

await expect(page.getByTestId('migration-conflict-table')).toBeVisible();
await page.getByTestId('migration-resolve-skip-10000003').click();
await page.getByTestId('migration-resolve-skip-10000004').click();
await expect(page.getByTestId('migration-batch-status')).toHaveText('ready');

await page.getByTestId('migration-execute-button').click();
await expect(page.getByTestId('migration-execute-confirm')).toBeVisible();
await page.getByTestId('migration-execute-confirm').click();

await expect(page.getByTestId('migration-batch-status')).toHaveText(
/running|completed/,
);
await expect(page.getByTestId('migration-batch-status')).toHaveText(
'completed',
{
timeout: 25_000,
},
);

await page.getByTestId('migration-verify-button').click();
await expect(page.getByTestId('migration-verify-results-table')).toBeVisible();
await expect(page.getByTestId('migration-verify-results-table')).toContainText(
'10000001',
);
await expect(page.getByTestId('migration-batch-detail-table')).toContainText(
'migrated',
);
});

test('page reload keeps batch list and detail loading available', async ({
page,
}) => {
const batchName = nextBatchName('ui-refresh');

await gotoUserPage(page);
await openMigrationTab(page);
await createBatch(page, batchName);

await page.getByTestId('migration-scan-button').click();
await expect(page.getByTestId('migration-batch-status')).toHaveText(
'scanned',
{
timeout: 25_000,
},
);

await page.reload();
await gotoUserPage(page);
await openMigrationTab(page);

await expect(page.getByTestId('migration-batch-table')).toContainText(batchName);
await page.getByTestId('migration-batch-table').getByText(batchName).click();
await expect(page.getByTestId('migration-batch-detail')).toBeVisible();
await expect(page.getByTestId('migration-batch-status')).toHaveText('scanned');
});
});

+ 1
- 0
web/package.json View File

@@ -76,6 +76,7 @@
},
"devDependencies": {
"@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6",
"@playwright/test": "^1.60.0",
"@so1ve/prettier-config": "^3.1.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.21",


+ 35
- 0
web/playwright.config.ts View File

@@ -0,0 +1,35 @@
import { defineConfig } from '@playwright/test';
import path from 'node:path';
import { cnBaseURL, playwrightArtifactRoot } from './e2e/fixtures/cluster';

export default defineConfig({
testDir: './e2e',
testMatch: '**/*.spec.ts',
fullyParallel: false,
workers: 1,
timeout: 60_000,
expect: {
timeout: 15_000,
},
outputDir: path.join(playwrightArtifactRoot, 'test-output'),
reporter: [
['list'],
[
'html',
{
outputFolder: path.join(playwrightArtifactRoot, 'html-report'),
open: 'never',
},
],
],
globalSetup: './e2e/global-setup.ts',
globalTeardown: './e2e/global-teardown.ts',
use: {
baseURL: cnBaseURL,
channel: 'msedge',
headless: true,
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
});

+ 14
- 4
web/src/components/table/users/migration/BatchDetail.jsx View File

@@ -42,13 +42,21 @@ export default function BatchDetail({
dataIndex: 'status',
render: (value) => <Tag color={statusColorMap[value] || 'grey'}>{value}</Tag>,
},
{ title: t('目标国内账号'), dataIndex: 'target_user_id', render: (value) => value || '-' },
{
title: t('目标国内账号'),
dataIndex: 'target_user_id',
render: (value) => value || '-',
},
{ title: t('错误信息'), dataIndex: 'error_message', render: (value) => value || '-' },
];

return (
<div style={{ width: '100%' }}>
<Typography.Title heading={5} style={{ marginBottom: 12 }}>
<div style={{ width: '100%' }} data-testid='migration-batch-detail'>
<Typography.Title
heading={5}
style={{ marginBottom: 12 }}
data-testid='migration-batch-detail-title'
>
{t('批次详情')} #{selectedBatch.id}
</Typography.Title>
<Table
@@ -56,13 +64,15 @@ export default function BatchDetail({
columns={columns}
dataSource={items}
size='small'
data-testid='migration-batch-detail-table'
pagination={{
currentPage: itemsPage + 1,
pageSize: itemsPageSize,
total: itemsTotal,
pageSizeOpts: [20, 50, 100],
showSizeChanger: true,
onPageChange: (page) => fetchItems(selectedBatch.id, page - 1, itemsPageSize),
onPageChange: (page) =>
fetchItems(selectedBatch.id, page - 1, itemsPageSize),
onPageSizeChange: (size) => fetchItems(selectedBatch.id, 0, size),
}}
/>


+ 26
- 4
web/src/components/table/users/migration/BatchList.jsx View File

@@ -61,15 +61,27 @@ export default function BatchList({
};

return (
<div style={{ width: '100%' }}>
<div style={{ width: '100%' }} data-testid='migration-batch-list'>
<Space style={{ marginBottom: 12 }}>
<Typography.Title heading={5} style={{ margin: 0 }}>
<Typography.Title
heading={5}
style={{ margin: 0 }}
data-testid='migration-batch-list-title'
>
{t('迁移批次')}
</Typography.Title>
<Button icon={<IconPlus />} onClick={() => setVisible(true)}>
<Button
icon={<IconPlus />}
onClick={() => setVisible(true)}
data-testid='migration-create-batch-button'
>
{t('新建批次')}
</Button>
<Button icon={<IconRefresh />} onClick={refresh}>
<Button
icon={<IconRefresh />}
onClick={refresh}
data-testid='migration-refresh-batches-button'
>
{t('刷新')}
</Button>
</Space>
@@ -79,11 +91,15 @@ export default function BatchList({
dataSource={batches}
pagination={false}
size='small'
data-testid='migration-batch-table'
rowSelection={{
type: 'radio',
selectedRowKeys: selectedBatch ? [selectedBatch.id] : [],
onChange: (_, rows) => rows[0] && selectBatch(rows[0]),
}}
onRow={(record) => ({
onClick: () => selectBatch(record),
})}
empty={t('暂无数据')}
/>

@@ -92,6 +108,9 @@ export default function BatchList({
title={t('创建批次')}
onCancel={() => setVisible(false)}
onOk={handleCreate}
okButtonProps={{ 'data-testid': 'migration-create-batch-submit' }}
cancelButtonProps={{ 'data-testid': 'migration-create-batch-cancel' }}
data-testid='migration-create-batch-modal'
>
<Form
getFormApi={(api) => {
@@ -103,16 +122,19 @@ export default function BatchList({
field='name'
label={t('批次名称')}
rules={[{ required: true, message: t('请输入批次名称') }]}
data-testid='migration-batch-name-field'
/>
<Form.Input
field='source_region'
label={t('源区域')}
rules={[{ required: true, message: t('请输入源区域') }]}
data-testid='migration-batch-source-region-field'
/>
<Form.Input
field='target_region'
label={t('目标区域')}
rules={[{ required: true, message: t('请输入目标区域') }]}
data-testid='migration-batch-target-region-field'
/>
</Form>
</Modal>


+ 22
- 4
web/src/components/table/users/migration/ConflictResolver.jsx View File

@@ -39,6 +39,7 @@ export default function ConflictResolver({ items, resolveItem }) {
setTargets((prev) => ({ ...prev, [record.id]: value || 0 }));
}}
placeholder={t('请输入目标国内用户 ID')}
data-testid={`migration-resolve-target-${record.source_user_id}`}
/>
),
},
@@ -46,16 +47,32 @@ export default function ConflictResolver({ items, resolveItem }) {
title: t('操作'),
render: (_, record) => (
<Space>
<Button size='small' onClick={() => resolveItem(record.id, 'create_new')}>
<Button
size='small'
onClick={() => resolveItem(record.id, 'create_new')}
data-testid={`migration-resolve-create-${record.source_user_id}`}
>
{t('新建国内主账号')}
</Button>
<Button
size='small'
onClick={() => resolveItem(record.id, 'merge_into_existing', targets[record.id] || record.target_user_id || 0)}
onClick={() =>
resolveItem(
record.id,
'merge_into_existing',
targets[record.id] || record.target_user_id || 0,
)
}
data-testid={`migration-resolve-merge-${record.source_user_id}`}
>
{t('合并到现有国内账号')}
</Button>
<Button size='small' type='danger' onClick={() => resolveItem(record.id, 'skip')}>
<Button
size='small'
type='danger'
onClick={() => resolveItem(record.id, 'skip')}
data-testid={`migration-resolve-skip-${record.source_user_id}`}
>
{t('跳过此用户')}
</Button>
</Space>
@@ -64,7 +81,7 @@ export default function ConflictResolver({ items, resolveItem }) {
];

return (
<div style={{ width: '100%' }}>
<div style={{ width: '100%' }} data-testid='migration-conflict-resolver'>
<Typography.Title heading={5} style={{ marginBottom: 12 }}>
{t('冲突处理')}
</Typography.Title>
@@ -75,6 +92,7 @@ export default function ConflictResolver({ items, resolveItem }) {
pagination={false}
size='small'
empty={t('暂无数据')}
data-testid='migration-conflict-table'
/>
</div>
);


+ 34
- 7
web/src/components/table/users/migration/ExecutionPanel.jsx View File

@@ -30,7 +30,9 @@ export default function ExecutionPanel({
{
title: t('状态'),
render: (_, record) => (
<Tag color={record.error ? 'red' : 'green'}>{record.error ? t('失败数') : t('验证')}</Tag>
<Tag color={record.error ? 'red' : 'green'}>
{record.error ? t('失败数') : t('验证')}
</Tag>
),
},
{
@@ -40,25 +42,49 @@ export default function ExecutionPanel({
];

return (
<div style={{ width: '100%' }}>
<div style={{ width: '100%' }} data-testid='migration-execution-panel'>
<Space style={{ marginBottom: 12 }}>
<Typography.Title heading={5} style={{ margin: 0 }}>
{t('执行与验证')}
</Typography.Title>
<Tag color={runningStatusMap[selectedBatch.status] || 'grey'}>
<Tag
color={runningStatusMap[selectedBatch.status] || 'grey'}
data-testid='migration-batch-status'
>
{selectedBatch.status}
</Tag>
<Button onClick={() => scanBatch(selectedBatch.id)}>{t('预检查')}</Button>
<Button
onClick={() => scanBatch(selectedBatch.id)}
data-testid='migration-scan-button'
>
{t('预检查')}
</Button>
<Popconfirm
title={t('执行前请确认已进入冻结窗口')}
onConfirm={() => executeBatch(selectedBatch.id)}
okButtonProps={{ 'data-testid': 'migration-execute-confirm' }}
cancelButtonProps={{ 'data-testid': 'migration-execute-cancel' }}
>
<Button theme='solid' type='primary'>
<Button
theme='solid'
type='primary'
data-testid='migration-execute-button'
>
{t('开始迁移')}
</Button>
</Popconfirm>
<Button onClick={() => retryBatch(selectedBatch.id)}>{t('重试失败项')}</Button>
<Button onClick={() => verifyBatch(selectedBatch.id)}>{t('迁移后验证')}</Button>
<Button
onClick={() => retryBatch(selectedBatch.id)}
data-testid='migration-retry-button'
>
{t('重试失败项')}
</Button>
<Button
onClick={() => verifyBatch(selectedBatch.id)}
data-testid='migration-verify-button'
>
{t('迁移后验证')}
</Button>
</Space>
{verifyResults.length > 0 && (
<Table
@@ -67,6 +93,7 @@ export default function ExecutionPanel({
dataSource={verifyResults}
pagination={false}
size='small'
data-testid='migration-verify-results-table'
/>
)}
</div>


+ 7
- 1
web/src/components/table/users/migration/index.jsx View File

@@ -11,7 +11,13 @@ export default function UserMigrationCenter() {

return (
<Spin spinning={data.loading}>
<Space vertical align='start' style={{ width: '100%' }} spacing='medium'>
<Space
vertical
align='start'
style={{ width: '100%' }}
spacing='medium'
data-testid='user-migration-center'
>
<BatchList {...data} />
{data.selectedBatch && (
<>


+ 6
- 3
web/src/pages/User/index.jsx View File

@@ -28,8 +28,8 @@ import { isRoot } from '../../helpers';
const User = () => {
const { t } = useTranslation();
return (
<div className='mt-[60px] px-2'>
<Tabs type='line'>
<div className='mt-[60px] px-2' data-testid='user-page'>
<Tabs type='line' data-testid='user-tabs'>
<TabPane tab={t('用户列表')} itemKey='users'>
<UsersTable />
</TabPane>
@@ -37,7 +37,10 @@ const User = () => {
<SettingsEmailQuotaRule />
</TabPane>
{isRoot() && (
<TabPane tab={t('用户迁移')} itemKey='user-migration'>
<TabPane
tab={<span data-testid='user-migration-tab'>{t('用户迁移')}</span>}
itemKey='user-migration'
>
<UserMigrationCenter />
</TabPane>
)}


Loading…
Cancel
Save