diff --git a/docs/generate_doc/ticloud_serverless.md b/docs/generate_doc/ticloud_serverless.md index 8fe93af7..d2c09ca0 100644 --- a/docs/generate_doc/ticloud_serverless.md +++ b/docs/generate_doc/ticloud_serverless.md @@ -30,6 +30,7 @@ Manage TiDB Cloud Serverless clusters * [ticloud serverless export](ticloud_serverless_export.md) - Manage TiDB Cloud Serverless exports * [ticloud serverless import](ticloud_serverless_import.md) - Manage TiDB Cloud Serverless data imports * [ticloud serverless list](ticloud_serverless_list.md) - List all TiDB Cloud Serverless clusters +* [ticloud serverless migration](ticloud_serverless_migration.md) - Manage TiDB Cloud Serverless migrations * [ticloud serverless private-link-connection](ticloud_serverless_private-link-connection.md) - Manage private link connections for dataflow * [ticloud serverless region](ticloud_serverless_region.md) - List all available regions for TiDB Cloud Serverless * [ticloud serverless shell](ticloud_serverless_shell.md) - Connect to a TiDB Cloud Serverless cluster diff --git a/docs/generate_doc/ticloud_serverless_migration.md b/docs/generate_doc/ticloud_serverless_migration.md new file mode 100644 index 00000000..84762e5a --- /dev/null +++ b/docs/generate_doc/ticloud_serverless_migration.md @@ -0,0 +1,29 @@ +## ticloud serverless migration + +Manage TiDB Cloud Serverless migrations + +### Options + +``` + -h, --help help for migration +``` + +### Options inherited from parent commands + +``` + -D, --debug Enable debug mode + --no-color Disable color output + -P, --profile string Profile to use from your configuration file +``` + +### SEE ALSO + +* [ticloud serverless](ticloud_serverless.md) - Manage TiDB Cloud Serverless clusters +* [ticloud serverless migration create](ticloud_serverless_migration_create.md) - Create a migration +* [ticloud serverless migration delete](ticloud_serverless_migration_delete.md) - Delete a migration +* [ticloud serverless migration describe](ticloud_serverless_migration_describe.md) - Describe a migration +* [ticloud serverless migration list](ticloud_serverless_migration_list.md) - List migrations +* [ticloud serverless migration pause](ticloud_serverless_migration_pause.md) - Pause a migration +* [ticloud serverless migration resume](ticloud_serverless_migration_resume.md) - Resume a paused migration +* [ticloud serverless migration template](ticloud_serverless_migration_template.md) - Show migration JSON templates + diff --git a/docs/generate_doc/ticloud_serverless_migration_create.md b/docs/generate_doc/ticloud_serverless_migration_create.md new file mode 100644 index 00000000..e165eb8c --- /dev/null +++ b/docs/generate_doc/ticloud_serverless_migration_create.md @@ -0,0 +1,39 @@ +## ticloud serverless migration create + +Create a migration + +``` +ticloud serverless migration create [flags] +``` + +### Examples + +``` + Create a migration: + $ ticloud serverless migration create -c --display-name --config-file --dry-run + $ ticloud serverless migration create -c --display-name --config-file + +``` + +### Options + +``` + -c, --cluster-id string The ID of the target cluster. + --config-file string Path to a migration config JSON file. Use "ticloud serverless migration template --mode " to print templates. + -n, --display-name string Display name for the migration. + --dry-run Run a migration precheck (dry run) with the provided inputs without creating a migration. + -h, --help help for create +``` + +### Options inherited from parent commands + +``` + -D, --debug Enable debug mode + --no-color Disable color output + -P, --profile string Profile to use from your configuration file +``` + +### SEE ALSO + +* [ticloud serverless migration](ticloud_serverless_migration.md) - Manage TiDB Cloud Serverless migrations + diff --git a/docs/generate_doc/ticloud_serverless_migration_delete.md b/docs/generate_doc/ticloud_serverless_migration_delete.md new file mode 100644 index 00000000..6ce05b3f --- /dev/null +++ b/docs/generate_doc/ticloud_serverless_migration_delete.md @@ -0,0 +1,39 @@ +## ticloud serverless migration delete + +Delete a migration + +``` +ticloud serverless migration delete [flags] +``` + +### Examples + +``` + Delete a migration in interactive mode: + $ ticloud serverless migration delete + + Delete a migration in non-interactive mode: + $ ticloud serverless migration delete -c --migration-id +``` + +### Options + +``` + -c, --cluster-id string Cluster ID that owns the migration. + --force Delete without confirmation. + -h, --help help for delete + -m, --migration-id string ID of the migration to delete. +``` + +### Options inherited from parent commands + +``` + -D, --debug Enable debug mode + --no-color Disable color output + -P, --profile string Profile to use from your configuration file +``` + +### SEE ALSO + +* [ticloud serverless migration](ticloud_serverless_migration.md) - Manage TiDB Cloud Serverless migrations + diff --git a/docs/generate_doc/ticloud_serverless_migration_describe.md b/docs/generate_doc/ticloud_serverless_migration_describe.md new file mode 100644 index 00000000..278657b5 --- /dev/null +++ b/docs/generate_doc/ticloud_serverless_migration_describe.md @@ -0,0 +1,38 @@ +## ticloud serverless migration describe + +Describe a migration + +``` +ticloud serverless migration describe [flags] +``` + +### Examples + +``` + Describe a migration in interactive mode: + $ ticloud serverless migration describe + + Describe a migration in non-interactive mode: + $ ticloud serverless migration describe -c --migration-id +``` + +### Options + +``` + -c, --cluster-id string Cluster ID that owns the migration. + -h, --help help for describe + -m, --migration-id string ID of the migration to describe. +``` + +### Options inherited from parent commands + +``` + -D, --debug Enable debug mode + --no-color Disable color output + -P, --profile string Profile to use from your configuration file +``` + +### SEE ALSO + +* [ticloud serverless migration](ticloud_serverless_migration.md) - Manage TiDB Cloud Serverless migrations + diff --git a/docs/generate_doc/ticloud_serverless_migration_list.md b/docs/generate_doc/ticloud_serverless_migration_list.md new file mode 100644 index 00000000..06a6d873 --- /dev/null +++ b/docs/generate_doc/ticloud_serverless_migration_list.md @@ -0,0 +1,38 @@ +## ticloud serverless migration list + +List migrations + +``` +ticloud serverless migration list [flags] +``` + +### Examples + +``` + List migrations in interactive mode: + $ ticloud serverless migration list + + List migrations in non-interactive mode with JSON output: + $ ticloud serverless migration list -c -o json +``` + +### Options + +``` + -c, --cluster-id string The cluster ID of the migration tasks to list. + -h, --help help for list + -o, --output string Output format, one of ["human" "json"]. For the complete result, please use json format. (default "human") +``` + +### Options inherited from parent commands + +``` + -D, --debug Enable debug mode + --no-color Disable color output + -P, --profile string Profile to use from your configuration file +``` + +### SEE ALSO + +* [ticloud serverless migration](ticloud_serverless_migration.md) - Manage TiDB Cloud Serverless migrations + diff --git a/docs/generate_doc/ticloud_serverless_migration_pause.md b/docs/generate_doc/ticloud_serverless_migration_pause.md new file mode 100644 index 00000000..0b514e11 --- /dev/null +++ b/docs/generate_doc/ticloud_serverless_migration_pause.md @@ -0,0 +1,38 @@ +## ticloud serverless migration pause + +Pause a migration + +``` +ticloud serverless migration pause [flags] +``` + +### Examples + +``` + Pause a migration in interactive mode: + $ ticloud serverless migration pause + + Pause a migration in non-interactive mode: + $ ticloud serverless migration pause -c --migration-id +``` + +### Options + +``` + -c, --cluster-id string Cluster ID that owns the migration. + -h, --help help for pause + -m, --migration-id string ID of the migration to pause. +``` + +### Options inherited from parent commands + +``` + -D, --debug Enable debug mode + --no-color Disable color output + -P, --profile string Profile to use from your configuration file +``` + +### SEE ALSO + +* [ticloud serverless migration](ticloud_serverless_migration.md) - Manage TiDB Cloud Serverless migrations + diff --git a/docs/generate_doc/ticloud_serverless_migration_resume.md b/docs/generate_doc/ticloud_serverless_migration_resume.md new file mode 100644 index 00000000..2b78bdfe --- /dev/null +++ b/docs/generate_doc/ticloud_serverless_migration_resume.md @@ -0,0 +1,38 @@ +## ticloud serverless migration resume + +Resume a paused migration + +``` +ticloud serverless migration resume [flags] +``` + +### Examples + +``` + Resume a migration in interactive mode: + $ ticloud serverless migration resume + + Resume a migration in non-interactive mode: + $ ticloud serverless migration resume -c --migration-id +``` + +### Options + +``` + -c, --cluster-id string Cluster ID that owns the migration. + -h, --help help for resume + -m, --migration-id string ID of the migration to resume. +``` + +### Options inherited from parent commands + +``` + -D, --debug Enable debug mode + --no-color Disable color output + -P, --profile string Profile to use from your configuration file +``` + +### SEE ALSO + +* [ticloud serverless migration](ticloud_serverless_migration.md) - Manage TiDB Cloud Serverless migrations + diff --git a/docs/generate_doc/ticloud_serverless_migration_template.md b/docs/generate_doc/ticloud_serverless_migration_template.md new file mode 100644 index 00000000..8a3bd21d --- /dev/null +++ b/docs/generate_doc/ticloud_serverless_migration_template.md @@ -0,0 +1,37 @@ +## ticloud serverless migration template + +Show migration JSON templates + +``` +ticloud serverless migration template [flags] +``` + +### Examples + +``` + Show the ALL mode migration template: + $ ticloud serverless migration template --mode all + + Show the INCREMENTAL migration template: + $ ticloud serverless migration template --mode incremental +``` + +### Options + +``` + -h, --help help for template + --mode string Migration mode template to show, one of [all, incremental]. +``` + +### Options inherited from parent commands + +``` + -D, --debug Enable debug mode + --no-color Disable color output + -P, --profile string Profile to use from your configuration file +``` + +### SEE ALSO + +* [ticloud serverless migration](ticloud_serverless_migration.md) - Manage TiDB Cloud Serverless migrations + diff --git a/internal/cli/serverless/migration/create_test.go b/internal/cli/serverless/migration/create_test.go new file mode 100644 index 00000000..06860de6 --- /dev/null +++ b/internal/cli/serverless/migration/create_test.go @@ -0,0 +1,176 @@ +// Copyright 2025 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package migration + +import ( + "bytes" + "context" + "os" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + mockTool "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/iostream" + "github.com/tidbcloud/tidbcloud-cli/internal/mock" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" + pkgmigration "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/migration" +) + +type CreateMigrationSuite struct { + suite.Suite + h *internal.Helper + mockClient *mock.TiDBCloudClient +} + +func (suite *CreateMigrationSuite) SetupTest() { + if err := os.Setenv("NO_COLOR", "true"); err != nil { + suite.T().Error(err) + } + + var pageSize int64 = 10 + suite.mockClient = new(mock.TiDBCloudClient) + suite.h = &internal.Helper{ + Client: func() (cloud.TiDBCloudClient, error) { + return suite.mockClient, nil + }, + QueryPageSize: pageSize, + IOStreams: iostream.Test(), + } +} + +func (suite *CreateMigrationSuite) TestCreateMigration() { + assert := require.New(suite.T()) + ctx := context.Background() + + clusterID := "c123" + migrationID := "mig-1" + displayName := "my-migration" + + configPath := suite.writeTempConfig(validMigrationConfig()) + + suite.mockClient.On( + "CreateMigration", + ctx, + clusterID, + mockTool.MatchedBy(func(body *pkgmigration.MigrationServiceCreateMigrationBody) bool { + return body != nil && + body.DisplayName == displayName && + body.Mode == pkgmigration.TASKMODE_ALL && + len(body.Sources) == 1 && + body.Target.User == "migration_user" + }), + ).Return(&pkgmigration.Migration{MigrationId: aws.String(migrationID)}, nil) + + cmd := CreateCmd(suite.h) + cmd.SetContext(ctx) + suite.h.IOStreams.Out.(*bytes.Buffer).Reset() + suite.h.IOStreams.Err.(*bytes.Buffer).Reset() + cmd.SetArgs([]string{"--cluster-id", clusterID, "--display-name", displayName, "--config-file", configPath}) + + err := cmd.Execute() + assert.NoError(err) + assert.Equal("migration "+displayName+"("+migrationID+") created\n", suite.h.IOStreams.Out.(*bytes.Buffer).String()) + assert.Equal("", suite.h.IOStreams.Err.(*bytes.Buffer).String()) + suite.mockClient.AssertExpectations(suite.T()) +} + +func (suite *CreateMigrationSuite) TestCreateMigrationInvalidInputs() { + assert := require.New(suite.T()) + ctx := context.Background() + + validPath := suite.writeTempConfig(validMigrationConfig()) + blankPath := suite.writeTempConfig(" ") + invalidJSONPath := suite.writeTempConfig("{invalid") + invalidModePath := suite.writeTempConfig(`{ "mode": "invalid", "target": {"user":"u","password":"p"}, "sources": [{"sourceType":"MYSQL","connProfile":{"connType":"PUBLIC","host":"h","port":3306,"user":"u","password":"p"}}] }`) + + tests := []struct { + name string + args []string + errContains string + }{ + { + name: "empty display name", + args: []string{"--cluster-id", "c1", "--display-name", " ", "--config-file", validPath}, + errContains: "display name is required", + }, + { + name: "empty config path", + args: []string{"--cluster-id", "c1", "--display-name", "name", "--config-file", blankPath}, + errContains: "migration config is required", + }, + { + name: "invalid json", + args: []string{"--cluster-id", "c1", "--display-name", "name", "--config-file", invalidJSONPath}, + errContains: "invalid migration definition JSON", + }, + { + name: "invalid mode", + args: []string{"--cluster-id", "c1", "--display-name", "name", "--config-file", invalidModePath}, + errContains: "invalid mode", + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + cmd := CreateCmd(suite.h) + cmd.SetContext(ctx) + suite.h.IOStreams.Out.(*bytes.Buffer).Reset() + suite.h.IOStreams.Err.(*bytes.Buffer).Reset() + cmd.SetArgs(tt.args) + err := cmd.Execute() + + assert.Error(err) + assert.Contains(err.Error(), tt.errContains) + suite.mockClient.AssertNotCalled(suite.T(), "CreateMigration", mockTool.Anything) + }) + } +} + +func (suite *CreateMigrationSuite) writeTempConfig(content string) string { + f, err := os.CreateTemp("", "migration-config.json") + suite.Require().NoError(err) + suite.Require().NoError(os.WriteFile(f.Name(), []byte(content), 0o600)) + return f.Name() +} + +func validMigrationConfig() string { + return `{ + "mode": "ALL", + "target": { + "user": "migration_user", + "password": "Passw0rd!" + }, + "sources": [ + { + "sourceType": "MYSQL", + "connProfile": { + "connType": "PUBLIC", + "host": "10.0.0.8", + "port": 3306, + "user": "dm_sync_user", + "password": "Passw0rd!" + } + } + ] +}` +} + +func TestCreateMigrationSuite(t *testing.T) { + suite.Run(t, new(CreateMigrationSuite)) +} diff --git a/internal/cli/serverless/migration/delete_test.go b/internal/cli/serverless/migration/delete_test.go new file mode 100644 index 00000000..812e3384 --- /dev/null +++ b/internal/cli/serverless/migration/delete_test.go @@ -0,0 +1,116 @@ +// Copyright 2025 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package migration + +import ( + "bytes" + "context" + "fmt" + "os" + "testing" + + mockTool "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/iostream" + "github.com/tidbcloud/tidbcloud-cli/internal/mock" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" +) + +type DeleteMigrationSuite struct { + suite.Suite + h *internal.Helper + mockClient *mock.TiDBCloudClient +} + +func (suite *DeleteMigrationSuite) SetupTest() { + if err := os.Setenv("NO_COLOR", "true"); err != nil { + suite.T().Error(err) + } + + var pageSize int64 = 10 + suite.mockClient = new(mock.TiDBCloudClient) + suite.h = &internal.Helper{ + Client: func() (cloud.TiDBCloudClient, error) { + return suite.mockClient, nil + }, + QueryPageSize: pageSize, + IOStreams: iostream.Test(), + } +} + +func (suite *DeleteMigrationSuite) TestDeleteMigrations() { + assert := require.New(suite.T()) + ctx := context.Background() + + clusterID := "c123" + migrationID := "m456" + + suite.mockClient.On( + "DeleteMigration", + ctx, + clusterID, + migrationID, + ).Return(nil, nil) + + tests := []struct { + name string + args []string + err error + stdoutString string + stderrString string + }{ + { + name: "delete migration with force", + args: []string{"--cluster-id", clusterID, "--migration-id", migrationID, "--force"}, + stdoutString: fmt.Sprintf("migration %s deleted\n", migrationID), + }, + { + name: "delete migration without force in non-interactive terminal", + args: []string{"--cluster-id", clusterID, "--migration-id", migrationID}, + err: fmt.Errorf("The terminal doesn't support prompt, please run with --force to delete the migration"), + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + cmd := DeleteCmd(suite.h) + cmd.SetContext(ctx) + suite.h.IOStreams.Out.(*bytes.Buffer).Reset() + suite.h.IOStreams.Err.(*bytes.Buffer).Reset() + cmd.SetArgs(tt.args) + err := cmd.Execute() + if tt.err != nil { + assert.EqualError(err, tt.err.Error()) + } else { + assert.NoError(err) + } + + assert.Equal(tt.stdoutString, suite.h.IOStreams.Out.(*bytes.Buffer).String()) + assert.Equal(tt.stderrString, suite.h.IOStreams.Err.(*bytes.Buffer).String()) + if tt.err == nil { + suite.mockClient.AssertExpectations(suite.T()) + } else { + suite.mockClient.AssertNotCalled(suite.T(), "DeleteMigration", mockTool.Anything) + } + }) + } +} + +func TestDeleteMigrationSuite(t *testing.T) { + suite.Run(t, new(DeleteMigrationSuite)) +} diff --git a/internal/cli/serverless/migration/describe_test.go b/internal/cli/serverless/migration/describe_test.go new file mode 100644 index 00000000..0396c8ca --- /dev/null +++ b/internal/cli/serverless/migration/describe_test.go @@ -0,0 +1,135 @@ +// Copyright 2025 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package migration + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + mockTool "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/iostream" + "github.com/tidbcloud/tidbcloud-cli/internal/mock" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" + pkgmigration "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/migration" +) + +type DescribeMigrationSuite struct { + suite.Suite + h *internal.Helper + mockClient *mock.TiDBCloudClient +} + +func (suite *DescribeMigrationSuite) SetupTest() { + if err := os.Setenv("NO_COLOR", "true"); err != nil { + suite.T().Error(err) + } + + var pageSize int64 = 10 + suite.mockClient = new(mock.TiDBCloudClient) + suite.h = &internal.Helper{ + Client: func() (cloud.TiDBCloudClient, error) { + return suite.mockClient, nil + }, + QueryPageSize: pageSize, + IOStreams: iostream.Test(), + } +} + +func (suite *DescribeMigrationSuite) TestDescribeMigrations() { + assert := require.New(suite.T()) + ctx := context.Background() + + clusterID := "c123" + migrationID := "m456" + mode := pkgmigration.TASKMODE_ALL + state := pkgmigration.MIGRATIONSTATE_RUNNING + createdAt := time.Now() + + mig := &pkgmigration.Migration{ + MigrationId: aws.String(migrationID), + DisplayName: aws.String("test-migration"), + Mode: &mode, + State: &state, + CreateTime: &createdAt, + } + + suite.mockClient.On( + "GetMigration", + ctx, + clusterID, + migrationID, + ).Return(mig, nil) + + resultJSON, err := json.MarshalIndent(mig, "", " ") + assert.Nil(err) + expected := string(resultJSON) + "\n" + + tests := []struct { + name string + args []string + err error + stdoutString string + stderrString string + }{ + { + name: "describe migration in non-interactive mode", + args: []string{"--cluster-id", clusterID, "--migration-id", migrationID}, + stdoutString: expected, + }, + { + name: "describe migration without required flags in non-interactive terminal", + args: []string{}, + err: fmt.Errorf("The terminal doesn't support interactive mode, please use non-interactive mode"), + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + cmd := DescribeCmd(suite.h) + cmd.SetContext(ctx) + suite.h.IOStreams.Out.(*bytes.Buffer).Reset() + suite.h.IOStreams.Err.(*bytes.Buffer).Reset() + cmd.SetArgs(tt.args) + err := cmd.Execute() + if tt.err != nil { + assert.EqualError(err, tt.err.Error()) + } else { + assert.NoError(err) + } + + assert.Equal(tt.stdoutString, suite.h.IOStreams.Out.(*bytes.Buffer).String()) + assert.Equal(tt.stderrString, suite.h.IOStreams.Err.(*bytes.Buffer).String()) + if tt.err == nil { + suite.mockClient.AssertExpectations(suite.T()) + } else { + suite.mockClient.AssertNotCalled(suite.T(), "GetMigration", mockTool.Anything) + } + }) + } +} + +func TestDescribeMigrationSuite(t *testing.T) { + suite.Run(t, new(DescribeMigrationSuite)) +} diff --git a/internal/cli/serverless/migration/list_test.go b/internal/cli/serverless/migration/list_test.go new file mode 100644 index 00000000..d77a8928 --- /dev/null +++ b/internal/cli/serverless/migration/list_test.go @@ -0,0 +1,135 @@ +// Copyright 2025 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package migration + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + mockTool "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/iostream" + "github.com/tidbcloud/tidbcloud-cli/internal/mock" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" + pkgmigration "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/migration" +) + +type ListMigrationSuite struct { + suite.Suite + h *internal.Helper + mockClient *mock.TiDBCloudClient +} + +func (suite *ListMigrationSuite) SetupTest() { + if err := os.Setenv("NO_COLOR", "true"); err != nil { + suite.T().Error(err) + } + + var pageSize int64 = 10 + suite.mockClient = new(mock.TiDBCloudClient) + suite.h = &internal.Helper{ + Client: func() (cloud.TiDBCloudClient, error) { + return suite.mockClient, nil + }, + QueryPageSize: pageSize, + IOStreams: iostream.Test(), + } +} + +func (suite *ListMigrationSuite) TestListMigrationsArgs() { + assert := require.New(suite.T()) + ctx := context.Background() + clusterID := "c123" + mode := pkgmigration.TASKMODE_ALL + state := pkgmigration.MIGRATIONSTATE_RUNNING + createdAt := time.Now() + + mig := pkgmigration.Migration{ + MigrationId: aws.String("mig-1"), + DisplayName: aws.String("test-migration"), + Mode: &mode, + State: &state, + CreateTime: &createdAt, + } + resp := &pkgmigration.ListMigrationsResp{Migrations: []pkgmigration.Migration{mig}, TotalSize: aws.Int64(1)} + + var pageSize = int32(suite.h.QueryPageSize) + suite.mockClient.On( + "ListMigrations", + ctx, + clusterID, + mockTool.MatchedBy(func(ps *int32) bool { return ps != nil && *ps == pageSize }), + (*string)(nil), + (*string)(nil), + ).Return(resp, nil) + + resultJSON, err := json.MarshalIndent(resp, "", " ") + assert.Nil(err) + expected := string(resultJSON) + "\n" + + tests := []struct { + name string + args []string + err error + stdoutString string + stderrString string + }{ + { + name: "list migrations in non-interactive mode", + args: []string{"--cluster-id", clusterID}, + stdoutString: expected, + }, + { + name: "list migrations without cluster id in non-interactive terminal", + args: []string{}, + err: fmt.Errorf("The terminal doesn't support interactive mode, please use non-interactive mode"), + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + cmd := ListCmd(suite.h) + cmd.SetContext(ctx) + suite.h.IOStreams.Out.(*bytes.Buffer).Reset() + suite.h.IOStreams.Err.(*bytes.Buffer).Reset() + cmd.SetArgs(tt.args) + err := cmd.Execute() + if tt.err != nil { + assert.EqualError(err, tt.err.Error()) + } else { + assert.NoError(err) + } + + assert.Equal(tt.stdoutString, suite.h.IOStreams.Out.(*bytes.Buffer).String()) + assert.Equal(tt.stderrString, suite.h.IOStreams.Err.(*bytes.Buffer).String()) + if tt.err == nil { + suite.mockClient.AssertExpectations(suite.T()) + } + }) + } +} + +func TestListMigrationSuite(t *testing.T) { + suite.Run(t, new(ListMigrationSuite)) +} diff --git a/internal/cli/serverless/migration/pause_test.go b/internal/cli/serverless/migration/pause_test.go new file mode 100644 index 00000000..cf0873b3 --- /dev/null +++ b/internal/cli/serverless/migration/pause_test.go @@ -0,0 +1,116 @@ +// Copyright 2025 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package migration + +import ( + "bytes" + "context" + "fmt" + "os" + "testing" + + mockTool "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/iostream" + "github.com/tidbcloud/tidbcloud-cli/internal/mock" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" +) + +type PauseMigrationSuite struct { + suite.Suite + h *internal.Helper + mockClient *mock.TiDBCloudClient +} + +func (suite *PauseMigrationSuite) SetupTest() { + if err := os.Setenv("NO_COLOR", "true"); err != nil { + suite.T().Error(err) + } + + var pageSize int64 = 10 + suite.mockClient = new(mock.TiDBCloudClient) + suite.h = &internal.Helper{ + Client: func() (cloud.TiDBCloudClient, error) { + return suite.mockClient, nil + }, + QueryPageSize: pageSize, + IOStreams: iostream.Test(), + } +} + +func (suite *PauseMigrationSuite) TestPauseMigrations() { + assert := require.New(suite.T()) + ctx := context.Background() + + clusterID := "c123" + migrationID := "m456" + + suite.mockClient.On( + "PauseMigration", + ctx, + clusterID, + migrationID, + ).Return(nil) + + tests := []struct { + name string + args []string + err error + stdoutString string + stderrString string + }{ + { + name: "pause migration in non-interactive mode", + args: []string{"--cluster-id", clusterID, "--migration-id", migrationID}, + stdoutString: fmt.Sprintf("migration %s paused\n", migrationID), + }, + { + name: "pause migration without required flags in non-interactive terminal", + args: []string{}, + err: fmt.Errorf("The terminal doesn't support interactive mode, please use non-interactive mode"), + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + cmd := PauseCmd(suite.h) + cmd.SetContext(ctx) + suite.h.IOStreams.Out.(*bytes.Buffer).Reset() + suite.h.IOStreams.Err.(*bytes.Buffer).Reset() + cmd.SetArgs(tt.args) + err := cmd.Execute() + if tt.err != nil { + assert.EqualError(err, tt.err.Error()) + } else { + assert.NoError(err) + } + + assert.Equal(tt.stdoutString, suite.h.IOStreams.Out.(*bytes.Buffer).String()) + assert.Equal(tt.stderrString, suite.h.IOStreams.Err.(*bytes.Buffer).String()) + if tt.err == nil { + suite.mockClient.AssertExpectations(suite.T()) + } else { + suite.mockClient.AssertNotCalled(suite.T(), "PauseMigration", mockTool.Anything) + } + }) + } +} + +func TestPauseMigrationSuite(t *testing.T) { + suite.Run(t, new(PauseMigrationSuite)) +} diff --git a/internal/cli/serverless/migration/resume_test.go b/internal/cli/serverless/migration/resume_test.go new file mode 100644 index 00000000..7970cdf2 --- /dev/null +++ b/internal/cli/serverless/migration/resume_test.go @@ -0,0 +1,116 @@ +// Copyright 2025 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package migration + +import ( + "bytes" + "context" + "fmt" + "os" + "testing" + + mockTool "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/iostream" + "github.com/tidbcloud/tidbcloud-cli/internal/mock" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" +) + +type ResumeMigrationSuite struct { + suite.Suite + h *internal.Helper + mockClient *mock.TiDBCloudClient +} + +func (suite *ResumeMigrationSuite) SetupTest() { + if err := os.Setenv("NO_COLOR", "true"); err != nil { + suite.T().Error(err) + } + + var pageSize int64 = 10 + suite.mockClient = new(mock.TiDBCloudClient) + suite.h = &internal.Helper{ + Client: func() (cloud.TiDBCloudClient, error) { + return suite.mockClient, nil + }, + QueryPageSize: pageSize, + IOStreams: iostream.Test(), + } +} + +func (suite *ResumeMigrationSuite) TestResumeMigrations() { + assert := require.New(suite.T()) + ctx := context.Background() + + clusterID := "c123" + migrationID := "m456" + + suite.mockClient.On( + "ResumeMigration", + ctx, + clusterID, + migrationID, + ).Return(nil) + + tests := []struct { + name string + args []string + err error + stdoutString string + stderrString string + }{ + { + name: "resume migration in non-interactive mode", + args: []string{"--cluster-id", clusterID, "--migration-id", migrationID}, + stdoutString: fmt.Sprintf("migration %s resumed\n", migrationID), + }, + { + name: "resume migration without required flags in non-interactive terminal", + args: []string{}, + err: fmt.Errorf("The terminal doesn't support interactive mode, please use non-interactive mode"), + }, + } + + for _, tt := range tests { + suite.T().Run(tt.name, func(t *testing.T) { + cmd := ResumeCmd(suite.h) + cmd.SetContext(ctx) + suite.h.IOStreams.Out.(*bytes.Buffer).Reset() + suite.h.IOStreams.Err.(*bytes.Buffer).Reset() + cmd.SetArgs(tt.args) + err := cmd.Execute() + if tt.err != nil { + assert.EqualError(err, tt.err.Error()) + } else { + assert.NoError(err) + } + + assert.Equal(tt.stdoutString, suite.h.IOStreams.Out.(*bytes.Buffer).String()) + assert.Equal(tt.stderrString, suite.h.IOStreams.Err.(*bytes.Buffer).String()) + if tt.err == nil { + suite.mockClient.AssertExpectations(suite.T()) + } else { + suite.mockClient.AssertNotCalled(suite.T(), "ResumeMigration", mockTool.Anything) + } + }) + } +} + +func TestResumeMigrationSuite(t *testing.T) { + suite.Run(t, new(ResumeMigrationSuite)) +} diff --git a/internal/cli/serverless/migration/template_test.go b/internal/cli/serverless/migration/template_test.go new file mode 100644 index 00000000..10c005d6 --- /dev/null +++ b/internal/cli/serverless/migration/template_test.go @@ -0,0 +1,96 @@ +// Copyright 2025 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package migration + +import ( + "bytes" + "fmt" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tidbcloud/tidbcloud-cli/internal" + "github.com/tidbcloud/tidbcloud-cli/internal/flag" + "github.com/tidbcloud/tidbcloud-cli/internal/iostream" + "github.com/tidbcloud/tidbcloud-cli/internal/service/cloud" + pkgmigration "github.com/tidbcloud/tidbcloud-cli/pkg/tidbcloud/v1beta1/serverless/migration" +) + +func TestTemplateCmd(t *testing.T) { + assert := require.New(t) + if err := os.Setenv("NO_COLOR", "true"); err != nil { + t.Error(err) + } + + h := testHelper() + + tests := []struct { + name string + args []string + err error + stdoutString string + stderrString string + }{ + { + name: "render ALL template", + args: []string{"--mode", "all"}, + stdoutString: fmt.Sprintf("%s\n%s\n", definitionTemplates[pkgmigration.TASKMODE_ALL].heading, migrationDefinitionAllTemplate), + }, + { + name: "render INCREMENTAL template", + args: []string{"--mode", "incremental"}, + stdoutString: fmt.Sprintf("%s\n%s\n", definitionTemplates[pkgmigration.TASKMODE_INCREMENTAL].heading, migrationDefinitionIncrementalTemplate), + }, + { + name: "invalid mode", + args: []string{"--mode", "invalid"}, + err: fmt.Errorf("unknown mode %q, allowed values: %s", "invalid", strings.Join(allowedTemplateModeStrings(), ", ")), + }, + { + name: "missing mode flag", + args: []string{}, + err: fmt.Errorf("required flag(s) \"%s\" not set", flag.MigrationMode), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := TemplateCmd(h) + h.IOStreams.Out.(*bytes.Buffer).Reset() + h.IOStreams.Err.(*bytes.Buffer).Reset() + cmd.SetArgs(tt.args) + err := cmd.Execute() + if tt.err != nil { + assert.EqualError(err, tt.err.Error()) + } else { + assert.NoError(err) + } + + assert.Equal(tt.stdoutString, h.IOStreams.Out.(*bytes.Buffer).String()) + assert.Equal(tt.stderrString, h.IOStreams.Err.(*bytes.Buffer).String()) + }) + } +} + +// testHelper creates a helper with no client dependency for template command tests. +func testHelper() *internal.Helper { + return &internal.Helper{ + Client: func() (cloud.TiDBCloudClient, error) { return nil, nil }, + QueryPageSize: 1, + IOStreams: iostream.Test(), + } +}