diff --git a/pkg/dashboard/dashboard.go b/pkg/dashboard/dashboard.go new file mode 100644 index 00000000..2f4e11e9 --- /dev/null +++ b/pkg/dashboard/dashboard.go @@ -0,0 +1,69 @@ +package dashboard + +import ( + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" +) + +// Dashboard represents the deployment overview returned by the dashboard and +// dynamic dashboard endpoints: the deployments themselves in Items, plus the +// reference data needed to resolve the IDs those items carry. +type Dashboard struct { + Items []*DashboardItem `json:"Items"` + Projects []*DashboardProject `json:"Projects"` + ProjectGroups []*DashboardProjectGroup `json:"ProjectGroups"` + Environments []*DashboardEnvironment `json:"Environments"` + Tenants []*DashboardTenant `json:"Tenants"` + + // ProjectLimit is set when the server caps how many projects the dashboard + // reports on, and is nil when no cap applies. Callers showing a full list + // should say so rather than presenting capped results as complete. + ProjectLimit *int `json:"ProjectLimit"` + + // IsFiltered reports whether the server narrowed the response, either from + // the query or from the caller's permissions. + IsFiltered bool `json:"IsFiltered"` + + resources.Resource +} + +// DashboardProject is the subset of a project the dashboard returns to +// accompany its items. +type DashboardProject struct { + Name string `json:"Name,omitempty"` + Slug string `json:"Slug,omitempty"` + ProjectGroupID string `json:"ProjectGroupId,omitempty"` + EnvironmentIDs []string `json:"EnvironmentIds,omitempty"` + TenantedDeploymentMode string `json:"TenantedDeploymentMode,omitempty"` + CanPerformUntenantedDeployment bool `json:"CanPerformUntenantedDeployment"` + IsDisabled bool `json:"IsDisabled"` + + resources.Resource +} + +// DashboardEnvironment is the subset of an environment the dashboard returns to +// accompany its items. +type DashboardEnvironment struct { + Name string `json:"Name,omitempty"` + + resources.Resource +} + +// DashboardProjectGroup is the subset of a project group the dashboard returns +// to accompany its items. +type DashboardProjectGroup struct { + Name string `json:"Name,omitempty"` + EnvironmentIDs []string `json:"EnvironmentIds,omitempty"` + + resources.Resource +} + +// DashboardTenant is the subset of a tenant the dashboard returns to accompany +// its items. +type DashboardTenant struct { + Name string `json:"Name,omitempty"` + TenantTags []string `json:"TenantTags,omitempty"` + ProjectEnvironments map[string][]string `json:"ProjectEnvironments,omitempty"` + IsDisabled bool `json:"IsDisabled"` + + resources.Resource +} diff --git a/pkg/dashboard/dashboard_dynamic_query.go b/pkg/dashboard/dashboard_dynamic_query.go index accf0ba0..41fa4c13 100644 --- a/pkg/dashboard/dashboard_dynamic_query.go +++ b/pkg/dashboard/dashboard_dynamic_query.go @@ -1,7 +1,12 @@ package dashboard type DashboardDynamicQuery struct { + // Environments narrows the dashboard to these environments. The server + // matches on ID only: an environment name matches nothing and yields an + // empty dashboard rather than an error. Environments []string `uri:"environments,omitempty" url:"environments,omitempty"` IncludePrevious bool `uri:"includePrevious,omitempty" url:"includePrevious,omitempty"` - Projects []string `uri:"projects,omitempty" url:"projects,omitempty"` + // Projects narrows the dashboard to these projects. As with Environments, + // the server matches on ID only. + Projects []string `uri:"projects,omitempty" url:"projects,omitempty"` } diff --git a/pkg/dashboard/dashboard_item.go b/pkg/dashboard/dashboard_item.go index e9c7f69e..5495810b 100644 --- a/pkg/dashboard/dashboard_item.go +++ b/pkg/dashboard/dashboard_item.go @@ -1,28 +1,43 @@ package dashboard -import "time" +import ( + "time" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/interruptions" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" +) type DashboardItem struct { - ChannelID string `json:"ChannelId,omitempty"` - CompletedTime *time.Time `json:"CompletedTime,omitempty"` - Created *time.Time `json:"Created,omitempty"` - DeploymentID string `json:"DeploymentId,omitempty"` - Duration string `json:"Duration,omitempty"` - EnvironmentID string `json:"EnvironmentId,omitempty"` - ErrorMessage string `json:"ErrorMessage,omitempty"` - HasPendingInterruptions bool `json:"HasPendingInterruptions"` - HasWarningsOrErrors bool `json:"HasWarningsOrErrors"` - IsCompleted bool `json:"IsCompleted"` - IsCurrent bool `json:"IsCurrent"` - IsPrevious bool `json:"IsPrevious"` - ProjectID string `json:"ProjectId,omitempty"` - QueueTime *time.Time `json:"QueueTime,omitempty"` - ReleaseID string `json:"ReleaseId,omitempty"` - ReleaseVersion string `json:"ReleaseVersion,omitempty"` - StartTime *time.Time `json:"StartTime,omitempty"` + ChannelID string `json:"ChannelId,omitempty"` + CompletedTime *time.Time `json:"CompletedTime,omitempty"` + Created *time.Time `json:"Created,omitempty"` + DeploymentID string `json:"DeploymentId,omitempty"` + Duration string `json:"Duration,omitempty"` + EnvironmentID string `json:"EnvironmentId,omitempty"` + ErrorMessage string `json:"ErrorMessage,omitempty"` + HasPendingInterruptions bool `json:"HasPendingInterruptions"` + HasPendingPreconditions bool `json:"HasPendingPreconditions"` + HasWarningsOrErrors bool `json:"HasWarningsOrErrors"` + IsCompleted bool `json:"IsCompleted"` + IsCurrent bool `json:"IsCurrent"` + IsPrevious bool `json:"IsPrevious"` + PendingInterruptionTypes []interruptions.InterruptionType `json:"PendingInterruptionTypes,omitempty"` + // PendingPreconditionTypes is an open set of strings server side rather than + // a fixed enum, so it is not typed. + PendingPreconditionTypes []string `json:"PendingPreconditionTypes,omitempty"` + ProjectID string `json:"ProjectId,omitempty"` + QueueTime *time.Time `json:"QueueTime,omitempty"` + ReleaseID string `json:"ReleaseId,omitempty"` + ReleaseVersion string `json:"ReleaseVersion,omitempty"` + StartTime *time.Time `json:"StartTime,omitempty"` // Enum: [Canceled Cancelling Executing Failed Queued Success TimedOut] State string `json:"State,omitempty"` TaskID string `json:"TaskId,omitempty"` TenantID string `json:"TenantId,omitempty"` + + // Resource carries the item's own Id and Links, which the dashboard returns + // for each item and which callers need to reach the deployment, release and + // task without reassembling paths from the IDs above. + resources.Resource } diff --git a/pkg/dashboard/dashboard_service.go b/pkg/dashboard/dashboard_service.go index d7991b06..689aecc8 100644 --- a/pkg/dashboard/dashboard_service.go +++ b/pkg/dashboard/dashboard_service.go @@ -1,8 +1,11 @@ package dashboard import ( + "github.com/OctopusDeploy/go-octopusdeploy/v2/internal" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/constants" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/services" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/services/api" + "github.com/OctopusDeploy/go-octopusdeploy/v2/uritemplates" "github.com/dghubble/sling" ) @@ -18,3 +21,43 @@ func NewDashboardService(sling *sling.Sling, uriTemplate string, dashboardDynami Service: services.NewService(constants.ServiceDashboardService, sling, uriTemplate), } } + +// GetDynamicDashboard returns the release currently deployed to each +// environment, optionally narrowed to the given projects and environments, and +// optionally including the deployment preceding the current one. +// +// Passing a zero-valued query returns every project and environment the caller +// can see, subject to the server's project limit. +// +// The query filters on IDs, not names. The server does not reject a name, it +// simply matches nothing, so a name-filtered call returns an empty dashboard +// that is indistinguishable from nothing being deployed. +func (s *DashboardService) GetDynamicDashboard(query DashboardDynamicQuery) (*Dashboard, error) { + path, err := s.getDynamicDashboardPath(query) + if err != nil { + return nil, err + } + + response, err := api.ApiGet(s.GetClient(), new(Dashboard), path) + if err != nil { + return nil, err + } + + return response.(*Dashboard), nil +} + +// getDynamicDashboardPath expands the dynamic dashboard link template with the +// query. The dynamic dashboard has its own link rather than living under the +// service's URI template, so it is parsed here instead of via GetURITemplate. +func (s *DashboardService) getDynamicDashboardPath(query DashboardDynamicQuery) (string, error) { + if internal.IsEmpty(s.dashboardDynamicPath) { + return "", internal.CreateInvalidParameterError(constants.OperationGet, "dashboardDynamicPath") + } + + template, err := uritemplates.Parse(s.dashboardDynamicPath) + if err != nil { + return "", err + } + + return template.Expand(query) +} diff --git a/pkg/dashboard/dashboard_service_test.go b/pkg/dashboard/dashboard_service_test.go new file mode 100644 index 00000000..1a213c01 --- /dev/null +++ b/pkg/dashboard/dashboard_service_test.go @@ -0,0 +1,304 @@ +package dashboard + +import ( + "encoding/json" + "net/url" + "testing" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/internal" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/constants" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/interruptions" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/services" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createDashboardService(t *testing.T) *DashboardService { + service := NewDashboardService(nil, constants.TestURIDashboard, constants.TestURIDashboardDynamic) + services.NewServiceTests(t, service, constants.TestURIDashboard, constants.ServiceDashboardService) + return service +} + +func TestNewDashboardService(t *testing.T) { + service := NewDashboardService(nil, constants.TestURIDashboard, constants.TestURIDashboardDynamic) + require.NotNil(t, service) + require.Equal(t, constants.TestURIDashboardDynamic, service.dashboardDynamicPath) +} + +func TestDashboardServiceGetDynamicDashboardPath(t *testing.T) { + service := createDashboardService(t) + + tests := []struct { + name string + query DashboardDynamicQuery + // expected query parameters, checked individually because the template + // does not guarantee ordering + expected map[string][]string + }{ + { + name: "empty query returns the unfiltered dashboard", + query: DashboardDynamicQuery{}, + expected: map[string][]string{}, + }, + { + name: "single environment", + query: DashboardDynamicQuery{Environments: []string{"Environments-1"}}, + expected: map[string][]string{"environments": {"Environments-1"}}, + }, + { + name: "multiple projects", + query: DashboardDynamicQuery{Projects: []string{"Projects-1", "Projects-2"}}, + expected: map[string][]string{"projects": {"Projects-1,Projects-2"}}, + }, + { + name: "include previous", + query: DashboardDynamicQuery{IncludePrevious: true}, + expected: map[string][]string{"includePrevious": {"true"}}, + }, + { + // IncludePrevious is omitempty, so false must not be sent; the + // server treats its presence as opt-in. + name: "include previous false is omitted", + query: DashboardDynamicQuery{IncludePrevious: false}, + expected: map[string][]string{}, + }, + { + name: "projects and environments together", + query: DashboardDynamicQuery{ + Projects: []string{"Projects-1"}, + Environments: []string{"Environments-1", "Environments-2"}, + IncludePrevious: true, + }, + expected: map[string][]string{ + "projects": {"Projects-1"}, + "environments": {"Environments-1,Environments-2"}, + "includePrevious": {"true"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path, err := service.getDynamicDashboardPath(test.query) + require.NoError(t, err) + + parsed, err := url.Parse(path) + require.NoError(t, err) + assert.Equal(t, "/api/Spaces-1/dashboard/dynamic", parsed.Path) + assert.Equal(t, url.Values(test.expected), parsed.Query()) + }) + } +} + +func TestDashboardServiceGetDynamicDashboardPathRequiresLink(t *testing.T) { + service := NewDashboardService(nil, constants.TestURIDashboard, "") + + path, err := service.getDynamicDashboardPath(DashboardDynamicQuery{}) + require.Equal(t, internal.CreateInvalidParameterError(constants.OperationGet, "dashboardDynamicPath"), err) + require.Empty(t, path) +} + +func TestDashboardServiceGetDynamicDashboardWithoutLinkReturnsError(t *testing.T) { + service := NewDashboardService(nil, constants.TestURIDashboard, "") + + dashboard, err := service.GetDynamicDashboard(DashboardDynamicQuery{}) + require.Error(t, err) + require.Nil(t, dashboard) +} + +// TestDashboardDeserialization pins the response shape against a capture from a +// real server, so a field being renamed or re-nested shows up as a test failure +// rather than a silently empty column. +func TestDashboardDeserialization(t *testing.T) { + const payload = `{ + "Projects": [ + { + "Id": "Projects-81", + "Name": "NJ Todo List K8s", + "IsDisabled": false, + "Slug": "nj-todo-list-k8s", + "ProjectGroupId": "ProjectGroups-1", + "EnvironmentIds": ["Environments-3", "Environments-1", "Environments-2"], + "TenantedDeploymentMode": "Untenanted", + "CanPerformUntenantedDeployment": true, + "Links": { "Self": "/api/Spaces-1/projects/Projects-81" } + } + ], + "ProjectGroups": [ + { + "Id": "ProjectGroups-1", + "Name": "Default Project Group", + "EnvironmentIds": ["Environments-3", "Environments-1", "Environments-2"] + } + ], + "Environments": [ + { + "Id": "Environments-3", + "Name": "Development", + "Links": { "Self": "/api/Spaces-1/environments/Environments-3" } + }, + { "Id": "Environments-1", "Name": "Staging" }, + { "Id": "Environments-2", "Name": "Production" } + ], + "Tenants": [ + { + "Id": "Tenants-1", + "Name": "Aus-East", + "TenantTags": ["Region/Aus-East"], + "ProjectEnvironments": { "Projects-182": ["Environments-3"] }, + "IsDisabled": false + } + ], + "Items": [ + { + "Id": "Deployments-387", + "ProjectId": "Projects-81", + "EnvironmentId": "Environments-2", + "ReleaseId": "Releases-302", + "DeploymentId": "Deployments-387", + "TaskId": "ServerTasks-12388", + "TenantId": null, + "ChannelId": "Channels-103", + "ReleaseVersion": "2.16.0", + "CompletedTime": "2026-08-04T07:39:42.254+00:00", + "State": "Success", + "HasWarningsOrErrors": false, + "ErrorMessage": "", + "Duration": "59 seconds", + "IsCurrent": true, + "IsPrevious": false, + "IsCompleted": true, + "HasPendingInterruptions": false, + "HasPendingPreconditions": false, + "PendingInterruptionTypes": [], + "PendingPreconditionTypes": [], + "Links": { + "Self": "/api/Spaces-1/deployments/Deployments-387", + "Release": "/api/Spaces-1/releases/Releases-302", + "Task": "/api/tasks/ServerTasks-12388" + } + } + ], + "ProjectLimit": null, + "IsFiltered": false + }` + + dashboard := &Dashboard{} + require.NoError(t, json.Unmarshal([]byte(payload), dashboard)) + + require.Len(t, dashboard.Items, 1) + item := dashboard.Items[0] + assert.Equal(t, "Projects-81", item.ProjectID) + assert.Equal(t, "Environments-2", item.EnvironmentID) + assert.Equal(t, "Releases-302", item.ReleaseID) + assert.Equal(t, "2.16.0", item.ReleaseVersion) + assert.Equal(t, "ServerTasks-12388", item.TaskID) + assert.Equal(t, "Success", item.State) + assert.Empty(t, item.TenantID) + assert.True(t, item.IsCurrent) + assert.False(t, item.IsPrevious) + require.NotNil(t, item.CompletedTime) + assert.Equal(t, 2026, item.CompletedTime.Year()) + + // Each item carries its own Id and Links. Without them a caller cannot reach + // the deployment, release or task without rebuilding paths from the IDs. + assert.Equal(t, "Deployments-387", item.GetID()) + assert.Equal(t, "/api/Spaces-1/deployments/Deployments-387", item.Links["Self"]) + assert.Equal(t, "/api/tasks/ServerTasks-12388", item.Links["Task"]) + assert.False(t, item.HasPendingInterruptions) + assert.False(t, item.HasPendingPreconditions) + assert.Empty(t, item.PendingInterruptionTypes) + assert.Empty(t, item.PendingPreconditionTypes) + + // The reference data is what makes the item IDs printable, so check each + // lookup resolves rather than only that it parsed. + require.Len(t, dashboard.Projects, 1) + assert.Equal(t, "Projects-81", dashboard.Projects[0].GetID()) + assert.Equal(t, "NJ Todo List K8s", dashboard.Projects[0].Name) + assert.Equal(t, "ProjectGroups-1", dashboard.Projects[0].ProjectGroupID) + assert.Len(t, dashboard.Projects[0].EnvironmentIDs, 3) + assert.Equal(t, "Untenanted", dashboard.Projects[0].TenantedDeploymentMode) + + require.Len(t, dashboard.ProjectGroups, 1) + assert.Equal(t, "Default Project Group", dashboard.ProjectGroups[0].Name) + + // Environment order is the dashboard's own ordering, not alphabetical, and + // callers rely on it to print columns Development -> Staging -> Production. + require.Len(t, dashboard.Environments, 3) + assert.Equal(t, []string{"Development", "Staging", "Production"}, + []string{dashboard.Environments[0].Name, dashboard.Environments[1].Name, dashboard.Environments[2].Name}) + assert.Equal(t, "Environments-3", dashboard.Environments[0].GetID()) + assert.Equal(t, "/api/Spaces-1/environments/Environments-3", dashboard.Environments[0].Links["Self"]) + + require.Len(t, dashboard.Tenants, 1) + assert.Equal(t, "Tenants-1", dashboard.Tenants[0].GetID()) + assert.Equal(t, "Aus-East", dashboard.Tenants[0].Name) + assert.Equal(t, []string{"Region/Aus-East"}, dashboard.Tenants[0].TenantTags) + assert.Equal(t, []string{"Environments-3"}, dashboard.Tenants[0].ProjectEnvironments["Projects-182"]) + + // A null ProjectLimit means uncapped, which callers must distinguish from a + // limit of zero. + assert.Nil(t, dashboard.ProjectLimit) + assert.False(t, dashboard.IsFiltered) +} + +func TestDashboardDeserializationWithProjectLimit(t *testing.T) { + dashboard := &Dashboard{} + require.NoError(t, json.Unmarshal([]byte(`{"ProjectLimit": 50, "IsFiltered": true}`), dashboard)) + + require.NotNil(t, dashboard.ProjectLimit) + assert.Equal(t, 50, *dashboard.ProjectLimit) + assert.True(t, dashboard.IsFiltered) +} + +func TestDashboardDeserializationTenantedItem(t *testing.T) { + dashboard := &Dashboard{} + require.NoError(t, json.Unmarshal([]byte(`{ + "Items": [ + { + "ProjectId": "Projects-182", + "EnvironmentId": "Environments-3", + "TenantId": "Tenants-1", + "ReleaseVersion": "1.0.0", + "State": "Success", + "IsCurrent": true + } + ] + }`), dashboard)) + + require.Len(t, dashboard.Items, 1) + assert.Equal(t, "Tenants-1", dashboard.Items[0].TenantID) + assert.Equal(t, "1.0.0", dashboard.Items[0].ReleaseVersion) +} + +// TestDashboardDeserializationPendingTypes covers an item waiting on a manual +// intervention or an approval. Both collections are empty on a healthy +// dashboard, so this is the only coverage they get. +func TestDashboardDeserializationPendingTypes(t *testing.T) { + dashboard := &Dashboard{} + require.NoError(t, json.Unmarshal([]byte(`{ + "Items": [ + { + "ProjectId": "Projects-1", + "EnvironmentId": "Environments-1", + "State": "Queued", + "HasPendingInterruptions": true, + "PendingInterruptionTypes": ["ManualIntervention", "GuidedFailure"], + "HasPendingPreconditions": true, + "PendingPreconditionTypes": ["Approval"] + } + ] + }`), dashboard)) + + require.Len(t, dashboard.Items, 1) + item := dashboard.Items[0] + + assert.True(t, item.HasPendingInterruptions) + assert.Equal(t, []interruptions.InterruptionType{ + interruptions.InterruptionTypeManualIntervention, + interruptions.InterruptionTypeGuidedFailure, + }, item.PendingInterruptionTypes) + + assert.True(t, item.HasPendingPreconditions) + assert.Equal(t, []string{"Approval"}, item.PendingPreconditionTypes) +} diff --git a/pkg/interruptions/interruption_type.go b/pkg/interruptions/interruption_type.go new file mode 100644 index 00000000..b4fd06f4 --- /dev/null +++ b/pkg/interruptions/interruption_type.go @@ -0,0 +1,11 @@ +package interruptions + +type InterruptionType string + +const ( + InterruptionTypeManualIntervention = InterruptionType("ManualIntervention") + InterruptionTypeGuidedFailure = InterruptionType("GuidedFailure") + InterruptionTypePullRequestCompletion = InterruptionType("PullRequestCompletion") + InterruptionTypeArgoCDApplicationSync = InterruptionType("ArgoCDApplicationSync") + InterruptionTypeKubernetesResourceVerification = InterruptionType("KubernetesResourceVerification") +) diff --git a/test/e2e/dashboard_service_test.go b/test/e2e/dashboard_service_test.go new file mode 100644 index 00000000..48e44a22 --- /dev/null +++ b/test/e2e/dashboard_service_test.go @@ -0,0 +1,207 @@ +package e2e + +import ( + "encoding/json" + "net/http" + "net/url" + "testing" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/dashboard" + "github.com/stretchr/testify/require" +) + +// These tests read whatever the target instance happens to hold rather than +// creating a deployment of their own, so anything needing an actual deployment +// skips on an instance that has none. +func skipWithoutItems(t *testing.T, board *dashboard.Dashboard) { + t.Helper() + + if len(board.Items) == 0 { + t.Skip("the dashboard is empty; this instance has no deployments to assert on") + } +} + +// projectWithItems returns a project ID that has at least one dashboard item, +// along with its name, so these tests discover their fixtures from the server +// rather than hardcoding IDs that only exist on one instance. +func projectWithItems(t *testing.T, board *dashboard.Dashboard) (string, string) { + t.Helper() + + for _, item := range board.Items { + for _, project := range board.Projects { + if project.GetID() == item.ProjectID { + return project.GetID(), project.Name + } + } + } + + t.Skip("no project on the dashboard has a deployment; cannot exercise filtering") + return "", "" +} + +func TestDashboardGetDynamicDashboardUnfiltered(t *testing.T) { + client := getOctopusClient() + require.NotNil(t, client) + + board, err := client.Dashboards.GetDynamicDashboard(dashboard.DashboardDynamicQuery{}) + require.NoError(t, err) + require.NotNil(t, board) + require.False(t, board.IsFiltered) + skipWithoutItems(t, board) + + // Every item must be resolvable through the reference data, otherwise a + // caller cannot render it. + projectIDs := map[string]bool{} + for _, project := range board.Projects { + projectIDs[project.GetID()] = true + } + environmentIDs := map[string]bool{} + for _, environment := range board.Environments { + environmentIDs[environment.GetID()] = true + } + + for _, item := range board.Items { + require.True(t, projectIDs[item.ProjectID], "item project %s missing from reference data", item.ProjectID) + require.True(t, environmentIDs[item.EnvironmentID], "item environment %s missing from reference data", item.EnvironmentID) + + // Id and Links are returned per item and are what callers navigate by. + require.NotEmpty(t, item.GetID()) + require.NotEmpty(t, item.Links) + require.NotEmpty(t, item.Links["Self"]) + } +} + +func TestDashboardGetDynamicDashboardFiltersByProjectID(t *testing.T) { + client := getOctopusClient() + require.NotNil(t, client) + + board, err := client.Dashboards.GetDynamicDashboard(dashboard.DashboardDynamicQuery{}) + require.NoError(t, err) + projectID, _ := projectWithItems(t, board) + + filtered, err := client.Dashboards.GetDynamicDashboard(dashboard.DashboardDynamicQuery{ + Projects: []string{projectID}, + }) + require.NoError(t, err) + require.True(t, filtered.IsFiltered) + require.NotEmpty(t, filtered.Items) + + for _, item := range filtered.Items { + require.Equal(t, projectID, item.ProjectID) + } + require.LessOrEqual(t, len(filtered.Items), len(board.Items)) +} + +// TestDashboardGetDynamicDashboardIgnoresProjectName pins the behaviour the +// query documents: the server matches IDs only, and a name yields an empty +// dashboard rather than an error. If a future server starts accepting names, +// this fails and the documentation needs revisiting. +func TestDashboardGetDynamicDashboardIgnoresProjectName(t *testing.T) { + client := getOctopusClient() + require.NotNil(t, client) + + board, err := client.Dashboards.GetDynamicDashboard(dashboard.DashboardDynamicQuery{}) + require.NoError(t, err) + _, projectName := projectWithItems(t, board) + + byName, err := client.Dashboards.GetDynamicDashboard(dashboard.DashboardDynamicQuery{ + Projects: []string{projectName}, + }) + require.NoError(t, err) + require.True(t, byName.IsFiltered) + require.Empty(t, byName.Items, "a project name matched items; the server contract may have changed") +} + +func TestDashboardGetDynamicDashboardUnknownIDReturnsEmpty(t *testing.T) { + client := getOctopusClient() + require.NotNil(t, client) + + board, err := client.Dashboards.GetDynamicDashboard(dashboard.DashboardDynamicQuery{ + Projects: []string{"Projects-999999999"}, + }) + require.NoError(t, err) + require.True(t, board.IsFiltered) + require.Empty(t, board.Items) +} + +func TestDashboardGetDynamicDashboardIncludePrevious(t *testing.T) { + client := getOctopusClient() + require.NotNil(t, client) + + current, err := client.Dashboards.GetDynamicDashboard(dashboard.DashboardDynamicQuery{}) + require.NoError(t, err) + + withPrevious, err := client.Dashboards.GetDynamicDashboard(dashboard.DashboardDynamicQuery{ + IncludePrevious: true, + }) + require.NoError(t, err) + + // Previous deployments arrive inside Items flagged with IsPrevious, not in a + // separate collection. + require.GreaterOrEqual(t, len(withPrevious.Items), len(current.Items)) + for _, item := range current.Items { + require.False(t, item.IsPrevious) + } + if len(withPrevious.Items) > len(current.Items) { + previous := 0 + for _, item := range withPrevious.Items { + if item.IsPrevious { + previous++ + } + } + require.NotZero(t, previous) + } +} + +// TestDashboardDynamicDashboardModelsEveryServerField decodes the live response +// with unknown fields disallowed, so a field the server adds or renames fails +// here instead of silently arriving as a zero value. +func TestDashboardDynamicDashboardModelsEveryServerField(t *testing.T) { + client := getOctopusClient() + require.NotNil(t, client) + + path, err := url.Parse("/api/" + client.GetSpaceID() + "/dashboard/dynamic?includePrevious=true") + require.NoError(t, err) + + response, err := client.HttpSession().DoRawRequest(&http.Request{ + Method: http.MethodGet, + URL: path, + Header: make(http.Header), + }) + require.NoError(t, err) + defer response.Body.Close() + require.Equal(t, http.StatusOK, response.StatusCode) + + decoder := json.NewDecoder(response.Body) + decoder.DisallowUnknownFields() + + board := &dashboard.Dashboard{} + require.NoError(t, decoder.Decode(board), "the server returned a field the SDK does not model") + + // The decode above is the assertion. On an instance with no deployments it + // only covers the envelope and whatever reference data exists, so log the + // coverage rather than failing. + if len(board.Items) == 0 { + t.Log("dashboard is empty; item fields were not covered by this decode") + } +} + +func TestDashboardGetDynamicDashboardFiltersByEnvironmentID(t *testing.T) { + client := getOctopusClient() + require.NotNil(t, client) + + board, err := client.Dashboards.GetDynamicDashboard(dashboard.DashboardDynamicQuery{}) + require.NoError(t, err) + skipWithoutItems(t, board) + + environmentID := board.Items[0].EnvironmentID + filtered, err := client.Dashboards.GetDynamicDashboard(dashboard.DashboardDynamicQuery{ + Environments: []string{environmentID}, + }) + require.NoError(t, err) + require.True(t, filtered.IsFiltered) + + for _, item := range filtered.Items { + require.Equal(t, environmentID, item.EnvironmentID) + } +}