Co-Authored-By: Codex <noreply@anthropic.com>master
| @@ -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 | |||
| } | |||
| @@ -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() | |||
| @@ -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 { | |||
| @@ -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) | |||
| @@ -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"]) | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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 中无明显无关脏改动 | |||
| - 测试覆盖矩阵可供审阅 | |||
| - 提交范围已经可清晰切分 | |||
| @@ -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` 通过 | |||
| @@ -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. 若新增场景改变运行产物或依赖,再同步更新本文档 | |||
| @@ -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)) | |||
| } | |||
| @@ -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 { | |||
| @@ -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), | |||
| @@ -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) | |||
| } | |||
| @@ -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") | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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) | |||
| @@ -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) | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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"` | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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 }; | |||
| @@ -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(); | |||
| } | |||
| } | |||
| @@ -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 | |||
| }); | |||
| } | |||
| @@ -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'); | |||
| }); | |||
| }); | |||
| @@ -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", | |||
| @@ -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', | |||
| }, | |||
| }); | |||
| @@ -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), | |||
| }} | |||
| /> | |||
| @@ -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> | |||
| @@ -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> | |||
| ); | |||
| @@ -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> | |||
| @@ -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 && ( | |||
| <> | |||
| @@ -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> | |||
| )} | |||