Co-Authored-By: Codex <noreply@anthropic.com>master
| @@ -1,10 +1,11 @@ | |||||
| package controller | package controller | ||||
| import ( | import ( | ||||
| "errors" | |||||
| "net/http" | "net/http" | ||||
| "strconv" | "strconv" | ||||
| "strings" | |||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| user_migration "github.com/QuantumNous/new-api/service/user_migration" | user_migration "github.com/QuantumNous/new-api/service/user_migration" | ||||
| "github.com/gin-gonic/gin" | "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"}) | c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "user not found"}) | ||||
| return | return | ||||
| } | } | ||||
| if user.Role == common.RoleRootUser { | |||||
| c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "user not found"}) | |||||
| return | |||||
| } | |||||
| bindings, _ := model.GetUserOAuthBindingsByUserId(userId) | bindings, _ := model.GetUserOAuthBindingsByUserId(userId) | ||||
| c.JSON(http.StatusOK, gin.H{"success": true, "data": buildRemoteUserSnapshot(user, bindings)}) | 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 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()}) | c.JSON(http.StatusConflict, gin.H{"success": false, "error": err.Error()}) | ||||
| return | return | ||||
| } | } | ||||
| @@ -60,6 +60,14 @@ func TestListMigrationUsers_ReturnsLocalOnly(t *testing.T) { | |||||
| Source: common.UserSourceSynced, | Source: common.UserSourceSynced, | ||||
| RemoteUserId: 1, | RemoteUserId: 1, | ||||
| }).Error) | }).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) | req := httptest.NewRequest(http.MethodGet, "/api/internal/migration/users?page=0&page_size=20", nil) | ||||
| w := httptest.NewRecorder() | w := httptest.NewRecorder() | ||||
| @@ -72,6 +80,82 @@ func TestListMigrationUsers_ReturnsLocalOnly(t *testing.T) { | |||||
| require.EqualValues(t, 1, body["total"]) | 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) { | func TestConvertMigrationUserToSynced_Idempotent(t *testing.T) { | ||||
| gin.SetMode(gin.TestMode) | gin.SetMode(gin.TestMode) | ||||
| router := gin.New() | router := gin.New() | ||||
| @@ -138,6 +222,140 @@ func TestConvertMigrationUserToSynced_ConflictRemoteId(t *testing.T) { | |||||
| require.Equal(t, http.StatusConflict, w.Code) | 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) { | func TestCheckSyncedCopy(t *testing.T) { | ||||
| gin.SetMode(gin.TestMode) | gin.SetMode(gin.TestMode) | ||||
| router := gin.New() | router := gin.New() | ||||
| @@ -562,9 +562,15 @@ func UpdateUser(c *gin.Context) { | |||||
| common.ApiError(c, err) | common.ApiError(c, err) | ||||
| return | 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") | myRole := c.GetInt("role") | ||||
| if myRole <= originUser.Role && myRole != common.RoleRootUser { | if myRole <= originUser.Role && myRole != common.RoleRootUser { | ||||
| @@ -10,6 +10,14 @@ import ( | |||||
| "github.com/gin-gonic/gin" | "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) { | func CreateUserMigrationBatch(c *gin.Context) { | ||||
| var req struct { | var req struct { | ||||
| Name string `json:"name" binding:"required"` | Name string `json:"name" binding:"required"` | ||||
| @@ -202,7 +210,7 @@ func VerifyUserMigrationBatch(c *gin.Context) { | |||||
| var items []model.UserMigrationItem | var items []model.UserMigrationItem | ||||
| model.DB.Where("batch_id = ? AND status = ?", batchID, model.UserMigrationItemStatusMigrated).Find(&items) | model.DB.Where("batch_id = ? AND status = ?", batchID, model.UserMigrationItemStatusMigrated).Find(&items) | ||||
| svc := newMigrationService() | |||||
| svc := newMigrationVerifier() | |||||
| results := make([]map[string]any, 0, len(items)) | results := make([]map[string]any, 0, len(items)) | ||||
| for _, item := range items { | for _, item := range items { | ||||
| result, err := svc.VerifyItem(&item) | result, err := svc.VerifyItem(&item) | ||||
| @@ -3,11 +3,13 @@ package controller | |||||
| import ( | import ( | ||||
| "bytes" | "bytes" | ||||
| "encoding/json" | "encoding/json" | ||||
| "errors" | |||||
| "net/http" | "net/http" | ||||
| "net/http/httptest" | "net/http/httptest" | ||||
| "testing" | "testing" | ||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| user_migration "github.com/QuantumNous/new-api/service/user_migration" | |||||
| "github.com/gin-gonic/gin" | "github.com/gin-gonic/gin" | ||||
| "github.com/glebarez/sqlite" | "github.com/glebarez/sqlite" | ||||
| "github.com/stretchr/testify/require" | "github.com/stretchr/testify/require" | ||||
| @@ -26,6 +28,8 @@ func setupUserMigrationControllerDB(t *testing.T) *gorm.DB { | |||||
| require.NoError(t, db.AutoMigrate( | require.NoError(t, db.AutoMigrate( | ||||
| &model.User{}, | &model.User{}, | ||||
| &model.CustomOAuthProvider{}, | |||||
| &model.UserOAuthBinding{}, | |||||
| &model.UserMigrationBatch{}, | &model.UserMigrationBatch{}, | ||||
| &model.UserMigrationItem{}, | &model.UserMigrationItem{}, | ||||
| &model.MigrationQuotaGrant{}, | &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) { | func TestCreateUserMigrationBatch_Success(t *testing.T) { | ||||
| gin.SetMode(gin.TestMode) | gin.SetMode(gin.TestMode) | ||||
| router := gin.New() | router := gin.New() | ||||
| @@ -75,6 +94,105 @@ func TestCreateUserMigrationBatch_Success(t *testing.T) { | |||||
| require.NotNil(t, resp["data"]) | 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) { | func TestExecuteUserMigrationBatch_AlreadyRunning(t *testing.T) { | ||||
| gin.SetMode(gin.TestMode) | gin.SetMode(gin.TestMode) | ||||
| router := gin.New() | router := gin.New() | ||||
| @@ -104,6 +222,37 @@ func TestExecuteUserMigrationBatch_AlreadyRunning(t *testing.T) { | |||||
| require.Equal(t, http.StatusConflict, w.Code) | 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) { | func TestResolveUserMigrationItem_InvalidStrategy(t *testing.T) { | ||||
| gin.SetMode(gin.TestMode) | gin.SetMode(gin.TestMode) | ||||
| router := gin.New() | router := gin.New() | ||||
| @@ -179,3 +328,143 @@ func TestResolveUserMigrationItem_RefreshesBatchStatusToReady(t *testing.T) { | |||||
| require.NoError(t, db.First(&refreshed, batch.Id).Error) | require.NoError(t, db.First(&refreshed, batch.Id).Error) | ||||
| require.Equal(t, model.UserMigrationBatchStatusReady, refreshed.Status) | 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 | package model | ||||
| import ( | import ( | ||||
| "errors" | |||||
| "testing" | "testing" | ||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| @@ -152,7 +153,7 @@ func TestConvertUserToSynced_DifferentRemoteUserIdFails(t *testing.T) { | |||||
| err := ConvertUserToSynced(10000002, 9999, 100) | err := ConvertUserToSynced(10000002, 9999, 100) | ||||
| require.Error(t, err) | require.Error(t, err) | ||||
| require.Contains(t, err.Error(), "already synced") | |||||
| require.ErrorIs(t, err, ErrSyncedUserRemoteUserIDImmutable) | |||||
| } | } | ||||
| func TestListLocalUsersForMigration_FiltersLocalOnly(t *testing.T) { | func TestListLocalUsersForMigration_FiltersLocalOnly(t *testing.T) { | ||||
| @@ -178,6 +179,14 @@ func TestListLocalUsersForMigration_FiltersLocalOnly(t *testing.T) { | |||||
| Source: common.UserSourceSynced, | Source: common.UserSourceSynced, | ||||
| RemoteUserId: 1, | RemoteUserId: 1, | ||||
| }).Error) | }).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) | users, total, err := ListLocalUsersForMigration(0, 20) | ||||
| require.NoError(t, err) | require.NoError(t, err) | ||||
| @@ -185,3 +194,53 @@ func TestListLocalUsersForMigration_FiltersLocalOnly(t *testing.T) { | |||||
| require.Len(t, users, 1) | require.Len(t, users, 1) | ||||
| require.Equal(t, "local-1", users[0].Username) | 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 users []*User | ||||
| var total int64 | 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 { | if err := query.Count(&total).Error; err != nil { | ||||
| return nil, 0, err | return nil, 0, err | ||||
| } | } | ||||
| @@ -1124,16 +1126,25 @@ func HasOVSyncedCopyByRemoteId(cnUserId int) (bool, error) { | |||||
| return count > 0, err | 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 { | func ConvertUserToSynced(userId, remoteUserId, syncedQuota int) error { | ||||
| var user User | var user User | ||||
| if err := DB.Where("id = ?", userId).First(&user).Error; err != nil { | if err := DB.Where("id = ?", userId).First(&user).Error; err != nil { | ||||
| return err | return err | ||||
| } | } | ||||
| if user.Role == common.RoleRootUser { | |||||
| return ErrConvertRootUserToSynced | |||||
| } | |||||
| now := common.GetTimestamp() | now := common.GetTimestamp() | ||||
| if user.Source == common.UserSourceSynced { | if user.Source == common.UserSourceSynced { | ||||
| if user.RemoteUserId != remoteUserId { | 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) | userId, user.RemoteUserId, remoteUserId) | ||||
| } | } | ||||
| if user.SyncedQuota == syncedQuota { | if user.SyncedQuota == syncedQuota { | ||||
| @@ -182,13 +182,14 @@ func RefreshUserMigrationBatchStats(batchId int) error { | |||||
| } | } | ||||
| func RefreshUserMigrationBatchExecutionStats(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, UserMigrationItemStatusMigrated).Count(&migrated) | ||||
| DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status = ?", batchId, UserMigrationItemStatusFailed).Count(&failed) | DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status = ?", batchId, UserMigrationItemStatusFailed).Count(&failed) | ||||
| DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status IN ?", batchId, []string{ | DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status IN ?", batchId, []string{ | ||||
| UserMigrationItemStatusReady, | UserMigrationItemStatusReady, | ||||
| UserMigrationItemStatusMigrating, | UserMigrationItemStatusMigrating, | ||||
| }).Count(&pending) | }).Count(&pending) | ||||
| DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status = ?", batchId, UserMigrationItemStatusMigrating).Count(&migrating) | |||||
| status := UserMigrationBatchStatusCompleted | status := UserMigrationBatchStatusCompleted | ||||
| if failed > 0 { | if failed > 0 { | ||||
| @@ -197,6 +198,9 @@ func RefreshUserMigrationBatchExecutionStats(batchId int) error { | |||||
| if pending > 0 { | if pending > 0 { | ||||
| status = UserMigrationBatchStatusRunning | status = UserMigrationBatchStatusRunning | ||||
| } | } | ||||
| if pending == migrating && migrating > 0 { | |||||
| status = UserMigrationBatchStatusFailed | |||||
| } | |||||
| return DB.Model(&UserMigrationBatch{}).Where("id = ?", batchId).Updates(map[string]any{ | return DB.Model(&UserMigrationBatch{}).Where("id = ?", batchId).Updates(map[string]any{ | ||||
| "migrated_users": int(migrated), | "migrated_users": int(migrated), | ||||
| @@ -179,3 +179,36 @@ func TestRefreshBatchStats_ReadyOnlyWhenNoPendingScan(t *testing.T) { | |||||
| require.NoError(t, DB.First(&refreshed, batch.Id).Error) | require.NoError(t, DB.First(&refreshed, batch.Id).Error) | ||||
| require.Equal(t, UserMigrationBatchStatusScanned, refreshed.Status) | 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 { | if err != nil { | ||||
| return err | return err | ||||
| } | } | ||||
| providerSlugMap, err := buildProviderSlugMap() | |||||
| if err != nil { | |||||
| return err | |||||
| } | |||||
| for _, item := range items { | for _, item := range items { | ||||
| if item.Status == model.UserMigrationItemStatusMigrated { | if item.Status == model.UserMigrationItemStatusMigrated { | ||||
| continue | continue | ||||
| } | } | ||||
| if err := s.executeItem(item); err != nil { | |||||
| if err := s.executeItem(item, providerSlugMap); err != nil { | |||||
| _ = model.MarkUserMigrationItemFailed(item.Id, err.Error()) | _ = model.MarkUserMigrationItemFailed(item.Id, err.Error()) | ||||
| } | } | ||||
| } | } | ||||
| @@ -27,12 +31,12 @@ func (s *Service) ExecuteBatch(batchId int) error { | |||||
| return model.RefreshUserMigrationBatchExecutionStats(batchId) | 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 { | switch item.ResolutionStrategy { | ||||
| case model.UserMigrationStrategyCreateNew: | case model.UserMigrationStrategyCreateNew: | ||||
| return s.executeCreateNew(item) | |||||
| return s.executeCreateNew(item, providerSlugMap) | |||||
| case model.UserMigrationStrategyMergeExisting: | case model.UserMigrationStrategyMergeExisting: | ||||
| return s.executeMergeExisting(item) | |||||
| return s.executeMergeExisting(item, providerSlugMap) | |||||
| case model.UserMigrationStrategySkip: | case model.UserMigrationStrategySkip: | ||||
| return model.MarkUserMigrationItemSkipped(item.Id) | return model.MarkUserMigrationItemSkipped(item.Id) | ||||
| default: | 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 { | 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) | cnUser, err := model.GetUserById(item.TargetUserId, true) | ||||
| if err != nil { | if err != nil { | ||||
| return err | return err | ||||
| @@ -53,7 +67,7 @@ func (s *Service) executeCreateNew(item *model.UserMigrationItem) error { | |||||
| if err != nil { | if err != nil { | ||||
| return fmt.Errorf("get remote user %d: %w", item.SourceUserId, err) | 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 | return err | ||||
| } | } | ||||
| if err := markItemMigrating(item.Id); err != nil { | 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) | 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) | snapshot, err := s.client.GetUser(item.SourceUserId) | ||||
| if err != nil { | if err != nil { | ||||
| return fmt.Errorf("get remote user %d: %w", item.SourceUserId, err) | 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 | return err | ||||
| } | } | ||||
| if err := markItemMigrating(item.Id); err != nil { | if err := markItemMigrating(item.Id); err != nil { | ||||
| @@ -197,24 +211,40 @@ func (s *Service) ensureRemoteConverted(item *model.UserMigrationItem, syncedQuo | |||||
| return markItemMigrated(item.Id) | 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 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) | 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 { | if err != nil { | ||||
| return err | 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 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.Source == common.UserSourceSynced { | ||||
| if snapshot.RemoteUserId == item.TargetUserId { | if snapshot.RemoteUserId == item.TargetUserId { | ||||
| return nil | 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) | 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 { | if err != nil { | ||||
| return err | return err | ||||
| } | } | ||||
| @@ -234,11 +264,7 @@ func (s *Service) revalidateMergeExisting(item *model.UserMigrationItem, snapsho | |||||
| return nil | 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) | result, err := analyzeConflicts(snapshot, providerSlugMap, s.client) | ||||
| if err != nil { | if err != nil { | ||||
| return nil, fmt.Errorf("revalidate conflicts for user %d: %w", snapshot.Id, err) | 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") | 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) { | func TestScanBatch_Paginated(t *testing.T) { | ||||
| db := setupMigrationServiceDB(t) | db := setupMigrationServiceDB(t) | ||||
| orig := model.DB | orig := model.DB | ||||
| @@ -952,3 +1285,218 @@ func TestVerifyItem_QuotaMismatchDetected(t *testing.T) { | |||||
| require.True(t, result.RemoteConverted) | require.True(t, result.RemoteConverted) | ||||
| require.False(t, result.QuotaMatched) | 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{ | return &VerifyResult{ | ||||
| TargetUserExists: targetUser != nil, | TargetUserExists: targetUser != nil, | ||||
| RemoteConverted: remoteConverted, | RemoteConverted: remoteConverted, | ||||
| QuotaMatched: targetUser != nil && remoteConverted && remoteUser.SyncedQuota == targetUser.Quota, | |||||
| QuotaMatched: targetUser != nil && remoteConverted && remoteUser.SyncedQuota >= item.SourceQuota, | |||||
| }, nil | }, 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": { | "devDependencies": { | ||||
| "@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6", | "@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6", | ||||
| "@playwright/test": "^1.60.0", | |||||
| "@so1ve/prettier-config": "^3.1.0", | "@so1ve/prettier-config": "^3.1.0", | ||||
| "@vitejs/plugin-react": "^4.2.1", | "@vitejs/plugin-react": "^4.2.1", | ||||
| "autoprefixer": "^10.4.21", | "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', | dataIndex: 'status', | ||||
| render: (value) => <Tag color={statusColorMap[value] || 'grey'}>{value}</Tag>, | 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 || '-' }, | { title: t('错误信息'), dataIndex: 'error_message', render: (value) => value || '-' }, | ||||
| ]; | ]; | ||||
| return ( | 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} | {t('批次详情')} #{selectedBatch.id} | ||||
| </Typography.Title> | </Typography.Title> | ||||
| <Table | <Table | ||||
| @@ -56,13 +64,15 @@ export default function BatchDetail({ | |||||
| columns={columns} | columns={columns} | ||||
| dataSource={items} | dataSource={items} | ||||
| size='small' | size='small' | ||||
| data-testid='migration-batch-detail-table' | |||||
| pagination={{ | pagination={{ | ||||
| currentPage: itemsPage + 1, | currentPage: itemsPage + 1, | ||||
| pageSize: itemsPageSize, | pageSize: itemsPageSize, | ||||
| total: itemsTotal, | total: itemsTotal, | ||||
| pageSizeOpts: [20, 50, 100], | pageSizeOpts: [20, 50, 100], | ||||
| showSizeChanger: true, | 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), | onPageSizeChange: (size) => fetchItems(selectedBatch.id, 0, size), | ||||
| }} | }} | ||||
| /> | /> | ||||
| @@ -61,15 +61,27 @@ export default function BatchList({ | |||||
| }; | }; | ||||
| return ( | return ( | ||||
| <div style={{ width: '100%' }}> | |||||
| <div style={{ width: '100%' }} data-testid='migration-batch-list'> | |||||
| <Space style={{ marginBottom: 12 }}> | <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('迁移批次')} | {t('迁移批次')} | ||||
| </Typography.Title> | </Typography.Title> | ||||
| <Button icon={<IconPlus />} onClick={() => setVisible(true)}> | |||||
| <Button | |||||
| icon={<IconPlus />} | |||||
| onClick={() => setVisible(true)} | |||||
| data-testid='migration-create-batch-button' | |||||
| > | |||||
| {t('新建批次')} | {t('新建批次')} | ||||
| </Button> | </Button> | ||||
| <Button icon={<IconRefresh />} onClick={refresh}> | |||||
| <Button | |||||
| icon={<IconRefresh />} | |||||
| onClick={refresh} | |||||
| data-testid='migration-refresh-batches-button' | |||||
| > | |||||
| {t('刷新')} | {t('刷新')} | ||||
| </Button> | </Button> | ||||
| </Space> | </Space> | ||||
| @@ -79,11 +91,15 @@ export default function BatchList({ | |||||
| dataSource={batches} | dataSource={batches} | ||||
| pagination={false} | pagination={false} | ||||
| size='small' | size='small' | ||||
| data-testid='migration-batch-table' | |||||
| rowSelection={{ | rowSelection={{ | ||||
| type: 'radio', | type: 'radio', | ||||
| selectedRowKeys: selectedBatch ? [selectedBatch.id] : [], | selectedRowKeys: selectedBatch ? [selectedBatch.id] : [], | ||||
| onChange: (_, rows) => rows[0] && selectBatch(rows[0]), | onChange: (_, rows) => rows[0] && selectBatch(rows[0]), | ||||
| }} | }} | ||||
| onRow={(record) => ({ | |||||
| onClick: () => selectBatch(record), | |||||
| })} | |||||
| empty={t('暂无数据')} | empty={t('暂无数据')} | ||||
| /> | /> | ||||
| @@ -92,6 +108,9 @@ export default function BatchList({ | |||||
| title={t('创建批次')} | title={t('创建批次')} | ||||
| onCancel={() => setVisible(false)} | onCancel={() => setVisible(false)} | ||||
| onOk={handleCreate} | onOk={handleCreate} | ||||
| okButtonProps={{ 'data-testid': 'migration-create-batch-submit' }} | |||||
| cancelButtonProps={{ 'data-testid': 'migration-create-batch-cancel' }} | |||||
| data-testid='migration-create-batch-modal' | |||||
| > | > | ||||
| <Form | <Form | ||||
| getFormApi={(api) => { | getFormApi={(api) => { | ||||
| @@ -103,16 +122,19 @@ export default function BatchList({ | |||||
| field='name' | field='name' | ||||
| label={t('批次名称')} | label={t('批次名称')} | ||||
| rules={[{ required: true, message: t('请输入批次名称') }]} | rules={[{ required: true, message: t('请输入批次名称') }]} | ||||
| data-testid='migration-batch-name-field' | |||||
| /> | /> | ||||
| <Form.Input | <Form.Input | ||||
| field='source_region' | field='source_region' | ||||
| label={t('源区域')} | label={t('源区域')} | ||||
| rules={[{ required: true, message: t('请输入源区域') }]} | rules={[{ required: true, message: t('请输入源区域') }]} | ||||
| data-testid='migration-batch-source-region-field' | |||||
| /> | /> | ||||
| <Form.Input | <Form.Input | ||||
| field='target_region' | field='target_region' | ||||
| label={t('目标区域')} | label={t('目标区域')} | ||||
| rules={[{ required: true, message: t('请输入目标区域') }]} | rules={[{ required: true, message: t('请输入目标区域') }]} | ||||
| data-testid='migration-batch-target-region-field' | |||||
| /> | /> | ||||
| </Form> | </Form> | ||||
| </Modal> | </Modal> | ||||
| @@ -39,6 +39,7 @@ export default function ConflictResolver({ items, resolveItem }) { | |||||
| setTargets((prev) => ({ ...prev, [record.id]: value || 0 })); | setTargets((prev) => ({ ...prev, [record.id]: value || 0 })); | ||||
| }} | }} | ||||
| placeholder={t('请输入目标国内用户 ID')} | placeholder={t('请输入目标国内用户 ID')} | ||||
| data-testid={`migration-resolve-target-${record.source_user_id}`} | |||||
| /> | /> | ||||
| ), | ), | ||||
| }, | }, | ||||
| @@ -46,16 +47,32 @@ export default function ConflictResolver({ items, resolveItem }) { | |||||
| title: t('操作'), | title: t('操作'), | ||||
| render: (_, record) => ( | render: (_, record) => ( | ||||
| <Space> | <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('新建国内主账号')} | {t('新建国内主账号')} | ||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| size='small' | 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('合并到现有国内账号')} | {t('合并到现有国内账号')} | ||||
| </Button> | </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('跳过此用户')} | {t('跳过此用户')} | ||||
| </Button> | </Button> | ||||
| </Space> | </Space> | ||||
| @@ -64,7 +81,7 @@ export default function ConflictResolver({ items, resolveItem }) { | |||||
| ]; | ]; | ||||
| return ( | return ( | ||||
| <div style={{ width: '100%' }}> | |||||
| <div style={{ width: '100%' }} data-testid='migration-conflict-resolver'> | |||||
| <Typography.Title heading={5} style={{ marginBottom: 12 }}> | <Typography.Title heading={5} style={{ marginBottom: 12 }}> | ||||
| {t('冲突处理')} | {t('冲突处理')} | ||||
| </Typography.Title> | </Typography.Title> | ||||
| @@ -75,6 +92,7 @@ export default function ConflictResolver({ items, resolveItem }) { | |||||
| pagination={false} | pagination={false} | ||||
| size='small' | size='small' | ||||
| empty={t('暂无数据')} | empty={t('暂无数据')} | ||||
| data-testid='migration-conflict-table' | |||||
| /> | /> | ||||
| </div> | </div> | ||||
| ); | ); | ||||
| @@ -30,7 +30,9 @@ export default function ExecutionPanel({ | |||||
| { | { | ||||
| title: t('状态'), | title: t('状态'), | ||||
| render: (_, record) => ( | 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 ( | return ( | ||||
| <div style={{ width: '100%' }}> | |||||
| <div style={{ width: '100%' }} data-testid='migration-execution-panel'> | |||||
| <Space style={{ marginBottom: 12 }}> | <Space style={{ marginBottom: 12 }}> | ||||
| <Typography.Title heading={5} style={{ margin: 0 }}> | <Typography.Title heading={5} style={{ margin: 0 }}> | ||||
| {t('执行与验证')} | {t('执行与验证')} | ||||
| </Typography.Title> | </Typography.Title> | ||||
| <Tag color={runningStatusMap[selectedBatch.status] || 'grey'}> | |||||
| <Tag | |||||
| color={runningStatusMap[selectedBatch.status] || 'grey'} | |||||
| data-testid='migration-batch-status' | |||||
| > | |||||
| {selectedBatch.status} | {selectedBatch.status} | ||||
| </Tag> | </Tag> | ||||
| <Button onClick={() => scanBatch(selectedBatch.id)}>{t('预检查')}</Button> | |||||
| <Button | |||||
| onClick={() => scanBatch(selectedBatch.id)} | |||||
| data-testid='migration-scan-button' | |||||
| > | |||||
| {t('预检查')} | |||||
| </Button> | |||||
| <Popconfirm | <Popconfirm | ||||
| title={t('执行前请确认已进入冻结窗口')} | title={t('执行前请确认已进入冻结窗口')} | ||||
| onConfirm={() => executeBatch(selectedBatch.id)} | 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('开始迁移')} | {t('开始迁移')} | ||||
| </Button> | </Button> | ||||
| </Popconfirm> | </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> | </Space> | ||||
| {verifyResults.length > 0 && ( | {verifyResults.length > 0 && ( | ||||
| <Table | <Table | ||||
| @@ -67,6 +93,7 @@ export default function ExecutionPanel({ | |||||
| dataSource={verifyResults} | dataSource={verifyResults} | ||||
| pagination={false} | pagination={false} | ||||
| size='small' | size='small' | ||||
| data-testid='migration-verify-results-table' | |||||
| /> | /> | ||||
| )} | )} | ||||
| </div> | </div> | ||||
| @@ -11,7 +11,13 @@ export default function UserMigrationCenter() { | |||||
| return ( | return ( | ||||
| <Spin spinning={data.loading}> | <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} /> | <BatchList {...data} /> | ||||
| {data.selectedBatch && ( | {data.selectedBatch && ( | ||||
| <> | <> | ||||
| @@ -28,8 +28,8 @@ import { isRoot } from '../../helpers'; | |||||
| const User = () => { | const User = () => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| return ( | 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'> | <TabPane tab={t('用户列表')} itemKey='users'> | ||||
| <UsersTable /> | <UsersTable /> | ||||
| </TabPane> | </TabPane> | ||||
| @@ -37,7 +37,10 @@ const User = () => { | |||||
| <SettingsEmailQuotaRule /> | <SettingsEmailQuotaRule /> | ||||
| </TabPane> | </TabPane> | ||||
| {isRoot() && ( | {isRoot() && ( | ||||
| <TabPane tab={t('用户迁移')} itemKey='user-migration'> | |||||
| <TabPane | |||||
| tab={<span data-testid='user-migration-tab'>{t('用户迁移')}</span>} | |||||
| itemKey='user-migration' | |||||
| > | |||||
| <UserMigrationCenter /> | <UserMigrationCenter /> | ||||
| </TabPane> | </TabPane> | ||||
| )} | )} | ||||