diff --git a/pkg/cmd/project/list/list.go b/pkg/cmd/project/list/list.go index 702e80ce..3e836182 100644 --- a/pkg/cmd/project/list/list.go +++ b/pkg/cmd/project/list/list.go @@ -3,6 +3,7 @@ package list import ( "github.com/MakeNowJust/heredoc/v2" "github.com/OctopusDeploy/cli/pkg/apiclient" + "github.com/OctopusDeploy/cli/pkg/cmd/project/shared" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/factory" "github.com/OctopusDeploy/cli/pkg/output" @@ -29,10 +30,19 @@ func NewCmdList(f factory.Factory) *cobra.Command { } type ProjectAsJson struct { - Id string `json:"Id"` - Name string `json:"Name"` - Description string `json:"Description"` - ProjectTags []string `json:"ProjectTags,omitempty"` + Id string `json:"Id"` + Name string `json:"Name"` + Description string `json:"Description"` + ProjectTags []string `json:"ProjectTags,omitempty"` + Slug string `json:"Slug"` + SpaceId string `json:"SpaceId"` + ProjectGroupId string `json:"ProjectGroupId"` + ProjectGroupName string `json:"ProjectGroupName,omitempty"` + LifecycleId string `json:"LifecycleId"` + LifecycleName string `json:"LifecycleName,omitempty"` + IsDisabled bool `json:"IsDisabled"` + IsVersionControlled bool `json:"IsVersionControlled"` + TenantedDeploymentMode string `json:"TenantedDeploymentMode"` } func listRun(cmd *cobra.Command, f factory.Factory) error { @@ -46,19 +56,40 @@ func listRun(cmd *cobra.Command, f factory.Factory) error { return err } + // two lookups for the whole list rather than one per project, and best-effort + // as channel list is: listing still works without access to either + lifecycleMap := shared.GetLifecycleMap(client) + projectGroupMap := shared.GetProjectGroupMap(client) + return output.PrintArray(allProjects, cmd, output.Mappers[*projects.Project]{ Json: func(p *projects.Project) any { return ProjectAsJson{ - Id: p.GetID(), - Name: p.GetName(), - Description: p.Description, - ProjectTags: p.ProjectTags, + Id: p.GetID(), + Name: p.GetName(), + Description: p.Description, + ProjectTags: p.ProjectTags, + Slug: p.Slug, + SpaceId: p.SpaceID, + ProjectGroupId: p.ProjectGroupID, + ProjectGroupName: projectGroupMap[p.ProjectGroupID], + LifecycleId: p.LifecycleID, + LifecycleName: lifecycleMap[p.LifecycleID], + IsDisabled: p.IsDisabled, + IsVersionControlled: p.IsVersionControlled, + TenantedDeploymentMode: shared.TenantedDeploymentMode(p), } }, Table: output.TableDefinition[*projects.Project]{ - Header: []string{"NAME", "DESCRIPTION", "TAGS"}, + Header: []string{"NAME", "SLUG", "PROJECT GROUP", "LIFECYCLE", "DESCRIPTION", "TAGS"}, Row: func(p *projects.Project) []string { - return []string{output.Bold(p.Name), p.Description, output.FormatAsList(p.ProjectTags)} + return []string{ + output.Bold(p.Name), + p.Slug, + shared.DisplayName(p.ProjectGroupID, projectGroupMap[p.ProjectGroupID]), + shared.DisplayName(p.LifecycleID, lifecycleMap[p.LifecycleID]), + p.Description, + output.FormatAsList(p.ProjectTags), + } }, }, Basic: func(p *projects.Project) string { diff --git a/pkg/cmd/project/list/list_test.go b/pkg/cmd/project/list/list_test.go new file mode 100644 index 00000000..46791d00 --- /dev/null +++ b/pkg/cmd/project/list/list_test.go @@ -0,0 +1,191 @@ +package list_test + +import ( + "bytes" + "testing" + + "github.com/MakeNowJust/heredoc/v2" + cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/test/fixtures" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/lifecycles" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projectgroups" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +var rootResource = testutil.NewRootResource() + +func TestProjectList(t *testing.T) { + const spaceID = "Spaces-1" + + space1 := fixtures.NewSpace(spaceID, "Default Space") + + lifecycle := lifecycles.NewLifecycle("Default Lifecycle") + lifecycle.ID = "Lifecycles-1" + + projectGroup := projectgroups.NewProjectGroup("Default Project Group") + projectGroup.ID = "ProjectGroups-1" + + fireProject := fixtures.NewProject(spaceID, "Projects-22", "Fire Project", "Lifecycles-1", "ProjectGroups-1", "") + fireProject.SpaceID = spaceID + fireProject.Slug = "fire-project" + fireProject.ProjectTags = []string{"team/red"} + + waterProject := fixtures.NewProject(spaceID, "Projects-23", "Water Project", "Lifecycles-99", "ProjectGroups-1", "") + waterProject.SpaceID = spaceID + waterProject.Slug = "water-project" + waterProject.Description = "Wet things" + waterProject.IsDisabled = true + waterProject.ProjectTags = []string{"team/blue"} + + expectListRequests := func(t *testing.T, api *testutil.MockHttpServer) { + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/all").RespondWith([]*projects.Project{fireProject, waterProject}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/lifecycles/all").RespondWith([]*lifecycles.Lifecycle{lifecycle}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projectgroups/all").RespondWith([]*projectgroups.ProjectGroup{projectGroup}) + } + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"project list resolves group and lifecycle names, and falls back to the ID", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"project", "list", "--no-prompt", "-f", "table"}) + return rootCmd.ExecuteC() + }) + + expectListRequests(t, api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + NAME SLUG PROJECT GROUP LIFECYCLE DESCRIPTION TAGS + Fire Project fire-project Default Project Group Default Lifecycle team/red + Water Project water-project Default Project Group Lifecycles-99 Wet things team/blue + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"project list still works when the lookups fail", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"project", "list", "--no-prompt", "-f", "table"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/all").RespondWith([]*projects.Project{fireProject}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/lifecycles/all").RespondWithStatus(403, "403 Forbidden", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projectgroups/all").RespondWithStatus(403, "403 Forbidden", nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + NAME SLUG PROJECT GROUP LIFECYCLE DESCRIPTION TAGS + Fire Project fire-project ProjectGroups-1 Lifecycles-1 team/red + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"outputFormat json", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"project", "list", "--no-prompt", "-f", "json"}) + return rootCmd.ExecuteC() + }) + + expectListRequests(t, api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + type x struct { + Id string + Name string + Description string + ProjectTags []string + Slug string + SpaceId string + ProjectGroupId string + ProjectGroupName string + LifecycleId string + LifecycleName string + IsDisabled bool + IsVersionControlled bool + TenantedDeploymentMode string + } + parsedStdout, err := testutil.ParseJsonStrict[[]x](stdOut) + assert.Nil(t, err) + + assert.Equal(t, []x{ + { + Id: "Projects-22", + Name: "Fire Project", + ProjectTags: []string{"team/red"}, + Slug: "fire-project", + SpaceId: spaceID, + ProjectGroupId: "ProjectGroups-1", + ProjectGroupName: "Default Project Group", + LifecycleId: "Lifecycles-1", + LifecycleName: "Default Lifecycle", + TenantedDeploymentMode: "Untenanted", + }, + { + Id: "Projects-23", + Name: "Water Project", + Description: "Wet things", + ProjectTags: []string{"team/blue"}, + Slug: "water-project", + SpaceId: spaceID, + ProjectGroupId: "ProjectGroups-1", + ProjectGroupName: "Default Project Group", + LifecycleId: "Lifecycles-99", + IsDisabled: true, + TenantedDeploymentMode: "Untenanted", + }, + }, parsedStdout) + assert.Equal(t, "", stdErr.String()) + }}, + + {"outputFormat basic still lists just names", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"project", "list", "--no-prompt", "-f", "basic"}) + return rootCmd.ExecuteC() + }) + + expectListRequests(t, api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + Fire Project + Water Project + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + api, qa := testutil.NewMockServerAndAsker() + askProvider := question.NewAskProvider(qa.AsAsker()) + fac := testutil.NewMockFactoryWithSpaceAndPrompt(api, space1, askProvider) + rootCmd := cmdRoot.NewCmdRoot(fac, nil, askProvider) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + test.run(t, api, qa, rootCmd, stdout, stderr) + }) + } +} diff --git a/pkg/cmd/project/shared/shared.go b/pkg/cmd/project/shared/shared.go index 071fa7fd..2c96fed6 100644 --- a/pkg/cmd/project/shared/shared.go +++ b/pkg/cmd/project/shared/shared.go @@ -6,7 +6,9 @@ import ( "github.com/OctopusDeploy/cli/pkg/question" "github.com/OctopusDeploy/cli/pkg/question/selectors" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projectgroups" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" ) type CreateProjectGroupCallback func() (string, cmd.Dependable, error) @@ -45,3 +47,74 @@ func AskProjectGroups(ask question.Asker, value string, getAllGroupsCallback Get } return g.Name, nil, nil } + +// GetLifecycleMap resolves lifecycle IDs to names for display. Best-effort: a +// failed lookup yields an empty map and callers fall back to the ID. +func GetLifecycleMap(octopus *client.Client) map[string]string { + lifecycleMap := make(map[string]string) + allLifecycles, err := octopus.Lifecycles.GetAll() + if err != nil { + return lifecycleMap + } + for _, l := range allLifecycles { + lifecycleMap[l.GetID()] = l.Name + } + return lifecycleMap +} + +// GetProjectGroupMap resolves project group IDs to names for display. Best-effort, +// as GetLifecycleMap is. +func GetProjectGroupMap(octopus *client.Client) map[string]string { + projectGroupMap := make(map[string]string) + allProjectGroups, err := octopus.ProjectGroups.GetAll() + if err != nil { + return projectGroupMap + } + for _, pg := range allProjectGroups { + projectGroupMap[pg.GetID()] = pg.Name + } + return projectGroupMap +} + +// GetLifecycleName resolves a single lifecycle ID, which is cheaper than a whole +// map when only one project is being displayed. Empty when it can't be resolved. +func GetLifecycleName(octopus *client.Client, lifecycleID string) string { + if lifecycleID == "" { + return "" + } + lifecycle, err := octopus.Lifecycles.GetByID(lifecycleID) + if err != nil { + return "" + } + return lifecycle.Name +} + +// GetProjectGroupName resolves a single project group ID, as GetLifecycleName does. +func GetProjectGroupName(octopus *client.Client, projectGroupID string) string { + if projectGroupID == "" { + return "" + } + projectGroup, err := octopus.ProjectGroups.GetByID(projectGroupID) + if err != nil { + return "" + } + return projectGroup.Name +} + +// DisplayName prefers the resolved name, falling back to the ID so there is always +// something to show. +func DisplayName(id string, name string) string { + if name == "" { + return id + } + return name +} + +// TenantedDeploymentMode reports the project's mode, defaulting to Untenanted as +// the server does when the project doesn't carry one. +func TenantedDeploymentMode(project *projects.Project) string { + if project.TenantedDeploymentMode == "" { + return string(core.TenantedDeploymentModeUntenanted) + } + return string(project.TenantedDeploymentMode) +} diff --git a/pkg/cmd/project/view/view.go b/pkg/cmd/project/view/view.go index 625d67b7..7f7fd310 100644 --- a/pkg/cmd/project/view/view.go +++ b/pkg/cmd/project/view/view.go @@ -8,13 +8,16 @@ import ( "github.com/OctopusDeploy/cli/pkg/apiclient" "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/cmd/project/shared" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/factory" "github.com/OctopusDeploy/cli/pkg/output" "github.com/OctopusDeploy/cli/pkg/usage" "github.com/OctopusDeploy/cli/pkg/util" "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/actiontemplates" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" "github.com/pkg/browser" "github.com/spf13/cobra" @@ -86,76 +89,122 @@ func viewRun(opts *ViewOptions) error { return err } + // best-effort, as channel list is: viewing still works without access to either + lifecycleName := shared.GetLifecycleName(opts.Client, project.LifecycleID) + projectGroupName := shared.GetProjectGroupName(opts.Client, project.ProjectGroupID) + return output.PrintResource(project, opts.Command, output.Mappers[*projects.Project]{ Json: func(p *projects.Project) any { - cacBranch := "Not version controlled" - if p.IsVersionControlled { - cacBranch = p.PersistenceSettings.(projects.GitPersistenceSettings).DefaultBranch() - } - return ProjectAsJson{ - Id: p.GetID(), - Name: p.Name, - Slug: p.Slug, - Description: p.Description, - IsVersionControlled: p.IsVersionControlled, - VersionControlBranch: cacBranch, - ProjectTags: p.ProjectTags, - WebUrl: util.GenerateWebURL(opts.Host, p.SpaceID, fmt.Sprintf("projects/%s", p.GetID())), + Id: p.GetID(), + Name: p.Name, + Slug: p.Slug, + Description: p.Description, + IsVersionControlled: p.IsVersionControlled, + VersionControlBranch: versionControlBranch(p), + ProjectTags: p.ProjectTags, + WebUrl: webUrl(opts, p), + SpaceId: p.SpaceID, + IsDisabled: p.IsDisabled, + ProjectGroupId: p.ProjectGroupID, + ProjectGroupName: projectGroupName, + LifecycleId: p.LifecycleID, + LifecycleName: lifecycleName, + TenantedDeploymentMode: shared.TenantedDeploymentMode(p), + DeploymentProcessId: p.DeploymentProcessID, + VariableSetId: p.VariableSetID, + IncludedLibraryVariableSetIds: p.IncludedLibraryVariableSets, + ClonedFromProjectId: p.ClonedFromProjectID, + AutoCreateRelease: p.AutoCreateRelease, + DefaultGuidedFailureMode: p.DefaultGuidedFailureMode, + DefaultToSkipIfAlreadyInstalled: p.DefaultToSkipIfAlreadyInstalled, + DiscreteChannelRelease: p.IsDiscreteChannelRelease, + ReleaseNotesTemplate: p.ReleaseNotesTemplate, + VersioningStrategy: p.VersioningStrategy, + ProjectConnectivityPolicy: p.ConnectivityPolicy, + Templates: p.Templates, } }, Table: output.TableDefinition[*projects.Project]{ - Header: []string{"NAME", "SLUG", "DESCRIPTION", "VERSION CONTROL", "TAGS", "WEB URL"}, + Header: []string{"NAME", "SLUG", "PROJECT GROUP", "LIFECYCLE", "DESCRIPTION", "VERSION CONTROL", "TAGS", "WEB URL"}, Row: func(p *projects.Project) []string { description := p.Description if description == "" { description = constants.NoDescription } - cacBranch := "Not version controlled" - if p.IsVersionControlled { - cacBranch = p.PersistenceSettings.(projects.GitPersistenceSettings).DefaultBranch() - } - return []string{ output.Bold(p.Name), p.Slug, + shared.DisplayName(p.ProjectGroupID, projectGroupName), + shared.DisplayName(p.LifecycleID, lifecycleName), description, - cacBranch, + versionControlBranch(p), output.FormatAsList(p.ProjectTags), - output.Blue(util.GenerateWebURL(opts.Host, p.SpaceID, fmt.Sprintf("projects/%s", p.GetID()))), + output.Blue(webUrl(opts, p)), } }, }, Basic: func(p *projects.Project) string { - return formatProjectForBasic(opts, p) + return formatProjectForBasic(opts, p, projectGroupName, lifecycleName) }, }) } type ProjectAsJson struct { - Id string `json:"Id"` - Name string `json:"Name"` - Slug string `json:"Slug"` - Description string `json:"Description"` - IsVersionControlled bool `json:"IsVersionControlled"` - VersionControlBranch string `json:"VersionControlBranch"` - ProjectTags []string `json:"ProjectTags,omitempty"` - WebUrl string `json:"WebUrl"` + Id string `json:"Id"` + Name string `json:"Name"` + Slug string `json:"Slug"` + Description string `json:"Description"` + IsVersionControlled bool `json:"IsVersionControlled"` + VersionControlBranch string `json:"VersionControlBranch"` + ProjectTags []string `json:"ProjectTags,omitempty"` + WebUrl string `json:"WebUrl"` + SpaceId string `json:"SpaceId"` + IsDisabled bool `json:"IsDisabled"` + ProjectGroupId string `json:"ProjectGroupId"` + ProjectGroupName string `json:"ProjectGroupName,omitempty"` + LifecycleId string `json:"LifecycleId"` + LifecycleName string `json:"LifecycleName,omitempty"` + TenantedDeploymentMode string `json:"TenantedDeploymentMode"` + DeploymentProcessId string `json:"DeploymentProcessId,omitempty"` + VariableSetId string `json:"VariableSetId,omitempty"` + IncludedLibraryVariableSetIds []string `json:"IncludedLibraryVariableSetIds,omitempty"` + ClonedFromProjectId string `json:"ClonedFromProjectId,omitempty"` + AutoCreateRelease bool `json:"AutoCreateRelease"` + DefaultGuidedFailureMode string `json:"DefaultGuidedFailureMode,omitempty"` + DefaultToSkipIfAlreadyInstalled bool `json:"DefaultToSkipIfAlreadyInstalled"` + DiscreteChannelRelease bool `json:"DiscreteChannelRelease"` + ReleaseNotesTemplate string `json:"ReleaseNotesTemplate,omitempty"` + VersioningStrategy *projects.VersioningStrategy `json:"VersioningStrategy,omitempty"` + ProjectConnectivityPolicy *core.ConnectivityPolicy `json:"ProjectConnectivityPolicy,omitempty"` + Templates []actiontemplates.ActionTemplateParameter `json:"Templates,omitempty"` +} + +func versionControlBranch(project *projects.Project) string { + if !project.IsVersionControlled { + return "Not version controlled" + } + return project.PersistenceSettings.(projects.GitPersistenceSettings).DefaultBranch() } -func formatProjectForBasic(opts *ViewOptions, project *projects.Project) string { +func webUrl(opts *ViewOptions, project *projects.Project) string { + return util.GenerateWebURL(opts.Host, project.SpaceID, fmt.Sprintf("projects/%s", project.GetID())) +} + +func formatProjectForBasic(opts *ViewOptions, project *projects.Project, projectGroupName string, lifecycleName string) string { var result strings.Builder // header result.WriteString(fmt.Sprintf("%s %s\n", output.Bold(project.Name), output.Dimf("(%s)", project.Slug))) + // where the project sits and how it releases + result.WriteString(fmt.Sprintf("Project group: %s\n", output.Cyan(shared.DisplayName(project.ProjectGroupID, projectGroupName)))) + result.WriteString(fmt.Sprintf("Lifecycle: %s\n", output.Cyan(shared.DisplayName(project.LifecycleID, lifecycleName)))) + result.WriteString(fmt.Sprintf("Tenanted deployment mode: %s\n", output.Cyan(shared.TenantedDeploymentMode(project)))) + // version control branch - cacBranch := "Not version controlled" - if project.IsVersionControlled { - cacBranch = project.PersistenceSettings.(projects.GitPersistenceSettings).DefaultBranch() - } - result.WriteString(fmt.Sprintf("Version control branch: %s\n", output.Cyan(cacBranch))) + result.WriteString(fmt.Sprintf("Version control branch: %s\n", output.Cyan(versionControlBranch(project)))) // tags if len(project.ProjectTags) > 0 { @@ -169,8 +218,14 @@ func formatProjectForBasic(opts *ViewOptions, project *projects.Project) string result.WriteString(fmt.Sprintln(output.Dim(project.Description))) } + if project.IsDisabled { + result.WriteString(fmt.Sprintln("Project is disabled")) + } else { + result.WriteString(fmt.Sprintln("Project is enabled")) + } + // footer with web URL - url := util.GenerateWebURL(opts.Host, project.SpaceID, fmt.Sprintf("projects/%s", project.GetID())) + url := webUrl(opts, project) result.WriteString(fmt.Sprintf("View this project in Octopus Deploy: %s\n", output.Blue(url))) if opts.flags.Web.Value { diff --git a/pkg/cmd/project/view/view_test.go b/pkg/cmd/project/view/view_test.go new file mode 100644 index 00000000..e8cb61c8 --- /dev/null +++ b/pkg/cmd/project/view/view_test.go @@ -0,0 +1,209 @@ +package view_test + +import ( + "bytes" + "testing" + + "github.com/MakeNowJust/heredoc/v2" + cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/test/fixtures" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/lifecycles" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projectgroups" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +var rootResource = testutil.NewRootResource() + +func TestProjectView(t *testing.T) { + const spaceID = "Spaces-1" + const projectID = "Projects-22" + + space1 := fixtures.NewSpace(spaceID, "Default Space") + + lifecycle := lifecycles.NewLifecycle("Default Lifecycle") + lifecycle.ID = "Lifecycles-1" + + projectGroup := projectgroups.NewProjectGroup("Default Project Group") + projectGroup.ID = "ProjectGroups-1" + + fireProject := fixtures.NewProject(spaceID, projectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", "deploymentprocess-Projects-22") + fireProject.SpaceID = spaceID + fireProject.Slug = "fire-project" + fireProject.Description = "Fire things" + fireProject.ProjectTags = []string{"team/red"} + fireProject.VariableSetID = "variableset-Projects-22" + fireProject.IncludedLibraryVariableSets = []string{"LibraryVariableSets-1"} + + expectViewRequests := func(t *testing.T, api *testutil.MockHttpServer) { + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/lifecycles/Lifecycles-1").RespondWith(lifecycle) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projectgroups/ProjectGroups-1").RespondWith(projectGroup) + } + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"project view (table)", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"project", "view", "Projects-22", "--no-prompt", "-f", "table"}) + return rootCmd.ExecuteC() + }) + + expectViewRequests(t, api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + NAME SLUG PROJECT GROUP LIFECYCLE DESCRIPTION VERSION CONTROL TAGS WEB URL + Fire Project fire-project Default Project Group Default Lifecycle Fire things Not version controlled team/red http://server/app#/Spaces-1/projects/Projects-22 + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"project view (basic)", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"project", "view", "Projects-22", "--no-prompt", "-f", "basic"}) + return rootCmd.ExecuteC() + }) + + expectViewRequests(t, api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + Fire Project (fire-project) + Project group: Default Project Group + Lifecycle: Default Lifecycle + Tenanted deployment mode: Untenanted + Version control branch: Not version controlled + Tags: team/red + Fire things + Project is enabled + View this project in Octopus Deploy: http://server/app#/Spaces-1/projects/Projects-22 + + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"project view falls back to IDs when the lookups fail (basic)", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"project", "view", "Projects-22", "--no-prompt", "-f", "basic"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/lifecycles/Lifecycles-1").RespondWithStatus(403, "403 Forbidden", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projectgroups/ProjectGroups-1").RespondWithStatus(403, "403 Forbidden", nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + Fire Project (fire-project) + Project group: ProjectGroups-1 + Lifecycle: Lifecycles-1 + Tenanted deployment mode: Untenanted + Version control branch: Not version controlled + Tags: team/red + Fire things + Project is enabled + View this project in Octopus Deploy: http://server/app#/Spaces-1/projects/Projects-22 + + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"outputFormat json", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"project", "view", "Projects-22", "--no-prompt", "-f", "json"}) + return rootCmd.ExecuteC() + }) + + expectViewRequests(t, api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + type x struct { + Id string + Name string + Slug string + Description string + IsVersionControlled bool + VersionControlBranch string + ProjectTags []string + WebUrl string + SpaceId string + IsDisabled bool + ProjectGroupId string + ProjectGroupName string + LifecycleId string + LifecycleName string + TenantedDeploymentMode string + DeploymentProcessId string + VariableSetId string + IncludedLibraryVariableSetIds []string + AutoCreateRelease bool + DefaultToSkipIfAlreadyInstalled bool + DiscreteChannelRelease bool + VersioningStrategy *projects.VersioningStrategy + ProjectConnectivityPolicy *core.ConnectivityPolicy + } + parsedStdout, err := testutil.ParseJsonStrict[x](stdOut) + assert.Nil(t, err) + + assert.Equal(t, x{ + Id: projectID, + Name: "Fire Project", + Slug: "fire-project", + Description: "Fire things", + VersionControlBranch: "Not version controlled", + ProjectTags: []string{"team/red"}, + WebUrl: "http://server/app#/Spaces-1/projects/Projects-22", + SpaceId: spaceID, + ProjectGroupId: "ProjectGroups-1", + ProjectGroupName: "Default Project Group", + LifecycleId: "Lifecycles-1", + LifecycleName: "Default Lifecycle", + TenantedDeploymentMode: "Untenanted", + DeploymentProcessId: "deploymentprocess-Projects-22", + VariableSetId: "variableset-Projects-22", + IncludedLibraryVariableSetIds: []string{"LibraryVariableSets-1"}, + VersioningStrategy: &projects.VersioningStrategy{ + Template: "#{Octopus.Version.LastMajor}.#{Octopus.Version.LastMinor}.#{Octopus.Version.NextPatch}", + }, + ProjectConnectivityPolicy: &core.ConnectivityPolicy{}, + }, parsedStdout) + assert.Equal(t, "", stdErr.String()) + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + api, qa := testutil.NewMockServerAndAsker() + askProvider := question.NewAskProvider(qa.AsAsker()) + fac := testutil.NewMockFactoryWithSpaceAndPrompt(api, space1, askProvider) + rootCmd := cmdRoot.NewCmdRoot(fac, nil, askProvider) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + test.run(t, api, qa, rootCmd, stdout, stderr) + }) + } +}