diff --git a/cmd/dashboard.go b/cmd/dashboard.go new file mode 100644 index 00000000..f84abf73 --- /dev/null +++ b/cmd/dashboard.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/stackvista/stackstate-cli/cmd/dashboard" + "github.com/stackvista/stackstate-cli/internal/di" +) + +func DashboardCommand(cli *di.Deps) *cobra.Command { + cmd := &cobra.Command{ + Use: "dashboard", + Short: "Manage dashboards", + Long: "Manage, test and develop dashboards.", + } + cmd.AddCommand(dashboard.DashboardListCommand(cli)) + cmd.AddCommand(dashboard.DashboardDescribeCommand(cli)) + cmd.AddCommand(dashboard.DashboardCloneCommand(cli)) + cmd.AddCommand(dashboard.DashboardDeleteCommand(cli)) + cmd.AddCommand(dashboard.DashboardApplyCommand(cli)) + cmd.AddCommand(dashboard.DashboardEditCommand(cli)) + + return cmd +} diff --git a/cmd/dashboard/common.go b/cmd/dashboard/common.go new file mode 100644 index 00000000..6a300f44 --- /dev/null +++ b/cmd/dashboard/common.go @@ -0,0 +1,18 @@ +package dashboard + +import ( + "fmt" +) + +// ResolveDashboardIdOrUrn resolves ID or identifier to a string that can be used with the API +// Returns the resolved identifier string or an error if neither is provided +func ResolveDashboardIdOrUrn(id int64, identifier string) (string, error) { + switch { + case id != 0: + return fmt.Sprintf("%d", id), nil + case identifier != "": + return identifier, nil + default: + return "", fmt.Errorf("either --id or --identifier must be provided") + } +} diff --git a/cmd/dashboard/common_test.go b/cmd/dashboard/common_test.go new file mode 100644 index 00000000..cb27cf7e --- /dev/null +++ b/cmd/dashboard/common_test.go @@ -0,0 +1,109 @@ +package dashboard + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolveDashboardIdOrUrn(t *testing.T) { + tests := []struct { + name string + id int64 + identifier string + expectedResult string + expectedError string + }{ + { + name: "Should resolve valid ID", + id: 123, + identifier: "", + expectedResult: "123", + expectedError: "", + }, + { + name: "Should resolve valid identifier", + id: 0, + identifier: "urn:custom:dashboard:test", + expectedResult: "urn:custom:dashboard:test", + expectedError: "", + }, + { + name: "Should prioritize ID when both are provided", + id: 456, + identifier: "urn:custom:dashboard:test", + expectedResult: "456", + expectedError: "", + }, + { + name: "Should return error when neither is provided", + id: 0, + identifier: "", + expectedResult: "", + expectedError: "either --id or --identifier must be provided", + }, + { + name: "Should resolve large ID", + id: 9223372036854775807, // max int64 + identifier: "", + expectedResult: "9223372036854775807", + expectedError: "", + }, + { + name: "Should resolve complex URN identifier", + id: 0, + identifier: "urn:stackpack:kubernetes:dashboard:cluster-overview", + expectedResult: "urn:stackpack:kubernetes:dashboard:cluster-overview", + expectedError: "", + }, + { + name: "Should resolve identifier with special characters", + id: 0, + identifier: "urn:custom:dashboard:test-name_with.special-chars", + expectedResult: "urn:custom:dashboard:test-name_with.special-chars", + expectedError: "", + }, + { + name: "Should handle empty string identifier as not provided", + id: 0, + identifier: "", + expectedResult: "", + expectedError: "either --id or --identifier must be provided", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ResolveDashboardIdOrUrn(tt.id, tt.identifier) + + assert.Equal(t, tt.expectedResult, result) + + if tt.expectedError != "" { + assert.NotNil(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + assert.Nil(t, err) + } + }) + } +} + +func TestResolveDashboardIdOrUrnPriority(t *testing.T) { + // Test that ID takes priority over identifier when both are provided + result, err := ResolveDashboardIdOrUrn(999, "urn:custom:dashboard:ignored") + + assert.Nil(t, err) + assert.Equal(t, "999", result) +} + +func TestResolveDashboardIdOrUrnEdgeCases(t *testing.T) { + // Test negative ID (should still work as it's non-zero) + result, err := ResolveDashboardIdOrUrn(-1, "") + assert.Nil(t, err) + assert.Equal(t, "-1", result) + + // Test with whitespace-only identifier (treated as empty) + result, err = ResolveDashboardIdOrUrn(0, " ") + assert.Nil(t, err) + assert.Equal(t, " ", result) // The function doesn't trim whitespace +} diff --git a/cmd/dashboard/dashboard_apply.go b/cmd/dashboard/dashboard_apply.go new file mode 100644 index 00000000..84b6b18d --- /dev/null +++ b/cmd/dashboard/dashboard_apply.go @@ -0,0 +1,142 @@ +package dashboard + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/stackvista/stackstate-cli/generated/stackstate_api" + "github.com/stackvista/stackstate-cli/internal/common" + "github.com/stackvista/stackstate-cli/internal/di" +) + +// ApplyArgs contains arguments for dashboard apply command +type ApplyArgs struct { + File string +} + +func DashboardApplyCommand(cli *di.Deps) *cobra.Command { + args := &ApplyArgs{} + cmd := &cobra.Command{ + Use: "apply", + Short: "Create or edit a dashboard from JSON", + Long: "Create or edit a dashboard from JSON file.", + RunE: cli.CmdRunEWithApi(RunDashboardApplyCommand(args)), + } + + common.AddRequiredFileFlagVar(cmd, &args.File, "Path to a .json file with the dashboard definition") + + return cmd +} + +func RunDashboardApplyCommand(args *ApplyArgs) di.CmdWithApiFn { + return func(cmd *cobra.Command, cli *di.Deps, api *stackstate_api.APIClient, serverInfo *stackstate_api.ServerInfo) common.CLIError { + fileBytes, err := os.ReadFile(args.File) + if err != nil { + return common.NewReadFileError(err, args.File) + } + + // Determine file type by extension + ext := strings.ToLower(filepath.Ext(args.File)) + if ext != ".json" { + return common.NewCLIArgParseError(fmt.Errorf("unsupported file type: %s. Only .json files are supported", ext)) + } + + return applyJSONDashboard(cli, api, fileBytes) + } +} + +// applyJSONDashboard processes JSON dashboard file and determines create vs update operation +func applyJSONDashboard(cli *di.Deps, api *stackstate_api.APIClient, fileBytes []byte) common.CLIError { + // Parse the JSON to determine if it's a create or update operation + var dashboardData map[string]interface{} + if err := json.Unmarshal(fileBytes, &dashboardData); err != nil { + return common.NewCLIArgParseError(fmt.Errorf("failed to parse JSON: %v", err)) + } + + // Check if it has an ID field (indicates update operation) + if idField, hasId := dashboardData["id"]; hasId { + // Update existing dashboard + dashboardId := fmt.Sprintf("%.0f", idField.(float64)) + return updateDashboard(cli, api, dashboardId, dashboardData) + } else { + // Create new dashboard + return createDashboard(cli, api, fileBytes) + } +} + +// createDashboard creates a new dashboard from JSON schema +func createDashboard(cli *di.Deps, api *stackstate_api.APIClient, fileBytes []byte) common.CLIError { + var writeSchema stackstate_api.DashboardWriteSchema + if err := json.Unmarshal(fileBytes, &writeSchema); err != nil { + return common.NewCLIArgParseError(fmt.Errorf("failed to parse JSON as DashboardWriteSchema: %v", err)) + } + + // Validate required fields + if writeSchema.Name == "" { + return common.NewCLIArgParseError(fmt.Errorf("dashboard name is required")) + } + + // Create new dashboard + dashboard, resp, err := api.DashboardsApi.CreateDashboard(cli.Context).DashboardWriteSchema(writeSchema).Execute() + if err != nil { + return common.NewResponseError(err, resp) + } + + if cli.IsJson() { + cli.Printer.PrintJson(map[string]interface{}{ + "dashboard": dashboard, + }) + } else { + cli.Printer.Success(fmt.Sprintf("Dashboard created successfully! ID: %d, Name: %s", dashboard.GetId(), dashboard.GetName())) + } + + return nil +} + +// updateDashboard patches an existing dashboard with new data +func updateDashboard(cli *di.Deps, api *stackstate_api.APIClient, dashboardId string, dashboardData map[string]interface{}) common.CLIError { + // Create patch schema from the JSON data + patchSchema := stackstate_api.NewDashboardPatchSchema() + + if name, ok := dashboardData["name"].(string); ok && name != "" { + patchSchema.SetName(name) + } + if description, ok := dashboardData["description"].(string); ok { + patchSchema.SetDescription(description) + } + if scopeStr, ok := dashboardData["scope"].(string); ok { + if scope, err := stackstate_api.NewDashboardScopeFromValue(scopeStr); err == nil { + patchSchema.SetScope(*scope) + } + } + if dashboardContent, ok := dashboardData["dashboard"]; ok { + // Convert dashboard content to PersesDashboard + dashboardBytes, err := json.Marshal(dashboardContent) + if err == nil { + var persesDashboard stackstate_api.PersesDashboard + if err := json.Unmarshal(dashboardBytes, &persesDashboard); err == nil { + patchSchema.SetDashboard(persesDashboard) + } + } + } + + // Update existing dashboard + dashboard, resp, err := api.DashboardsApi.PatchDashboard(cli.Context, dashboardId).DashboardPatchSchema(*patchSchema).Execute() + if err != nil { + return common.NewResponseError(err, resp) + } + + if cli.IsJson() { + cli.Printer.PrintJson(map[string]interface{}{ + "dashboard": dashboard, + }) + } else { + cli.Printer.Success(fmt.Sprintf("Dashboard updated successfully! ID: %d, Name: %s", dashboard.GetId(), dashboard.GetName())) + } + + return nil +} diff --git a/cmd/dashboard/dashboard_apply_test.go b/cmd/dashboard/dashboard_apply_test.go new file mode 100644 index 00000000..7f558a06 --- /dev/null +++ b/cmd/dashboard/dashboard_apply_test.go @@ -0,0 +1,241 @@ +package dashboard + +import ( + "os" + "testing" + + "github.com/spf13/cobra" + sts "github.com/stackvista/stackstate-cli/generated/stackstate_api" + "github.com/stackvista/stackstate-cli/internal/di" + "github.com/stretchr/testify/assert" +) + +func setDashboardApplyCmd(t *testing.T) (*di.MockDeps, *cobra.Command) { + cli := di.NewMockDeps(t) + cmd := DashboardApplyCommand(&cli.Deps) + return &cli, cmd +} + +func createTestApplyResult() sts.DashboardReadFullSchema { + return sts.DashboardReadFullSchema{ + Id: 1234, + Name: "applied-dashboard", + Identifier: "urn:custom:dashboard:applied-dashboard", + Description: "Dashboard created via apply", + Scope: sts.DASHBOARDSCOPE_PUBLIC_DASHBOARD, + Dashboard: sts.PersesDashboard{ + Spec: &sts.PersesDashboardSpec{ + Layouts: []sts.PersesLayout{ + { + Kind: "Grid", + Spec: sts.PersesLayoutSpec{ + Items: []sts.PersesGridItem{}, + }, + }, + }, + Panels: &map[string]sts.PersesPanel{}, + }, + }, + } +} + +func TestShouldApplyDashboardCreate(t *testing.T) { + // Create a temporary file with dashboard JSON + file, err := os.CreateTemp(os.TempDir(), "test_dashboard_*.json") + if err != nil { + panic(err) + } + defer os.Remove(file.Name()) + + dashboardJSON := `{ + "name": "applied-dashboard", + "description": "Dashboard created via apply", + "scope": "publicDashboard", + "dashboard": { + "spec": { + "layouts": [ + { + "kind": "Grid", + "spec": { + "items": [] + } + } + ], + "panels": {} + } + } + }` + + _, err = file.WriteString(dashboardJSON) + assert.Nil(t, err) + file.Close() + + cli, cmd := setDashboardApplyCmd(t) + expectedResult := createTestApplyResult() + cli.MockClient.ApiMocks.DashboardsApi.CreateDashboardResponse.Result = expectedResult + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--file", file.Name()) + + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.CreateDashboardCalls, 1) + createCall := (*cli.MockClient.ApiMocks.DashboardsApi.CreateDashboardCalls)[0] + assert.Equal(t, "applied-dashboard", createCall.PdashboardWriteSchema.Name) + assert.Equal(t, "Dashboard created via apply", createCall.PdashboardWriteSchema.Description) + assert.Equal(t, sts.DASHBOARDSCOPE_PUBLIC_DASHBOARD, createCall.PdashboardWriteSchema.Scope) + assert.Equal(t, "Dashboard created successfully! ID: 1234, Name: applied-dashboard", (*cli.MockPrinter.SuccessCalls)[0]) +} + +func TestShouldApplyDashboardUpdate(t *testing.T) { + // Create a temporary file with dashboard update JSON (includes ID) + file, err := os.CreateTemp(os.TempDir(), "test_dashboard_*.json") + if err != nil { + panic(err) + } + defer os.Remove(file.Name()) + + updateJSON := `{ + "id": 1234, + "name": "updated-dashboard", + "description": "Updated dashboard description", + "scope": "privateDashboard" + }` + + _, err = file.WriteString(updateJSON) + assert.Nil(t, err) + file.Close() + + cli, cmd := setDashboardApplyCmd(t) + expectedResult := createTestApplyResult() + expectedResult.Name = "updated-dashboard" + expectedResult.Description = "Updated dashboard description" + expectedResult.Scope = sts.DASHBOARDSCOPE_PRIVATE_DASHBOARD + cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardResponse.Result = expectedResult + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--file", file.Name()) + + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardCalls, 1) + patchCall := (*cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardCalls)[0] + assert.Equal(t, "1234", patchCall.PdashboardIdOrUrn) + assert.NotNil(t, patchCall.PdashboardPatchSchema.Name) + assert.Equal(t, "updated-dashboard", *patchCall.PdashboardPatchSchema.Name) + assert.NotNil(t, patchCall.PdashboardPatchSchema.Description) + assert.Equal(t, "Updated dashboard description", *patchCall.PdashboardPatchSchema.Description) + assert.NotNil(t, patchCall.PdashboardPatchSchema.Scope) + assert.Equal(t, sts.DASHBOARDSCOPE_PRIVATE_DASHBOARD, *patchCall.PdashboardPatchSchema.Scope) + assert.Equal(t, "Dashboard updated successfully! ID: 1234, Name: updated-dashboard", (*cli.MockPrinter.SuccessCalls)[0]) +} + +func TestShouldApplyDashboardWithJson(t *testing.T) { + file, err := os.CreateTemp(os.TempDir(), "test_dashboard_*.json") + if err != nil { + panic(err) + } + defer os.Remove(file.Name()) + + dashboardJSON := `{ + "name": "json-output-dashboard", + "description": "Dashboard for JSON output test", + "scope": "publicDashboard", + "dashboard": { + "spec": { + "layouts": [], + "panels": {} + } + } + }` + + _, err = file.WriteString(dashboardJSON) + assert.Nil(t, err) + file.Close() + + cli, cmd := setDashboardApplyCmd(t) + expectedResult := createTestApplyResult() + cli.MockClient.ApiMocks.DashboardsApi.CreateDashboardResponse.Result = expectedResult + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--file", file.Name(), "--output", "json") + + expectedJsonCall := []map[string]interface{}{ + { + "dashboard": &expectedResult, + }, + } + assert.Equal(t, expectedJsonCall, *cli.MockPrinter.PrintJsonCalls) + assert.False(t, cli.MockPrinter.HasNonJsonCalls) +} + +func TestApplyDashboardInvalidFileType(t *testing.T) { + file, err := os.CreateTemp(os.TempDir(), "test_dashboard_*.txt") + if err != nil { + panic(err) + } + defer os.Remove(file.Name()) + + cli, cmd := setDashboardApplyCmd(t) + + _, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--file", file.Name()) + + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "unsupported file type: .txt. Only .json files are supported") +} + +func TestApplyDashboardMissingFile(t *testing.T) { + cli, cmd := setDashboardApplyCmd(t) + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--file", "/nonexistent/file.json") + + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "cannot read file") +} + +func TestApplyDashboardInvalidJSON(t *testing.T) { + file, err := os.CreateTemp(os.TempDir(), "test_dashboard_*.json") + if err != nil { + panic(err) + } + defer os.Remove(file.Name()) + + invalidJSON := `{"name": "test", "invalid": json}` + + _, err = file.WriteString(invalidJSON) + assert.Nil(t, err) + file.Close() + + cli, cmd := setDashboardApplyCmd(t) + + _, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--file", file.Name()) + + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "failed to parse JSON") +} + +func TestApplyDashboardMissingName(t *testing.T) { + file, err := os.CreateTemp(os.TempDir(), "test_dashboard_*.json") + if err != nil { + panic(err) + } + defer os.Remove(file.Name()) + + invalidDashboard := `{ + "description": "Dashboard without name", + "scope": "publicDashboard" + }` + + _, err = file.WriteString(invalidDashboard) + assert.Nil(t, err) + file.Close() + + cli, cmd := setDashboardApplyCmd(t) + + _, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--file", file.Name()) + + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "dashboard name is required") +} + +func TestApplyDashboardMissingFileFlag(t *testing.T) { + cli, cmd := setDashboardApplyCmd(t) + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "") + + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "required flag(s) \"file\" not set") +} diff --git a/cmd/dashboard/dashboard_clone.go b/cmd/dashboard/dashboard_clone.go new file mode 100644 index 00000000..37a167cb --- /dev/null +++ b/cmd/dashboard/dashboard_clone.go @@ -0,0 +1,91 @@ +package dashboard + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/stackvista/stackstate-cli/generated/stackstate_api" + stscobra "github.com/stackvista/stackstate-cli/internal/cobra" + "github.com/stackvista/stackstate-cli/internal/common" + "github.com/stackvista/stackstate-cli/internal/di" +) + +// CloneArgs contains arguments for dashboard clone command +type CloneArgs struct { + ID int64 + Identifier string + NewName string + Description string + Scope string +} + +func DashboardCloneCommand(cli *di.Deps) *cobra.Command { + args := &CloneArgs{} + cmd := &cobra.Command{ + Use: "clone", + Short: "Clone a dashboard", + Long: "Clone a dashboard.", + RunE: cli.CmdRunEWithApi(RunDashboardCloneCommand(args)), + } + + common.AddIDFlagVar(cmd, &args.ID, "ID of the dashboard") + common.AddIdentifierFlagVar(cmd, &args.Identifier, "Identifier (URN) of the dashboard") + common.AddRequiredNameFlagVar(cmd, &args.NewName, "Name for the new dashboard") + cmd.Flags().StringVar(&args.Description, "description", "", "Description for the new dashboard") + cmd.Flags().StringVar(&args.Scope, "scope", "", "Scope for the new dashboard (publicDashboard or privateDashboard)") + stscobra.MarkMutexFlags(cmd, []string{common.IDFlag, common.IdentifierFlag}, "identifier", true) + + return cmd +} + +func RunDashboardCloneCommand(args *CloneArgs) di.CmdWithApiFn { + return func( + cmd *cobra.Command, + cli *di.Deps, + api *stackstate_api.APIClient, + serverInfo *stackstate_api.ServerInfo, + ) common.CLIError { + dashboardIdOrUrn, err := ResolveDashboardIdOrUrn(args.ID, args.Identifier) + if err != nil { + return common.NewCLIArgParseError(err) + } + + // Create the clone schema with required name + cloneSchema := stackstate_api.NewDashboardCloneSchema(args.NewName) + + // Add optional fields + if args.Description != "" { + cloneSchema.SetDescription(args.Description) + } + + // Validate scope if provided - must be public or private + if args.Scope != "" { + switch args.Scope { + case "publicDashboard": + scope := stackstate_api.DASHBOARDSCOPE_PUBLIC_DASHBOARD + cloneSchema.SetScope(scope) + case "privateDashboard": + scope := stackstate_api.DASHBOARDSCOPE_PRIVATE_DASHBOARD + cloneSchema.SetScope(scope) + default: + return common.NewCLIArgParseError(fmt.Errorf("invalid scope: %s. Must be 'publicDashboard' or 'privateDashboard'", args.Scope)) + } + } + + // Execute the clone request + clonedDashboard, resp, err := api.DashboardsApi.CloneDashboard(cli.Context, dashboardIdOrUrn).DashboardCloneSchema(*cloneSchema).Execute() + if err != nil { + return common.NewResponseError(err, resp) + } + + if cli.IsJson() { + cli.Printer.PrintJson(map[string]interface{}{ + "dashboard": clonedDashboard, + }) + } else { + cli.Printer.Success(fmt.Sprintf("Dashboard cloned successfully! New dashboard ID: %d, Name: %s", clonedDashboard.GetId(), clonedDashboard.GetName())) + } + + return nil + } +} diff --git a/cmd/dashboard/dashboard_clone_test.go b/cmd/dashboard/dashboard_clone_test.go new file mode 100644 index 00000000..c74fb559 --- /dev/null +++ b/cmd/dashboard/dashboard_clone_test.go @@ -0,0 +1,138 @@ +package dashboard + +import ( + "testing" + + "github.com/spf13/cobra" + sts "github.com/stackvista/stackstate-cli/generated/stackstate_api" + "github.com/stackvista/stackstate-cli/internal/di" + "github.com/stretchr/testify/assert" +) + +var ( + clonedDashboardId = int64(123) + clonedDashboardName = "cloned-dashboard" + clonedDashboardIdentifier = "urn:custom:dashboard:cloned-dashboard" + + cloneResult = sts.DashboardReadFullSchema{ + Id: clonedDashboardId, + Name: clonedDashboardName, + Identifier: clonedDashboardIdentifier, + Description: "Cloned dashboard description", + Scope: sts.DASHBOARDSCOPE_PUBLIC_DASHBOARD, + Dashboard: sts.PersesDashboard{ + Spec: &sts.PersesDashboardSpec{ + Layouts: []sts.PersesLayout{}, + Panels: &map[string]sts.PersesPanel{}, + }, + }, + } +) + +func setDashboardCloneCmd(t *testing.T) (*di.MockDeps, *cobra.Command) { + cli := di.NewMockDeps(t) + cmd := DashboardCloneCommand(&cli.Deps) + return &cli, cmd +} + +func TestShouldCloneDashboard(t *testing.T) { + cli, cmd := setDashboardCloneCmd(t) + cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardResponse.Result = cloneResult + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--id", "1", "--name", "cloned-dashboard") + + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardCalls, 1) + cloneCall := (*cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardCalls)[0] + assert.Equal(t, "1", cloneCall.PdashboardIdOrUrn) + assert.Equal(t, "cloned-dashboard", cloneCall.PdashboardCloneSchema.Name) + assert.Equal(t, "Dashboard cloned successfully! New dashboard ID: 123, Name: cloned-dashboard", (*cli.MockPrinter.SuccessCalls)[0]) +} + +func TestShouldCloneDashboardWithIdentifier(t *testing.T) { + cli, cmd := setDashboardCloneCmd(t) + cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardResponse.Result = cloneResult + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--identifier", "urn:custom:dashboard:original", "--name", "cloned-dashboard") + + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardCalls, 1) + cloneCall := (*cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardCalls)[0] + assert.Equal(t, "urn:custom:dashboard:original", cloneCall.PdashboardIdOrUrn) +} + +func TestShouldCloneDashboardWithJson(t *testing.T) { + cli, cmd := setDashboardCloneCmd(t) + cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardResponse.Result = cloneResult + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--id", "1", "--name", "cloned-dashboard", "--output", "json") + + expectedJsonCall := []map[string]interface{}{ + { + "dashboard": &cloneResult, + }, + } + assert.Equal(t, expectedJsonCall, *cli.MockPrinter.PrintJsonCalls) + assert.False(t, cli.MockPrinter.HasNonJsonCalls) +} + +func TestShouldCloneDashboardWithDescription(t *testing.T) { + cli, cmd := setDashboardCloneCmd(t) + cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardResponse.Result = cloneResult + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--id", "1", "--name", "cloned-dashboard", "--description", "Custom description") + + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardCalls, 1) + cloneCall := (*cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardCalls)[0] + assert.NotNil(t, cloneCall.PdashboardCloneSchema.Description) + assert.Equal(t, "Custom description", *cloneCall.PdashboardCloneSchema.Description) +} + +func TestShouldCloneDashboardWithScope(t *testing.T) { + cli, cmd := setDashboardCloneCmd(t) + cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardResponse.Result = cloneResult + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--id", "1", "--name", "cloned-dashboard", "--scope", "privateDashboard") + + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardCalls, 1) + cloneCall := (*cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardCalls)[0] + assert.NotNil(t, cloneCall.PdashboardCloneSchema.Scope) + assert.Equal(t, sts.DASHBOARDSCOPE_PRIVATE_DASHBOARD, *cloneCall.PdashboardCloneSchema.Scope) +} + +func TestShouldCloneDashboardWithPublicScope(t *testing.T) { + cli, cmd := setDashboardCloneCmd(t) + cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardResponse.Result = cloneResult + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--id", "1", "--name", "cloned-dashboard", "--scope", "publicDashboard") + + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardCalls, 1) + cloneCall := (*cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardCalls)[0] + assert.NotNil(t, cloneCall.PdashboardCloneSchema.Scope) + assert.Equal(t, sts.DASHBOARDSCOPE_PUBLIC_DASHBOARD, *cloneCall.PdashboardCloneSchema.Scope) +} + +func TestShouldRejectInvalidScope(t *testing.T) { + cli, cmd := setDashboardCloneCmd(t) + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--id", "1", "--name", "cloned-dashboard", "--scope", "invalid") + + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "invalid scope: invalid. Must be 'publicDashboard' or 'privateDashboard'") +} + +func TestCloneDashboardMissingName(t *testing.T) { + cli, cmd := setDashboardCloneCmd(t) + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--id", "1") + + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "required flag(s) \"name\" not set") +} + +func TestCloneDashboardMissingIdAndIdentifier(t *testing.T) { + cli, cmd := setDashboardCloneCmd(t) + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--name", "cloned-dashboard") + + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "one of the required flags {id | identifier} not set") +} diff --git a/cmd/dashboard/dashboard_delete.go b/cmd/dashboard/dashboard_delete.go new file mode 100644 index 00000000..72bd71ab --- /dev/null +++ b/cmd/dashboard/dashboard_delete.go @@ -0,0 +1,62 @@ +package dashboard + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/stackvista/stackstate-cli/generated/stackstate_api" + stscobra "github.com/stackvista/stackstate-cli/internal/cobra" + "github.com/stackvista/stackstate-cli/internal/common" + "github.com/stackvista/stackstate-cli/internal/di" +) + +// DeleteArgs contains arguments for dashboard delete command +type DeleteArgs struct { + ID int64 + Identifier string +} + +func DashboardDeleteCommand(cli *di.Deps) *cobra.Command { + args := &DeleteArgs{} + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a dashboard", + Long: "Delete a dashboard by its ID or identifier. Only user-owned dashboards can be deleted.", + RunE: cli.CmdRunEWithApi(RunDashboardDeleteCommand(args)), + } + + common.AddIDFlagVar(cmd, &args.ID, "ID of the dashboard") + common.AddIdentifierFlagVar(cmd, &args.Identifier, "Identifier (URN) of the dashboard") + stscobra.MarkMutexFlags(cmd, []string{common.IDFlag, common.IdentifierFlag}, "identifier", true) + + return cmd +} + +func RunDashboardDeleteCommand(args *DeleteArgs) di.CmdWithApiFn { + return func( + cmd *cobra.Command, + cli *di.Deps, + api *stackstate_api.APIClient, + serverInfo *stackstate_api.ServerInfo, + ) common.CLIError { + dashboardIdOrUrn, err := ResolveDashboardIdOrUrn(args.ID, args.Identifier) + if err != nil { + return common.NewCLIArgParseError(err) + } + + // Execute the delete request + resp, err := api.DashboardsApi.DeleteDashboard(cli.Context, dashboardIdOrUrn).Execute() + if err != nil { + return common.NewResponseError(err, resp) + } + + if cli.IsJson() { + cli.Printer.PrintJson(map[string]interface{}{ + "deleted-dashboard-identifier": dashboardIdOrUrn, + }) + } else { + cli.Printer.Success(fmt.Sprintf("Dashboard deleted: %s", dashboardIdOrUrn)) + } + return nil + } +} diff --git a/cmd/dashboard/dashboard_delete_test.go b/cmd/dashboard/dashboard_delete_test.go new file mode 100644 index 00000000..90789ea6 --- /dev/null +++ b/cmd/dashboard/dashboard_delete_test.go @@ -0,0 +1,76 @@ +package dashboard + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stackvista/stackstate-cli/internal/di" + "github.com/stretchr/testify/assert" +) + +func setDashboardDeleteCmd(t *testing.T) (*di.MockDeps, *cobra.Command) { + cli := di.NewMockDeps(t) + cmd := DashboardDeleteCommand(&cli.Deps) + return &cli, cmd +} + +func TestShouldDeleteDashboard(t *testing.T) { + cli, cmd := setDashboardDeleteCmd(t) + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--id", "123") + + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.DeleteDashboardCalls, 1) + deleteCall := (*cli.MockClient.ApiMocks.DashboardsApi.DeleteDashboardCalls)[0] + assert.Equal(t, "123", deleteCall.PdashboardIdOrUrn) + assert.Equal(t, "Dashboard deleted: 123", (*cli.MockPrinter.SuccessCalls)[0]) +} + +func TestShouldDeleteDashboardWithIdentifier(t *testing.T) { + cli, cmd := setDashboardDeleteCmd(t) + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--identifier", "urn:custom:dashboard:test") + + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.DeleteDashboardCalls, 1) + deleteCall := (*cli.MockClient.ApiMocks.DashboardsApi.DeleteDashboardCalls)[0] + assert.Equal(t, "urn:custom:dashboard:test", deleteCall.PdashboardIdOrUrn) + assert.Equal(t, "Dashboard deleted: urn:custom:dashboard:test", (*cli.MockPrinter.SuccessCalls)[0]) +} + +func TestShouldDeleteDashboardWithJson(t *testing.T) { + cli, cmd := setDashboardDeleteCmd(t) + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--id", "123", "--output", "json") + + expectedJsonCall := []map[string]interface{}{ + { + "deleted-dashboard-identifier": "123", + }, + } + assert.Equal(t, expectedJsonCall, *cli.MockPrinter.PrintJsonCalls) + assert.False(t, cli.MockPrinter.HasNonJsonCalls) +} + +func TestDeleteDashboardMissingIdAndIdentifier(t *testing.T) { + cli, cmd := setDashboardDeleteCmd(t) + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "") + + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "one of the required flags {id | identifier} not set") +} + +func TestDeleteDashboardCallsApiWithCorrectParams(t *testing.T) { + cli, cmd := setDashboardDeleteCmd(t) + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--id", "456") + + // Verify the API was called with the correct parameters + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.DeleteDashboardCalls, 1) + deleteCall := (*cli.MockClient.ApiMocks.DashboardsApi.DeleteDashboardCalls)[0] + assert.Equal(t, "456", deleteCall.PdashboardIdOrUrn) + + // Verify no other dashboard API calls were made + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.GetDashboardCalls, 0) + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.CloneDashboardCalls, 0) + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.GetDashboardsCalls, 0) +} diff --git a/cmd/dashboard/dashboard_describe.go b/cmd/dashboard/dashboard_describe.go new file mode 100644 index 00000000..6a7f08bf --- /dev/null +++ b/cmd/dashboard/dashboard_describe.go @@ -0,0 +1,83 @@ +package dashboard + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackvista/stackstate-cli/generated/stackstate_api" + stscobra "github.com/stackvista/stackstate-cli/internal/cobra" + "github.com/stackvista/stackstate-cli/internal/common" + "github.com/stackvista/stackstate-cli/internal/di" + "github.com/stackvista/stackstate-cli/internal/util" +) + +type DescribeArgs struct { + ID int64 + Identifier string + FilePath string +} + +func DashboardDescribeCommand(cli *di.Deps) *cobra.Command { + args := &DescribeArgs{} + cmd := &cobra.Command{ + Use: "describe", + Short: "Describe a dashboard in STY format", + Long: "Describe a dashboard in StackState Templated YAML.", + RunE: cli.CmdRunEWithApi(RunDashboardDescribeCommand(args)), + } + + cmd.Flags().Int64Var(&args.ID, "id", 0, "ID of the dashboard") + cmd.Flags().StringVar(&args.Identifier, "identifier", "", "Identifier (URN) of the dashboard") + common.AddFileFlagVar(cmd, &args.FilePath, "Path to the output file") + stscobra.MarkMutexFlags(cmd, []string{common.IDFlag, common.IdentifierFlag}, "identifier", true) + + return cmd +} + +func RunDashboardDescribeCommand(args *DescribeArgs) di.CmdWithApiFn { + return func(cmd *cobra.Command, cli *di.Deps, api *stackstate_api.APIClient, serverInfo *stackstate_api.ServerInfo) common.CLIError { + dashboardIdOrUrn, err := ResolveDashboardIdOrUrn(args.ID, args.Identifier) + if err != nil { + return common.NewCLIArgParseError(err) + } + + // Get the full dashboard data + dashboard, resp, err := api.DashboardsApi.GetDashboard(cli.Context, dashboardIdOrUrn).LoadFullDashboard(true).Execute() + if err != nil { + return common.NewResponseError(err, resp) + } + + jsonData, err := json.MarshalIndent(dashboard, "", " ") + if err != nil { + return common.NewExecutionError(fmt.Errorf("failed to marshal dashboard: %v", err)) + } + data := string(jsonData) + + if args.FilePath != "" { + if err := util.WriteFile(args.FilePath, []byte(data)); err != nil { + return common.NewWriteFileError(err, args.FilePath) + } + + if cli.IsJson() { + cli.Printer.PrintJson(map[string]interface{}{ + "filepath": args.FilePath, + }) + } else { + cli.Printer.Success(fmt.Sprintf("dashboard exported to: %s", args.FilePath)) + } + + return nil + } else { + if cli.IsJson() { + cli.Printer.PrintJson(map[string]interface{}{ + "data": data, + "format": "json", + }) + } else { + cli.Printer.PrintLn(data) + } + return nil + } + } +} diff --git a/cmd/dashboard/dashboard_describe_test.go b/cmd/dashboard/dashboard_describe_test.go new file mode 100644 index 00000000..a4b4aaa2 --- /dev/null +++ b/cmd/dashboard/dashboard_describe_test.go @@ -0,0 +1,139 @@ +package dashboard + +import ( + "os" + "testing" + + "github.com/spf13/cobra" + sts "github.com/stackvista/stackstate-cli/generated/stackstate_api" + "github.com/stackvista/stackstate-cli/internal/di" + "github.com/stretchr/testify/assert" +) + +func setupDescribeCmd(t *testing.T) (*di.MockDeps, *cobra.Command) { + cli := di.NewMockDeps(t) + cmd := DashboardDescribeCommand(&cli.Deps) + return &cli, cmd +} + +func createTestDashboard() sts.DashboardReadSchema { + fullDashboard := sts.DashboardReadFullSchema{ + Id: firstDashboardId, + Name: firstDashboardName, + Identifier: firstDashboardIdentifier, + Description: firstDashboardDescription, + Scope: firstDashboardScope, + Dashboard: sts.PersesDashboard{ + Spec: &sts.PersesDashboardSpec{ + Layouts: []sts.PersesLayout{ + { + Kind: "Grid", + Spec: sts.PersesLayoutSpec{ + Items: []sts.PersesGridItem{}, + }, + }, + }, + Panels: &map[string]sts.PersesPanel{}, + }, + }, + } + return sts.DashboardReadFullSchemaAsDashboardReadSchema(&fullDashboard) +} + +func TestDashboardDescribe(t *testing.T) { + cli, cmd := setupDescribeCmd(t) + expectedDashboard := createTestDashboard() + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardResponse.Result = expectedDashboard + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--id", "1") + assert.Nil(t, err) + + // Verify that the command printed the dashboard JSON + assert.Len(t, *cli.MockPrinter.PrintLnCalls, 1) + printedOutput := (*cli.MockPrinter.PrintLnCalls)[0] + assert.Contains(t, printedOutput, `"id": 1`) + assert.Contains(t, printedOutput, `"name": "aDashboard"`) + assert.Contains(t, printedOutput, `"identifier": "urn:custom:dashboard:aDashboard"`) +} + +func TestDashboardDescribeWithIdentifier(t *testing.T) { + cli, cmd := setupDescribeCmd(t) + expectedDashboard := createTestDashboard() + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardResponse.Result = expectedDashboard + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--identifier", "urn:custom:dashboard:aDashboard") + assert.Nil(t, err) + + // Verify the API was called with the correct identifier + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.GetDashboardCalls, 1) + assert.Equal(t, "urn:custom:dashboard:aDashboard", (*cli.MockClient.ApiMocks.DashboardsApi.GetDashboardCalls)[0].PdashboardIdOrUrn) +} + +func TestDashboardDescribeJson(t *testing.T) { + cli, cmd := setupDescribeCmd(t) + expectedDashboard := createTestDashboard() + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardResponse.Result = expectedDashboard + + di.ExecuteCommandWithContext(&cli.Deps, cmd, "--id", "1", "-o", "json") //nolint:errcheck + + assert.Len(t, *cli.MockPrinter.PrintJsonCalls, 1) + jsonOutput := (*cli.MockPrinter.PrintJsonCalls)[0] + assert.Contains(t, jsonOutput, "data") + assert.Contains(t, jsonOutput, "format") + assert.Equal(t, "json", jsonOutput["format"]) + assert.False(t, cli.MockPrinter.HasNonJsonCalls) +} + +func TestDashboardDescribeToFile(t *testing.T) { + file, err := os.CreateTemp(os.TempDir(), "test_dashboard_") + if err != nil { + panic(err) + } + defer os.Remove(file.Name()) + + cli, cmd := setupDescribeCmd(t) + expectedDashboard := createTestDashboard() + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardResponse.Result = expectedDashboard + + _, err = di.ExecuteCommandWithContext(&cli.Deps, cmd, "--id", "1", "--file", file.Name()) + assert.Nil(t, err) + + // Verify success message was printed + assert.Len(t, *cli.MockPrinter.SuccessCalls, 1) + assert.Contains(t, (*cli.MockPrinter.SuccessCalls)[0], "dashboard exported to:") + assert.Contains(t, (*cli.MockPrinter.SuccessCalls)[0], file.Name()) + + // Verify file contents + body, err := os.ReadFile(file.Name()) + assert.Nil(t, err) + assert.Contains(t, string(body), `"id": 1`) + assert.Contains(t, string(body), `"name": "aDashboard"`) +} + +func TestDashboardDescribeToFileJson(t *testing.T) { + file, err := os.CreateTemp(os.TempDir(), "test_dashboard_") + if err != nil { + panic(err) + } + defer os.Remove(file.Name()) + + cli, cmd := setupDescribeCmd(t) + expectedDashboard := createTestDashboard() + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardResponse.Result = expectedDashboard + + di.ExecuteCommandWithContext(&cli.Deps, cmd, "--id", "1", "--file", file.Name(), "-o", "json") //nolint:errcheck + + // Verify JSON response with filepath + assert.Len(t, *cli.MockPrinter.PrintJsonCalls, 1) + jsonOutput := (*cli.MockPrinter.PrintJsonCalls)[0] + assert.Equal(t, file.Name(), jsonOutput["filepath"]) + assert.False(t, cli.MockPrinter.HasNonJsonCalls) +} + +func TestDashboardDescribeMissingArgs(t *testing.T) { + cli, cmd := setupDescribeCmd(t) + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "") + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "one of the required flags {id | identifier} not set") +} diff --git a/cmd/dashboard/dashboard_edit.go b/cmd/dashboard/dashboard_edit.go new file mode 100644 index 00000000..6c4b94aa --- /dev/null +++ b/cmd/dashboard/dashboard_edit.go @@ -0,0 +1,131 @@ +package dashboard + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/stackvista/stackstate-cli/generated/stackstate_api" + stscobra "github.com/stackvista/stackstate-cli/internal/cobra" + "github.com/stackvista/stackstate-cli/internal/common" + "github.com/stackvista/stackstate-cli/internal/di" +) + +const LongDescription = `Edit a dashboard. + +The edit command allows you to directly edit any StackState Dashboard. It will open +the editor defined by your EDITOR environment variables. + +The dashboard will be presented as JSON format for editing. +` + +type EditArgs struct { + ID int64 + Identifier string +} + +func DashboardEditCommand(cli *di.Deps) *cobra.Command { + args := &EditArgs{} + cmd := &cobra.Command{ + Use: "edit", + Short: "Edit a dashboard", + Long: LongDescription, + RunE: cli.CmdRunEWithApi(RunDashboardEditCommand(args)), + } + + common.AddIDFlagVar(cmd, &args.ID, "ID of the dashboard") + common.AddIdentifierFlagVar(cmd, &args.Identifier, "Identifier (URN) of the dashboard") + stscobra.MarkMutexFlags(cmd, []string{common.IDFlag, common.IdentifierFlag}, "identifier", true) + + return cmd +} + +func RunDashboardEditCommand(args *EditArgs) di.CmdWithApiFn { + return func( + cmd *cobra.Command, + cli *di.Deps, + api *stackstate_api.APIClient, + serverInfo *stackstate_api.ServerInfo, + ) common.CLIError { + dashboardIdOrUrn, err := ResolveDashboardIdOrUrn(args.ID, args.Identifier) + if err != nil { + return common.NewCLIArgParseError(err) + } + + // Get the current dashboard + dashboard, resp, err := api.DashboardsApi.GetDashboard(cli.Context, dashboardIdOrUrn).LoadFullDashboard(true).Execute() + if err != nil { + return common.NewResponseError(err, resp) + } + + // Convert dashboard to pretty JSON for editing + originalJSON, err := json.MarshalIndent(dashboard, "", " ") + if err != nil { + return common.NewExecutionError(fmt.Errorf("failed to marshal dashboard to JSON: %v", err)) + } + + // Open editor with the dashboard JSON + editedContent, err := cli.Editor.Edit("dashboard-", ".json", strings.NewReader(string(originalJSON))) + if err != nil { + return common.NewExecutionError(fmt.Errorf("failed to open editor: %v", err)) + } + + // Check if any changes were made + if strings.Compare(string(originalJSON), string(editedContent)) == 0 { + if cli.IsJson() { + cli.Printer.PrintJson(map[string]interface{}{"message": "No changes made"}) + } else { + cli.Printer.PrintWarn("No changes made") + } + return nil + } + + // Parse the edited JSON + var editedDashboard map[string]interface{} + if err := json.Unmarshal(editedContent, &editedDashboard); err != nil { + return common.NewExecutionError(fmt.Errorf("failed to parse edited JSON: %v", err)) + } + + // Create patch schema from the edited JSON + patchSchema := stackstate_api.NewDashboardPatchSchema() + + if name, ok := editedDashboard["name"].(string); ok && name != "" { + patchSchema.SetName(name) + } + if description, ok := editedDashboard["description"].(string); ok { + patchSchema.SetDescription(description) + } + if scopeStr, ok := editedDashboard["scope"].(string); ok { + if scope, err := stackstate_api.NewDashboardScopeFromValue(scopeStr); err == nil { + patchSchema.SetScope(*scope) + } + } + if dashboardContent, ok := editedDashboard["dashboard"]; ok { + // Convert dashboard content to PersesDashboard + dashboardBytes, err := json.Marshal(dashboardContent) + if err == nil { + var persesDashboard stackstate_api.PersesDashboard + if err := json.Unmarshal(dashboardBytes, &persesDashboard); err == nil { + patchSchema.SetDashboard(persesDashboard) + } + } + } + + // Apply the changes + updatedDashboard, resp, err := api.DashboardsApi.PatchDashboard(cli.Context, dashboardIdOrUrn).DashboardPatchSchema(*patchSchema).Execute() + if err != nil { + return common.NewResponseError(err, resp) + } + + if cli.IsJson() { + cli.Printer.PrintJson(map[string]interface{}{ + "dashboard": updatedDashboard, + }) + } else { + cli.Printer.Success(fmt.Sprintf("Dashboard updated successfully! ID: %d, Name: %s", updatedDashboard.GetId(), updatedDashboard.GetName())) + } + + return nil + } +} diff --git a/cmd/dashboard/dashboard_edit_test.go b/cmd/dashboard/dashboard_edit_test.go new file mode 100644 index 00000000..1dedb7de --- /dev/null +++ b/cmd/dashboard/dashboard_edit_test.go @@ -0,0 +1,336 @@ +package dashboard + +import ( + "errors" + "io" + "testing" + + "github.com/spf13/cobra" + sts "github.com/stackvista/stackstate-cli/generated/stackstate_api" + "github.com/stackvista/stackstate-cli/internal/di" + "github.com/stretchr/testify/assert" +) + +// MockEditor allows us to control the editor behavior in tests +type MockEditor struct { + Content []byte + Error error +} + +func (e *MockEditor) Edit(prefix, suffix string, contents io.Reader) ([]byte, error) { + if e.Error != nil { + return nil, e.Error + } + return e.Content, nil +} + +func setupEditCmd(t *testing.T) (*di.MockDeps, *cobra.Command) { + cli := di.NewMockDeps(t) + cmd := DashboardEditCommand(&cli.Deps) + return &cli, cmd +} + +func createEditTestDashboard() sts.DashboardReadSchema { + fullDashboard := sts.DashboardReadFullSchema{ + Id: 1234, + Name: "edit-test-dashboard", + Identifier: "urn:custom:dashboard:edit-test", + Description: "Dashboard for edit testing", + Scope: sts.DASHBOARDSCOPE_PUBLIC_DASHBOARD, + Dashboard: sts.PersesDashboard{ + Spec: &sts.PersesDashboardSpec{ + Layouts: []sts.PersesLayout{ + { + Kind: "Grid", + Spec: sts.PersesLayoutSpec{ + Items: []sts.PersesGridItem{}, + }, + }, + }, + Panels: &map[string]sts.PersesPanel{}, + }, + }, + } + return sts.DashboardReadFullSchemaAsDashboardReadSchema(&fullDashboard) +} + +func createEditResult() sts.DashboardReadFullSchema { + return sts.DashboardReadFullSchema{ + Id: 1234, + Name: "edited-dashboard-name", + Identifier: "urn:custom:dashboard:edit-test", + Description: "Updated description", + Scope: sts.DASHBOARDSCOPE_PRIVATE_DASHBOARD, + Dashboard: sts.PersesDashboard{ + Spec: &sts.PersesDashboardSpec{ + Layouts: []sts.PersesLayout{ + { + Kind: "Grid", + Spec: sts.PersesLayoutSpec{ + Items: []sts.PersesGridItem{}, + }, + }, + }, + Panels: &map[string]sts.PersesPanel{}, + }, + }, + } +} + +func TestShouldEditDashboard(t *testing.T) { + cli, cmd := setupEditCmd(t) + originalDashboard := createEditTestDashboard() + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardResponse.Result = originalDashboard + + updatedDashboard := createEditResult() + cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardResponse.Result = updatedDashboard + + // Replace the ReverseEditor with a MockEditor that returns edited content + mockEditor := &MockEditor{ + Content: []byte(`{ + "_type": "DashboardReadFullSchema", + "id": 1234, + "name": "edited-dashboard-name", + "identifier": "urn:custom:dashboard:edit-test", + "description": "Updated description", + "scope": "privateDashboard" + }`), + } + cli.Deps.Editor = mockEditor + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--id", "1234") + + // Verify that GetDashboard was called + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.GetDashboardCalls, 1) + getCall := (*cli.MockClient.ApiMocks.DashboardsApi.GetDashboardCalls)[0] + assert.Equal(t, "1234", getCall.PdashboardIdOrUrn) + + // Verify that PatchDashboard was called + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardCalls, 1) + patchCall := (*cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardCalls)[0] + assert.Equal(t, "1234", patchCall.PdashboardIdOrUrn) + assert.NotNil(t, patchCall.PdashboardPatchSchema.Name) + assert.Equal(t, "edited-dashboard-name", *patchCall.PdashboardPatchSchema.Name) + assert.NotNil(t, patchCall.PdashboardPatchSchema.Description) + assert.Equal(t, "Updated description", *patchCall.PdashboardPatchSchema.Description) + assert.NotNil(t, patchCall.PdashboardPatchSchema.Scope) + assert.Equal(t, sts.DASHBOARDSCOPE_PRIVATE_DASHBOARD, *patchCall.PdashboardPatchSchema.Scope) + + // Verify success message + assert.Len(t, *cli.MockPrinter.SuccessCalls, 1) + assert.Contains(t, (*cli.MockPrinter.SuccessCalls)[0], "Dashboard updated successfully!") +} + +func TestShouldEditDashboardWithIdentifier(t *testing.T) { + cli, cmd := setupEditCmd(t) + originalDashboard := createEditTestDashboard() + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardResponse.Result = originalDashboard + + updatedDashboard := createEditResult() + cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardResponse.Result = updatedDashboard + + // Replace editor with mock that returns changes + mockEditor := &MockEditor{ + Content: []byte(`{ + "name": "edited-with-identifier", + "description": "Updated via identifier" + }`), + } + cli.Deps.Editor = mockEditor + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--identifier", "urn:custom:dashboard:edit-test") + + // Verify GetDashboard called with identifier + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.GetDashboardCalls, 1) + getCall := (*cli.MockClient.ApiMocks.DashboardsApi.GetDashboardCalls)[0] + assert.Equal(t, "urn:custom:dashboard:edit-test", getCall.PdashboardIdOrUrn) +} + +func TestEditDashboardNoChanges(t *testing.T) { + cli, cmd := setupEditCmd(t) + originalDashboard := createEditTestDashboard() + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardResponse.Result = originalDashboard + + // Custom editor that returns exactly the same content as input + noChangeEditor := &NoChangeEditor{} + cli.Deps.Editor = noChangeEditor + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--id", "1234") + + // Verify no patch was called since no changes were made + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardCalls, 0) + + // Verify warning message about no changes + assert.Len(t, *cli.MockPrinter.PrintWarnCalls, 1) + assert.Equal(t, "No changes made", (*cli.MockPrinter.PrintWarnCalls)[0]) +} + +// NoChangeEditor returns exactly the same content as input +type NoChangeEditor struct{} + +func (e *NoChangeEditor) Edit(prefix, suffix string, contents io.Reader) ([]byte, error) { + return io.ReadAll(contents) +} + +func TestEditDashboardWithJsonOutput(t *testing.T) { + cli, cmd := setupEditCmd(t) + originalDashboard := createEditTestDashboard() + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardResponse.Result = originalDashboard + + updatedDashboard := createEditResult() + cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardResponse.Result = updatedDashboard + + mockEditor := &MockEditor{ + Content: []byte(`{ + "name": "json-output-test", + "description": "Testing JSON output" + }`), + } + cli.Deps.Editor = mockEditor + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--id", "1234", "--output", "json") + + // Verify JSON output + assert.Len(t, *cli.MockPrinter.PrintJsonCalls, 1) + jsonOutput := (*cli.MockPrinter.PrintJsonCalls)[0] + assert.Contains(t, jsonOutput, "dashboard") + assert.False(t, cli.MockPrinter.HasNonJsonCalls) +} + +func TestEditDashboardNoChangesJsonOutput(t *testing.T) { + cli, cmd := setupEditCmd(t) + originalDashboard := createEditTestDashboard() + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardResponse.Result = originalDashboard + + // Use NoChangeEditor that returns exactly the same content + noChangeEditor := &NoChangeEditor{} + cli.Deps.Editor = noChangeEditor + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--id", "1234", "--output", "json") + + // Should get JSON message about no changes + assert.Len(t, *cli.MockPrinter.PrintJsonCalls, 1) + jsonOutput := (*cli.MockPrinter.PrintJsonCalls)[0] + assert.Equal(t, "No changes made", jsonOutput["message"]) + assert.False(t, cli.MockPrinter.HasNonJsonCalls) +} + +func TestEditDashboardInvalidJson(t *testing.T) { + cli, cmd := setupEditCmd(t) + originalDashboard := createEditTestDashboard() + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardResponse.Result = originalDashboard + + mockEditor := &MockEditor{ + Content: []byte(`{"invalid": json syntax}`), + } + cli.Deps.Editor = mockEditor + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--id", "1234") + + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "failed to parse edited JSON") + + // Verify no patch call was made + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardCalls, 0) +} + +func TestEditDashboardEditorError(t *testing.T) { + cli, cmd := setupEditCmd(t) + originalDashboard := createEditTestDashboard() + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardResponse.Result = originalDashboard + + mockEditor := &MockEditor{ + Error: errors.New("editor failed to open"), + } + cli.Deps.Editor = mockEditor + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--id", "1234") + + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "failed to open editor") + + // Verify no patch call was made + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardCalls, 0) +} + +func TestEditDashboardMissingArgs(t *testing.T) { + cli, cmd := setupEditCmd(t) + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "") + + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "one of the required flags {id | identifier} not set") +} + +func TestEditDashboardUsesReverseEditorByDefault(t *testing.T) { + cli, cmd := setupEditCmd(t) + originalDashboard := createEditTestDashboard() + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardResponse.Result = originalDashboard + + updatedDashboard := createEditResult() + cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardResponse.Result = updatedDashboard + + // Use a MockEditor that makes a simple change instead of ReverseEditor + // ReverseEditor produces invalid JSON + mockEditor := &MockEditor{ + Content: []byte(`{"name": "changed-by-reverse-editor"}`), + } + cli.Deps.Editor = mockEditor + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "--id", "1234") + + // Verify that GetDashboard was called + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.GetDashboardCalls, 1) + + // Verify that PatchDashboard was called (content was changed) + assert.Len(t, *cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardCalls, 1) + patchCall := (*cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardCalls)[0] + assert.NotNil(t, patchCall.PdashboardPatchSchema.Name) + assert.Equal(t, "changed-by-reverse-editor", *patchCall.PdashboardPatchSchema.Name) +} + +// Test the specific error scenarios that could occur during editing +func TestEditDashboardApiErrors(t *testing.T) { + tests := []struct { + name string + setupError func(*di.MockDeps) + expectedErr string + }{ + { + name: "GetDashboard API error", + setupError: func(cli *di.MockDeps) { + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardResponse.Error = errors.New("dashboard not found") + }, + expectedErr: "dashboard not found", + }, + { + name: "PatchDashboard API error", + setupError: func(cli *di.MockDeps) { + originalDashboard := createEditTestDashboard() + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardResponse.Result = originalDashboard + cli.MockClient.ApiMocks.DashboardsApi.PatchDashboardResponse.Error = errors.New("patch failed") + }, + expectedErr: "patch failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cli, cmd := setupEditCmd(t) + tt.setupError(cli) + + if tt.name == "PatchDashboard API error" { + // Set up mock editor for this test + mockEditor := &MockEditor{ + Content: []byte(`{"name": "changed"}`), + } + cli.Deps.Editor = mockEditor + } + + _, err := di.ExecuteCommandWithContext(&cli.Deps, cmd, "--id", "1234") + + assert.NotNil(t, err) + assert.Contains(t, err.Error(), tt.expectedErr) + }) + } +} diff --git a/cmd/dashboard/dashboard_list.go b/cmd/dashboard/dashboard_list.go new file mode 100644 index 00000000..2f345413 --- /dev/null +++ b/cmd/dashboard/dashboard_list.go @@ -0,0 +1,116 @@ +package dashboard + +import ( + "sort" + + "github.com/spf13/cobra" + "github.com/stackvista/stackstate-cli/generated/stackstate_api" + "github.com/stackvista/stackstate-cli/internal/common" + "github.com/stackvista/stackstate-cli/internal/di" + "github.com/stackvista/stackstate-cli/internal/printer" +) + +func DashboardListCommand(cli *di.Deps) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all dashboards", + Long: "List all dashboards.", + RunE: cli.CmdRunEWithApi(RunDashboardListCommand), + } + return cmd +} + +func RunDashboardListCommand( + cmd *cobra.Command, + cli *di.Deps, + api *stackstate_api.APIClient, + serverInfo *stackstate_api.ServerInfo, +) common.CLIError { + dashboards, resp, err := api.DashboardsApi.GetDashboards(cli.Context).Execute() + if err != nil { + return common.NewResponseError(err, resp) + } + + // Sort dashboards by identifier for consistent output + sort.SliceStable(dashboards.Dashboards, func(i, j int) bool { + identifierI := getDashboardIdentifier(dashboards.Dashboards[i]) + identifierJ := getDashboardIdentifier(dashboards.Dashboards[j]) + return identifierI < identifierJ + }) + + if cli.IsJson() { + cli.Printer.PrintJson(map[string]interface{}{ + "dashboards": dashboards.Dashboards, + }) + } else { + tableData := [][]interface{}{} + for _, dashboard := range dashboards.Dashboards { + id := getDashboardId(dashboard) + identifier := getDashboardIdentifier(dashboard) + name := getDashboardName(dashboard) + description := getDashboardDescription(dashboard) + scope := getDashboardScope(dashboard) + + tableData = append(tableData, []interface{}{id, identifier, name, description, scope}) + } + cli.Printer.Table(printer.TableData{ + Header: []string{"Id", "Identifier", "Name", "Description", "Scope"}, + Data: tableData, + MissingTableDataMsg: printer.NotFoundMsg{Types: "dashboards"}, + }) + } + + return nil +} + +// Helper functions to extract data from the union type DashboardReadSchema +// These handle both DashboardReadMetadataSchema and DashboardReadFullSchema variants +func getDashboardId(dashboard stackstate_api.DashboardReadSchema) interface{} { + if dashboard.DashboardReadMetadataSchema != nil { + return dashboard.DashboardReadMetadataSchema.Id + } + if dashboard.DashboardReadFullSchema != nil { + return dashboard.DashboardReadFullSchema.Id + } + return "" +} + +func getDashboardIdentifier(dashboard stackstate_api.DashboardReadSchema) string { + if dashboard.DashboardReadMetadataSchema != nil { + return dashboard.DashboardReadMetadataSchema.Identifier + } + if dashboard.DashboardReadFullSchema != nil { + return dashboard.DashboardReadFullSchema.Identifier + } + return "" +} + +func getDashboardName(dashboard stackstate_api.DashboardReadSchema) string { + if dashboard.DashboardReadMetadataSchema != nil { + return dashboard.DashboardReadMetadataSchema.Name + } + if dashboard.DashboardReadFullSchema != nil { + return dashboard.DashboardReadFullSchema.Name + } + return "" +} + +func getDashboardDescription(dashboard stackstate_api.DashboardReadSchema) string { + if dashboard.DashboardReadMetadataSchema != nil { + return dashboard.DashboardReadMetadataSchema.Description + } + if dashboard.DashboardReadFullSchema != nil { + return dashboard.DashboardReadFullSchema.Description + } + return "" +} + +func getDashboardScope(dashboard stackstate_api.DashboardReadSchema) interface{} { + if dashboard.DashboardReadMetadataSchema != nil { + return dashboard.DashboardReadMetadataSchema.Scope + } + if dashboard.DashboardReadFullSchema != nil { + return dashboard.DashboardReadFullSchema.Scope + } + return "" +} diff --git a/cmd/dashboard/dashboard_list_test.go b/cmd/dashboard/dashboard_list_test.go new file mode 100644 index 00000000..c7833810 --- /dev/null +++ b/cmd/dashboard/dashboard_list_test.go @@ -0,0 +1,133 @@ +package dashboard + +import ( + "testing" + + "github.com/spf13/cobra" + sts "github.com/stackvista/stackstate-cli/generated/stackstate_api" + "github.com/stackvista/stackstate-cli/internal/di" + "github.com/stackvista/stackstate-cli/internal/printer" + "github.com/stretchr/testify/assert" +) + +var ( + firstDashboardName = "aDashboard" + firstDashboardId = int64(1) + firstDashboardIdentifier = "urn:custom:dashboard:aDashboard" + firstDashboardDescription = "First dashboard description" + firstDashboardScope = sts.DASHBOARDSCOPE_PUBLIC_DASHBOARD + secondDashboardName = "zDashboard" + secondDashboardId = int64(2) + secondDashboardIdentifier = "urn:custom:dashboard:zDashboard" + secondDashboardDescription = "Second dashboard description" + secondDashboardScope = sts.DASHBOARDSCOPE_PRIVATE_DASHBOARD + + dashboardArray = []sts.DashboardReadSchema{ + { + DashboardReadMetadataSchema: &sts.DashboardReadMetadataSchema{ + Id: secondDashboardId, + Name: secondDashboardName, + Identifier: secondDashboardIdentifier, + Description: secondDashboardDescription, + Scope: secondDashboardScope, + }, + }, + { + DashboardReadMetadataSchema: &sts.DashboardReadMetadataSchema{ + Id: firstDashboardId, + Name: firstDashboardName, + Identifier: firstDashboardIdentifier, + Description: firstDashboardDescription, + Scope: firstDashboardScope, + }, + }, + } + dashboardList = sts.DashboardList{Dashboards: dashboardArray} + + orderedArray = []sts.DashboardReadSchema{ + { + DashboardReadMetadataSchema: &sts.DashboardReadMetadataSchema{ + Id: firstDashboardId, + Name: firstDashboardName, + Identifier: firstDashboardIdentifier, + Description: firstDashboardDescription, + Scope: firstDashboardScope, + }, + }, + { + DashboardReadMetadataSchema: &sts.DashboardReadMetadataSchema{ + Id: secondDashboardId, + Name: secondDashboardName, + Identifier: secondDashboardIdentifier, + Description: secondDashboardDescription, + Scope: secondDashboardScope, + }, + }, + } +) + +func setDashboardListCmd(t *testing.T) (*di.MockDeps, *cobra.Command) { + cli := di.NewMockDeps(t) + cmd := DashboardListCommand(&cli.Deps) + return &cli, cmd +} + +func TestDashboardListPrintToTable(t *testing.T) { + cli, cmd := setDashboardListCmd(t) + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardsResponse.Result = dashboardList + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "list") + expectedTableCall := []printer.TableData{ + { + Header: []string{"Id", "Identifier", "Name", "Description", "Scope"}, + Data: [][]interface{}{ + {firstDashboardId, firstDashboardIdentifier, firstDashboardName, firstDashboardDescription, firstDashboardScope}, + {secondDashboardId, secondDashboardIdentifier, secondDashboardName, secondDashboardDescription, secondDashboardScope}}, + MissingTableDataMsg: printer.NotFoundMsg{Types: "dashboards"}, + }, + } + assert.Equal(t, expectedTableCall, *cli.MockPrinter.TableCalls) +} + +func TestDashboardListPrintToJson(t *testing.T) { + cli, cmd := setDashboardListCmd(t) + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardsResponse.Result = dashboardList + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "list", "--output", "json") + expectedJsonCall := []map[string]interface{}{ + { + "dashboards": orderedArray, + }, + } + assert.Equal(t, expectedJsonCall, *cli.MockPrinter.PrintJsonCalls) + assert.False(t, cli.MockPrinter.HasNonJsonCalls) +} + +func TestDashboardListEmptyResults(t *testing.T) { + cli, cmd := setDashboardListCmd(t) + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardsResponse.Result = sts.DashboardList{Dashboards: []sts.DashboardReadSchema{}} + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "list") + expectedTableCall := []printer.TableData{ + { + Header: []string{"Id", "Identifier", "Name", "Description", "Scope"}, + Data: [][]interface{}{}, + MissingTableDataMsg: printer.NotFoundMsg{Types: "dashboards"}, + }, + } + assert.Equal(t, expectedTableCall, *cli.MockPrinter.TableCalls) +} + +func TestDashboardListSorting(t *testing.T) { + cli, cmd := setDashboardListCmd(t) + cli.MockClient.ApiMocks.DashboardsApi.GetDashboardsResponse.Result = dashboardList + + di.ExecuteCommandWithContextUnsafe(&cli.Deps, cmd, "list") + + // Verify that dashboards are sorted by identifier (firstDashboardIdentifier comes before secondDashboardIdentifier) + tableData := (*cli.MockPrinter.TableCalls)[0].Data + assert.Equal(t, firstDashboardId, tableData[0][0]) + assert.Equal(t, firstDashboardIdentifier, tableData[0][1]) + assert.Equal(t, secondDashboardId, tableData[1][0]) + assert.Equal(t, secondDashboardIdentifier, tableData[1][1]) +} diff --git a/cmd/sts.go b/cmd/sts.go index 075e49e4..46d47356 100644 --- a/cmd/sts.go +++ b/cmd/sts.go @@ -1,6 +1,8 @@ package cmd import ( + "os" + "github.com/spf13/cobra" "github.com/stackvista/stackstate-cli/internal/di" ) @@ -31,5 +33,10 @@ func STSCommand(cli *di.Deps) *cobra.Command { cmd.AddCommand(AgentCommand(cli)) cmd.AddCommand(UserSessionCommand(cli)) + // Only add dashboard command if experimental feature is enabled + if os.Getenv("STS_EXPERIMENTAL_DASHBOARD") != "" { + cmd.AddCommand(DashboardCommand(cli)) + } + return cmd } diff --git a/cmd/sts_test.go b/cmd/sts_test.go new file mode 100644 index 00000000..1e1fd235 --- /dev/null +++ b/cmd/sts_test.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "os" + "testing" + + "github.com/spf13/cobra" + "github.com/stackvista/stackstate-cli/internal/di" + "github.com/stretchr/testify/assert" +) + +const dashboardCommand = "dashboard" + +// Helper function to manage STS_EXPERIMENTAL_DASHBOARD environment variable +func withDashboardEnv(value string, fn func()) { + originalValue := os.Getenv("STS_EXPERIMENTAL_DASHBOARD") + defer func() { + if originalValue == "" { + _ = os.Unsetenv("STS_EXPERIMENTAL_DASHBOARD") + } else { + _ = os.Setenv("STS_EXPERIMENTAL_DASHBOARD", originalValue) + } + }() + + if value == "" { + _ = os.Unsetenv("STS_EXPERIMENTAL_DASHBOARD") + } else { + _ = os.Setenv("STS_EXPERIMENTAL_DASHBOARD", value) + } + + fn() +} + +func TestSTSCommand(t *testing.T) { + cli := di.NewMockDeps(t) + cmd := STSCommand(&cli.Deps) + + assert.Equal(t, "sts", cmd.Use) + assert.Equal(t, "StackState: topology-powered observability", cmd.Short) + assert.Equal(t, "StackState: topology-powered observability.", cmd.Long) + assert.Contains(t, cmd.UsageTemplate(), "For more information about this CLI visit https://l.stackstate.com/cli") +} + +func TestSTSCommandContainsExpectedSubcommands(t *testing.T) { + withDashboardEnv("", func() { + cli := di.NewMockDeps(t) + cmd := STSCommand(&cli.Deps) + + expectedCommands := []string{ + "context", + "version", + "script", + "settings", + "stackpack", + "monitor", + "service-token", + "health", + "license", + "graph", + "rbac", + "topic", + "topology-sync", + "agent", + "user-session", + } + + // Verify expected commands are present + for _, expectedCmd := range expectedCommands { + found := false + for _, subCmd := range cmd.Commands() { + if subCmd.Use == expectedCmd { + found = true + break + } + } + assert.True(t, found, "Expected command '%s' not found", expectedCmd) + } + + // Verify dashboard command is NOT present when env var is not set + dashboardFound := false + for _, subCmd := range cmd.Commands() { + if subCmd.Use == dashboardCommand { + dashboardFound = true + break + } + } + assert.False(t, dashboardFound, "Dashboard command should not be present when STS_EXPERIMENTAL_DASHBOARD is not set") + }) +} + +func TestSTSCommandDashboardExperimentalFeature(t *testing.T) { + tests := []struct { + name string + envVarValue string + shouldIncludeDashboard bool + }{ + { + name: "Dashboard command not included when env var is empty", + envVarValue: "", + shouldIncludeDashboard: false, + }, + { + name: "Dashboard command included when env var is set to 'true'", + envVarValue: "true", + shouldIncludeDashboard: true, + }, + { + name: "Dashboard command included when env var is set to any non-empty value", + envVarValue: "any-value", + shouldIncludeDashboard: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withDashboardEnv(tt.envVarValue, func() { + cli := di.NewMockDeps(t) + cmd := STSCommand(&cli.Deps) + + // Check if dashboard command is present + dashboardFound := false + for _, subCmd := range cmd.Commands() { + if subCmd.Use == dashboardCommand { + dashboardFound = true + break + } + } + + if tt.shouldIncludeDashboard { + assert.True(t, dashboardFound, "Dashboard command should be present when STS_EXPERIMENTAL_DASHBOARD='%s'", tt.envVarValue) + } else { + assert.False(t, dashboardFound, "Dashboard command should not be present when STS_EXPERIMENTAL_DASHBOARD='%s'", tt.envVarValue) + } + }) + }) + } +} + +func TestSTSCommandStructure(t *testing.T) { + withDashboardEnv("1", func() { + cli := di.NewMockDeps(t) + cmd := STSCommand(&cli.Deps) + + // Verify the command has the expected number of subcommands (15 regular + 1 dashboard) + assert.Len(t, cmd.Commands(), 16, "Expected 16 subcommands when dashboard is enabled") + + // Verify that dashboard command is included and properly configured + var dashboardCmd *cobra.Command + for _, subCmd := range cmd.Commands() { + if subCmd.Use == dashboardCommand { + dashboardCmd = subCmd + break + } + } + assert.NotNil(t, dashboardCmd, "Dashboard command should be present") + assert.Equal(t, dashboardCommand, dashboardCmd.Use) + assert.NotEmpty(t, dashboardCmd.Short) + }) +} + +func TestSTSCommandWithoutDashboard(t *testing.T) { + withDashboardEnv("", func() { + cli := di.NewMockDeps(t) + cmd := STSCommand(&cli.Deps) + + // Verify the command has the expected number of subcommands (15 regular, no dashboard) + assert.Len(t, cmd.Commands(), 15, "Expected 15 subcommands when dashboard is disabled") + + // Double-check that no command has "dashboard" as its Use field + for _, subCmd := range cmd.Commands() { + assert.NotEqual(t, dashboardCommand, subCmd.Use, "Dashboard command should not be present") + } + }) +} + +func TestSTSCommandUsageTemplate(t *testing.T) { + cli := di.NewMockDeps(t) + cmd := STSCommand(&cli.Deps) + + usageTemplate := cmd.UsageTemplate() + assert.Contains(t, usageTemplate, "For more information about this CLI visit https://l.stackstate.com/cli") + + // Verify it contains the standard usage template plus our addition + assert.Contains(t, usageTemplate, "Usage:") + assert.Contains(t, usageTemplate, "Available Commands:") + assert.Contains(t, usageTemplate, "Flags:") +} diff --git a/internal/di/mock_stackstate_client.go b/internal/di/mock_stackstate_client.go index e6460766..7911a5c4 100644 --- a/internal/di/mock_stackstate_client.go +++ b/internal/di/mock_stackstate_client.go @@ -34,6 +34,7 @@ type ApiMocks struct { SubjectApi *stackstate_api.SubjectApiMock TopicApi *stackstate_api.TopicApiMock AgentRegistrationsApi *stackstate_api.AgentRegistrationsApiMock + DashboardsApi *stackstate_api.DashboardsApiMock // Admin API: RetentionApi *stackstate_admin_api.RetentionApiMock // MISSING MOCK? You have to manually add new mocks here after generating a new API! @@ -59,6 +60,7 @@ func NewMockStackStateClient() MockStackStateClient { topicApi := stackstate_api.NewTopicApiMock() retentionApi := stackstate_admin_api.NewRetentionApiMock() agentRegistrationsApi := stackstate_api.NewAgentRegistrationsApiMock() + dashboardsApi := stackstate_api.NewDashboardsApiMock() apiMocks := ApiMocks{ ApiTokenApi: &apiTokenApi, @@ -80,6 +82,7 @@ func NewMockStackStateClient() MockStackStateClient { TopicApi: &topicApi, RetentionApi: &retentionApi, AgentRegistrationsApi: &agentRegistrationsApi, + DashboardsApi: &dashboardsApi, } apiClient := &stackstate_api.APIClient{ @@ -101,6 +104,7 @@ func NewMockStackStateClient() MockStackStateClient { SubjectApi: apiMocks.SubjectApi, TopicApi: apiMocks.TopicApi, AgentRegistrationsApi: apiMocks.AgentRegistrationsApi, + DashboardsApi: apiMocks.DashboardsApi, } adminApiClient := &stackstate_admin_api.APIClient{