From 2f927782ae66de1c1fdbb75a74c38b230c7509fe Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 20 Mar 2026 19:08:33 +0000 Subject: [PATCH 1/8] test: add integration tests for decode, url, and relationships Phase 1 of integration test expansion. These commands need no mock API changes: - decode: base64+JSON decoding, property extraction, invalid input - environment:url: --pipe and --primary flags, both API and local modes - environment:relationships: local mode via PLATFORM_RELATIONSHIPS env var, property extraction with fully-qualified paths Co-Authored-By: Claude Opus 4.6 (1M context) --- integration-tests/decode_test.go | 28 +++++++ .../environment_relationships_test.go | 57 ++++++++++++++ integration-tests/environment_url_test.go | 74 +++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 integration-tests/decode_test.go create mode 100644 integration-tests/environment_relationships_test.go create mode 100644 integration-tests/environment_url_test.go diff --git a/integration-tests/decode_test.go b/integration-tests/decode_test.go new file mode 100644 index 00000000..25f78ea4 --- /dev/null +++ b/integration-tests/decode_test.go @@ -0,0 +1,28 @@ +package tests + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDecode(t *testing.T) { + f := &cmdFactory{t: t} + + // Simple base64-encoded JSON. + assertTrimmed(t, `{ + "foo": "bar" +}`, f.Run("decode", "eyJmb28iOiAiYmFyIn0=")) + + // Property extraction. + assertTrimmed(t, "bar", f.Run("decode", "eyJmb28iOiAiYmFyIn0=", "-P", "foo")) + + // Nested property extraction. + input := "eyJhIjogeyJiIjogImMifX0=" // {"a": {"b": "c"}} + assertTrimmed(t, "c", f.Run("decode", input, "-P", "a.b")) + + // Invalid base64 input. + _, stdErr, err := f.RunCombinedOutput("decode", "not-valid-base64!") + assert.Error(t, err) + assert.Contains(t, stdErr, "Invalid value") +} diff --git a/integration-tests/environment_relationships_test.go b/integration-tests/environment_relationships_test.go new file mode 100644 index 00000000..92e11002 --- /dev/null +++ b/integration-tests/environment_relationships_test.go @@ -0,0 +1,57 @@ +package tests + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRelationshipsLocal(t *testing.T) { + relationships := map[string]any{ + "database": []any{ + map[string]any{ + "service": "db", + "host": "database.internal", + "rel": "mysql", + "scheme": "mysql", + "username": "user", + "password": "", + "path": "main", + "port": 3306, + "type": "mysql:10.6", + }, + }, + "redis": []any{ + map[string]any{ + "service": "cache", + "host": "redis.internal", + "rel": "redis", + "scheme": "redis", + "username": "", + "password": "", + "path": "", + "port": 6379, + "type": "redis:7.0", + }, + }, + } + + data, err := json.Marshal(relationships) + require.NoError(t, err) + + f := &cmdFactory{t: t} + f.extraEnv = []string{"PLATFORM_RELATIONSHIPS=" + base64.StdEncoding.EncodeToString(data)} + + // List all relationships. + output := f.Run("environment:relationships") + assert.Contains(t, output, "database") + assert.Contains(t, output, "redis") + + // Extract a specific property (fully-qualified path: relationship.index.key). + assertTrimmed(t, "database.internal", f.Run("environment:relationships", "-P", "database.0.host")) + assertTrimmed(t, "redis.internal", f.Run("environment:relationships", "-P", "redis.0.host")) + assertTrimmed(t, "3306", f.Run("environment:relationships", "-P", "database.0.port")) +} diff --git a/integration-tests/environment_url_test.go b/integration-tests/environment_url_test.go new file mode 100644 index 00000000..6a8ba235 --- /dev/null +++ b/integration-tests/environment_url_test.go @@ -0,0 +1,74 @@ +package tests + +import ( + "encoding/base64" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/pkg/mockapi" +) + +func TestEnvironmentURL(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiHandler := mockapi.NewHandler(t) + + projectID := mockapi.ProjectID() + apiHandler.SetProjects([]*mockapi.Project{{ + ID: projectID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + ), + DefaultBranch: "main", + }}) + + main := makeEnv(projectID, "main", "production", "active", nil) + main.SetCurrentDeployment(&mockapi.Deployment{ + WebApps: map[string]mockapi.App{ + "app": {Name: "app", Type: "golang:1.23", Size: "M", Disk: 2048, Mounts: map[string]mockapi.Mount{}}, + }, + Routes: mockRoutes(), + Links: mockapi.MakeHALLinks("self=/projects/" + projectID + "/environments/main/deployment/current"), + }) + apiHandler.SetEnvironments([]*mockapi.Environment{main}) + + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + + // --pipe lists all URLs. + output := f.Run("environment:url", "-p", projectID, "-e", ".", "--pipe") + assert.Contains(t, output, "https://main.example.com/") + assert.Contains(t, output, "http://main.example.com/") + + // --primary returns only the primary route URL (the upstream, not redirect). + output = f.Run("environment:url", "-p", projectID, "-e", ".", "--primary", "--pipe") + assert.Contains(t, output, "main.example.com/") + // Only one URL should be returned. + assert.Equal(t, 1, len(strings.Split(strings.TrimSpace(output), "\n"))) +} + +func TestEnvironmentURLLocal(t *testing.T) { + f := &cmdFactory{t: t} + routes, err := json.Marshal(mockRoutes()) + require.NoError(t, err) + f.extraEnv = []string{"PLATFORM_ROUTES=" + base64.StdEncoding.EncodeToString(routes)} + + // --pipe lists all URLs. + output := f.Run("environment:url", "--pipe") + assert.Contains(t, output, "https://main.example.com/") + assert.Contains(t, output, "http://main.example.com/") + + // --primary returns only the primary route URL. + output = f.Run("environment:url", "--primary", "--pipe") + assert.Contains(t, output, "main.example.com/") + assert.Equal(t, 1, len(strings.Split(strings.TrimSpace(output), "\n"))) +} From ed7f2a2d2f016947710ae882ccf485843a2f1446 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 20 Mar 2026 19:16:49 +0000 Subject: [PATCH 2/8] test: add integration tests for variable:delete, activate, pause, resume, redeploy Phase 2 of integration test expansion. Mock API changes: - Add DELETE handlers for project and env-level variables - Add POST handlers for activate, pause, resume, redeploy on environments - Add stub endpoints for /projects/{id}/settings and /projects/{id}/capabilities Tests cover: - variable:delete: project-level delete with verification, env-level delete - environment:activate: activate inactive env, already-active detection - environment:pause/resume: pause active env, resume paused env - environment:redeploy: redeploy with HAL link, error without HAL link Co-Authored-By: Claude Opus 4.6 (1M context) --- .../environment_activate_test.go | 47 +++++++++++ integration-tests/environment_pause_test.go | 75 +++++++++++++++++ .../environment_redeploy_test.go | 51 ++++++++++++ integration-tests/variable_delete_test.go | 82 +++++++++++++++++++ pkg/mockapi/api_server.go | 12 +++ pkg/mockapi/environments.go | 44 ++++++++++ pkg/mockapi/variables.go | 40 +++++++++ 7 files changed, 351 insertions(+) create mode 100644 integration-tests/environment_activate_test.go create mode 100644 integration-tests/environment_pause_test.go create mode 100644 integration-tests/environment_redeploy_test.go create mode 100644 integration-tests/variable_delete_test.go diff --git a/integration-tests/environment_activate_test.go b/integration-tests/environment_activate_test.go new file mode 100644 index 00000000..fea6ea80 --- /dev/null +++ b/integration-tests/environment_activate_test.go @@ -0,0 +1,47 @@ +package tests + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/upsun/cli/pkg/mockapi" +) + +func TestEnvironmentActivate(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiHandler := mockapi.NewHandler(t) + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + projectID := mockapi.ProjectID() + apiHandler.SetProjects([]*mockapi.Project{{ + ID: projectID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + ), + DefaultBranch: "main", + }}) + + main := makeEnv(projectID, "main", "production", "active", nil) + inactive := makeEnv(projectID, "staging", "staging", "inactive", "main") + inactive.Links["#activate"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/staging/activate"} + apiHandler.SetEnvironments([]*mockapi.Environment{main, inactive}) + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.Run("cc") + + // Activate an inactive environment. + _, stdErr, err := f.RunCombinedOutput("environment:activate", "-p", projectID, "-e", "staging", "--no-wait") + assert.NoError(t, err) + assert.Contains(t, stdErr, "Activating environment") + + // Activating an already-active environment (no #activate link) reports it. + _, stdErr, err = f.RunCombinedOutput("environment:activate", "-p", projectID, "-e", "main") + assert.NoError(t, err) + assert.Contains(t, stdErr, "already active") +} diff --git a/integration-tests/environment_pause_test.go b/integration-tests/environment_pause_test.go new file mode 100644 index 00000000..51f7e490 --- /dev/null +++ b/integration-tests/environment_pause_test.go @@ -0,0 +1,75 @@ +package tests + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/upsun/cli/pkg/mockapi" +) + +func TestEnvironmentPause(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiHandler := mockapi.NewHandler(t) + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + projectID := mockapi.ProjectID() + apiHandler.SetProjects([]*mockapi.Project{{ + ID: projectID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + ), + DefaultBranch: "main", + }}) + + main := makeEnv(projectID, "main", "production", "active", nil) + main.Links["#pause"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/pause"} + apiHandler.SetEnvironments([]*mockapi.Environment{main}) + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.Run("cc") + + // Pause an active environment. + stdOut, stdErr, err := f.RunCombinedOutput("environment:pause", "-p", projectID, "-e", ".", "--no-wait") + assert.NoError(t, err) + // The CLI outputs confirmation and pause messages on stderr. + combined := stdOut + stdErr + assert.Contains(t, combined, "pause") +} + +func TestEnvironmentResume(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiHandler := mockapi.NewHandler(t) + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + projectID := mockapi.ProjectID() + apiHandler.SetProjects([]*mockapi.Project{{ + ID: projectID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + ), + DefaultBranch: "main", + }}) + + main := makeEnv(projectID, "main", "production", "paused", nil) + main.Links["#resume"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/resume"} + apiHandler.SetEnvironments([]*mockapi.Environment{main}) + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.Run("cc") + + // Resume a paused environment. + stdOut, stdErr, err := f.RunCombinedOutput("environment:resume", "-p", projectID, "-e", ".", "--no-wait") + assert.NoError(t, err) + combined := stdOut + stdErr + assert.Contains(t, combined, "resum") +} diff --git a/integration-tests/environment_redeploy_test.go b/integration-tests/environment_redeploy_test.go new file mode 100644 index 00000000..36edb2bc --- /dev/null +++ b/integration-tests/environment_redeploy_test.go @@ -0,0 +1,51 @@ +package tests + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/upsun/cli/pkg/mockapi" +) + +func TestEnvironmentRedeploy(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiHandler := mockapi.NewHandler(t) + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + projectID := mockapi.ProjectID() + apiHandler.SetProjects([]*mockapi.Project{{ + ID: projectID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + ), + DefaultBranch: "main", + }}) + + main := makeEnv(projectID, "main", "production", "active", nil) + main.Links["#redeploy"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/redeploy"} + apiHandler.SetEnvironments([]*mockapi.Environment{main}) + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.Run("cc") + + // Redeploy an active environment. + stdOut, stdErr, err := f.RunCombinedOutput("redeploy", "-p", projectID, "-e", ".", "--no-wait") + assert.NoError(t, err) + combined := stdOut + stdErr + assert.Contains(t, combined, "redeploy") + + // Remove the #redeploy link and verify the error. + noRedeployEnv := makeEnv(projectID, "main", "production", "active", nil) + apiHandler.SetEnvironments([]*mockapi.Environment{noRedeployEnv}) + f.Run("cc") + + _, stdErr, err = f.RunCombinedOutput("redeploy", "-p", projectID, "-e", ".") + assert.Error(t, err) + assert.Contains(t, stdErr, "redeploy") +} diff --git a/integration-tests/variable_delete_test.go b/integration-tests/variable_delete_test.go new file mode 100644 index 00000000..b8241d65 --- /dev/null +++ b/integration-tests/variable_delete_test.go @@ -0,0 +1,82 @@ +package tests + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/upsun/cli/pkg/mockapi" +) + +func TestVariableDelete(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiHandler := mockapi.NewHandler(t) + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + projectID := mockapi.ProjectID() + + apiHandler.SetProjects([]*mockapi.Project{{ + ID: projectID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + ), + DefaultBranch: "main", + }}) + main := makeEnv(projectID, "main", "production", "active", nil) + main.Links["#variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"} + main.Links["#manage-variables"] = mockapi.HALLink{HREF: "/projects/" + projectID + "/environments/main/variables"} + apiHandler.SetEnvironments([]*mockapi.Environment{main}) + + apiHandler.SetProjectVariables(projectID, []*mockapi.Variable{ + { + Name: "to_delete", + Value: "val1", + VisibleBuild: true, + VisibleRuntime: true, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID+"/variables/to_delete", + "#edit=/projects/"+projectID+"/variables/to_delete", + "#delete=/projects/"+projectID+"/variables/to_delete", + ), + }, + }) + + apiHandler.SetEnvLevelVariables(projectID, "main", []*mockapi.EnvLevelVariable{ + { + Variable: mockapi.Variable{ + Name: "env:TO_DELETE", + Value: "envval", + VisibleRuntime: true, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID+"/environments/main/variables/env:TO_DELETE", + "#edit=/projects/"+projectID+"/environments/main/variables/env:TO_DELETE", + "#delete=/projects/"+projectID+"/environments/main/variables/env:TO_DELETE", + ), + }, + IsEnabled: true, + IsInheritable: false, + }, + }) + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.Run("cc") + + // Delete a project-level variable (use -y to confirm). + _, stdErr, err := f.RunCombinedOutput("var:delete", "-p", projectID, "-l", "p", "-y", "to_delete") + assert.NoError(t, err) + assert.Contains(t, stdErr, "Deleted variable to_delete") + + // Verify it is gone from list. + stdOut, stdErr, _ := f.RunCombinedOutput("var", "-p", projectID, "-l", "p") + assert.NotContains(t, stdOut+stdErr, "to_delete") + + // Delete an env-level variable. + _, stdErr, err = f.RunCombinedOutput("var:delete", "-p", projectID, "-e", "main", "-l", "e", "-y", "env:TO_DELETE") + assert.NoError(t, err) + assert.Contains(t, stdErr, "Deleted variable env:TO_DELETE") +} diff --git a/pkg/mockapi/api_server.go b/pkg/mockapi/api_server.go index a054f1c2..c214e4af 100644 --- a/pkg/mockapi/api_server.go +++ b/pkg/mockapi/api_server.go @@ -66,6 +66,12 @@ func NewHandler(t *testing.T) *Handler { _ = json.NewEncoder(w).Encode(map[string]any{"total": "$1,000 USD"}) }) + h.Get("/projects/{project_id}/settings", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{}) + }) + h.Get("/projects/{project_id}/capabilities", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{}) + }) h.Get("/projects/{project_id}", h.handleGetProject) h.Patch("/projects/{project_id}", h.handlePatchProject) h.Get("/projects/{project_id}/environments", h.handleListEnvironments) @@ -74,6 +80,10 @@ func NewHandler(t *testing.T) *Handler { h.Get("/projects/{project_id}/environments/{environment_id}/settings", h.handleGetEnvironmentSettings) h.Patch("/projects/{project_id}/environments/{environment_id}/settings", h.handleSetEnvironmentSettings) h.Post("/projects/{project_id}/environments/{environment_id}/deploy", h.handleDeployEnvironment) + h.Post("/projects/{project_id}/environments/{environment_id}/activate", h.handleActivateEnvironment) + h.Post("/projects/{project_id}/environments/{environment_id}/pause", h.handlePauseEnvironment) + h.Post("/projects/{project_id}/environments/{environment_id}/resume", h.handleResumeEnvironment) + h.Post("/projects/{project_id}/environments/{environment_id}/redeploy", h.handleRedeployEnvironment) h.Get("/projects/{project_id}/environments/{environment_id}/backups", h.handleListBackups) h.Post("/projects/{project_id}/environments/{environment_id}/backups", h.handleCreateBackup) h.Get("/projects/{project_id}/environments/{environment_id}/deployments/current", h.handleGetCurrentDeployment) @@ -91,10 +101,12 @@ func NewHandler(t *testing.T) *Handler { h.Post("/projects/{project_id}/variables", h.handleCreateProjectVariable) h.Get("/projects/{project_id}/variables/{name}", h.handleGetProjectVariable) h.Patch("/projects/{project_id}/variables/{name}", h.handlePatchProjectVariable) + h.Delete("/projects/{project_id}/variables/{name}", h.handleDeleteProjectVariable) h.Get("/projects/{project_id}/environments/{environment_id}/variables", h.handleListEnvLevelVariables) h.Post("/projects/{project_id}/environments/{environment_id}/variables", h.handleCreateEnvLevelVariable) h.Get("/projects/{project_id}/environments/{environment_id}/variables/{name}", h.handleGetEnvLevelVariable) h.Patch("/projects/{project_id}/environments/{environment_id}/variables/{name}", h.handlePatchEnvLevelVariable) + h.Delete("/projects/{project_id}/environments/{environment_id}/variables/{name}", h.handleDeleteEnvLevelVariable) return h } diff --git a/pkg/mockapi/environments.go b/pkg/mockapi/environments.go index 067c82ac..f26795f7 100644 --- a/pkg/mockapi/environments.go +++ b/pkg/mockapi/environments.go @@ -123,6 +123,50 @@ func (h *Handler) handleDeployEnvironment(w http.ResponseWriter, req *http.Reque }) } +func (h *Handler) handleActivateEnvironment(w http.ResponseWriter, req *http.Request) { + env := h.findEnvironment(chi.URLParam(req, "project_id"), chi.URLParam(req, "environment_id")) + if env == nil { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "_embedded": map[string]any{"activities": []Activity{}}, + }) +} + +func (h *Handler) handlePauseEnvironment(w http.ResponseWriter, req *http.Request) { + env := h.findEnvironment(chi.URLParam(req, "project_id"), chi.URLParam(req, "environment_id")) + if env == nil { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "_embedded": map[string]any{"activities": []Activity{}}, + }) +} + +func (h *Handler) handleResumeEnvironment(w http.ResponseWriter, req *http.Request) { + env := h.findEnvironment(chi.URLParam(req, "project_id"), chi.URLParam(req, "environment_id")) + if env == nil { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "_embedded": map[string]any{"activities": []Activity{}}, + }) +} + +func (h *Handler) handleRedeployEnvironment(w http.ResponseWriter, req *http.Request) { + env := h.findEnvironment(chi.URLParam(req, "project_id"), chi.URLParam(req, "environment_id")) + if env == nil { + w.WriteHeader(http.StatusNotFound) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "_embedded": map[string]any{"activities": []Activity{}}, + }) +} + func (h *Handler) handleGetCurrentDeployment(w http.ResponseWriter, req *http.Request) { h.RLock() defer h.RUnlock() diff --git a/pkg/mockapi/variables.go b/pkg/mockapi/variables.go index e496e0af..a76e4c97 100644 --- a/pkg/mockapi/variables.go +++ b/pkg/mockapi/variables.go @@ -10,6 +10,13 @@ import ( "github.com/go-chi/chi/v5" ) +// activityResponse returns a standard activity-embedded response. +func activityResponse() map[string]any { + return map[string]any{ + "_embedded": map[string]any{"activities": []Activity{}}, + } +} + func (h *Handler) handleListProjectVariables(w http.ResponseWriter, req *http.Request) { h.RLock() defer h.RUnlock() @@ -69,6 +76,21 @@ func (h *Handler) handleCreateProjectVariable(w http.ResponseWriter, req *http.R }) } +func (h *Handler) handleDeleteProjectVariable(w http.ResponseWriter, req *http.Request) { + h.Lock() + defer h.Unlock() + projectID := chi.URLParam(req, "project_id") + variableName, _ := url.PathUnescape(chi.URLParam(req, "name")) + for k, v := range h.projectVariables[projectID] { + if v.Name == variableName { + h.projectVariables[projectID] = slices.Delete(h.projectVariables[projectID], k, k+1) + w.WriteHeader(http.StatusOK) + return + } + } + w.WriteHeader(http.StatusNotFound) +} + func (h *Handler) handlePatchProjectVariable(w http.ResponseWriter, req *http.Request) { h.Lock() defer h.Unlock() @@ -190,3 +212,21 @@ func (h *Handler) handlePatchEnvLevelVariable(w http.ResponseWriter, req *http.R h.envLevelVariables[projectID][environmentID][key] = &patched _ = json.NewEncoder(w).Encode(&patched) } + +func (h *Handler) handleDeleteEnvLevelVariable(w http.ResponseWriter, req *http.Request) { + h.Lock() + defer h.Unlock() + projectID := chi.URLParam(req, "project_id") + environmentID, _ := url.PathUnescape(chi.URLParam(req, "environment_id")) + variableName, _ := url.PathUnescape(chi.URLParam(req, "name")) + for k, v := range h.envLevelVariables[projectID][environmentID] { + if v.Name == variableName { + h.envLevelVariables[projectID][environmentID] = slices.Delete( + h.envLevelVariables[projectID][environmentID], k, k+1, + ) + _ = json.NewEncoder(w).Encode(activityResponse()) + return + } + } + w.WriteHeader(http.StatusNotFound) +} From d0e5bf61cbc45098812b931993c2c3f142719677 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 20 Mar 2026 19:23:12 +0000 Subject: [PATCH 3/8] test: add integration tests for domains, integrations, and certificates Phase 3 of integration test expansion. New mock API components: - Domain, Integration, Certificate models in model.go - Store setters for each new resource type - Handler files: domains.go, integrations.go, certificates.go - GET list/get routes registered in api_server.go Tests cover: - domain:list/get: table output, property extraction, empty list - integration:list/get: multiple types (github, webhook), property extraction - certificate:list/get: cert details, property extraction, empty list Co-Authored-By: Claude Opus 4.6 (1M context) --- integration-tests/certificate_test.go | 91 ++++++++++++++++++++++ integration-tests/domain_test.go | 105 ++++++++++++++++++++++++++ integration-tests/integration_test.go | 74 ++++++++++++++++++ pkg/mockapi/api_server.go | 6 ++ pkg/mockapi/certificates.go | 33 ++++++++ pkg/mockapi/domains.go | 34 +++++++++ pkg/mockapi/integrations.go | 33 ++++++++ pkg/mockapi/model.go | 50 ++++++++++++ pkg/mockapi/store.go | 31 ++++++++ 9 files changed, 457 insertions(+) create mode 100644 integration-tests/certificate_test.go create mode 100644 integration-tests/domain_test.go create mode 100644 integration-tests/integration_test.go create mode 100644 pkg/mockapi/certificates.go create mode 100644 pkg/mockapi/domains.go create mode 100644 pkg/mockapi/integrations.go diff --git a/integration-tests/certificate_test.go b/integration-tests/certificate_test.go new file mode 100644 index 00000000..a310d0ee --- /dev/null +++ b/integration-tests/certificate_test.go @@ -0,0 +1,91 @@ +package tests + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/upsun/cli/pkg/mockapi" +) + +func TestCertificateList(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiHandler := mockapi.NewHandler(t) + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + projectID := mockapi.ProjectID() + apiHandler.SetProjects([]*mockapi.Project{{ + ID: projectID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + ), + DefaultBranch: "main", + }}) + + main := makeEnv(projectID, "main", "production", "active", nil) + apiHandler.SetEnvironments([]*mockapi.Environment{main}) + + created, _ := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z") + expires, _ := time.Parse(time.RFC3339, "2027-01-01T00:00:00Z") + apiHandler.SetProjectCertificates(projectID, []*mockapi.Certificate{ + { + ID: "cert1", + Domains: []string{"example.com", "www.example.com"}, + Issuer: "Custom CA", + IsProvisioned: false, + ExpiresAt: expires, + CreatedAt: created, + UpdatedAt: created, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID+"/certificates/cert1", + ), + }, + }) + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.Run("cc") + + // List certificates. + output := f.Run("certificates", "-p", projectID) + assert.Contains(t, output, "cert1") + assert.Contains(t, output, "example.com") + + // Get a specific certificate property. + assertTrimmed(t, "Custom CA", f.Run("certificate:get", "-p", projectID, "cert1", "-P", "issuer")) +} + +func TestCertificateListEmpty(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiHandler := mockapi.NewHandler(t) + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + projectID := mockapi.ProjectID() + apiHandler.SetProjects([]*mockapi.Project{{ + ID: projectID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + ), + DefaultBranch: "main", + }}) + + main := makeEnv(projectID, "main", "production", "active", nil) + apiHandler.SetEnvironments([]*mockapi.Environment{main}) + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.Run("cc") + + // Empty certificate list. + _, stdErr, err := f.RunCombinedOutput("certificates", "-p", projectID) + assert.NoError(t, err) + assert.Contains(t, stdErr, "No certificates found") +} diff --git a/integration-tests/domain_test.go b/integration-tests/domain_test.go new file mode 100644 index 00000000..029633f1 --- /dev/null +++ b/integration-tests/domain_test.go @@ -0,0 +1,105 @@ +package tests + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/upsun/cli/pkg/mockapi" +) + +func TestDomainList(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiHandler := mockapi.NewHandler(t) + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + projectID := mockapi.ProjectID() + apiHandler.SetProjects([]*mockapi.Project{{ + ID: projectID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + "domains=/projects/"+projectID+"/domains", + ), + DefaultBranch: "main", + }}) + + main := makeEnv(projectID, "main", "production", "active", nil) + apiHandler.SetEnvironments([]*mockapi.Environment{main}) + + created, _ := time.Parse(time.RFC3339, "2024-01-15T10:00:00Z") + apiHandler.SetProjectDomains(projectID, []*mockapi.Domain{ + { + ID: "example.com", + Name: "example.com", + Type: "production", + IsDefault: true, + SSL: &mockapi.DomainSSL{HasCertificate: true}, + CreatedAt: created, + UpdatedAt: created, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID+"/domains/example.com", + "#edit=/projects/"+projectID+"/domains/example.com", + ), + }, + { + ID: "www.example.com", + Name: "www.example.com", + Type: "production", + IsDefault: false, + SSL: &mockapi.DomainSSL{HasCertificate: true}, + CreatedAt: created, + UpdatedAt: created, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID+"/domains/www.example.com", + "#edit=/projects/"+projectID+"/domains/www.example.com", + ), + }, + }) + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.Run("cc") + + // List domains. + output := f.Run("domains", "-p", projectID) + assert.Contains(t, output, "example.com") + assert.Contains(t, output, "www.example.com") + + // Get a specific domain property. + assertTrimmed(t, "production", f.Run("domain:get", "-p", projectID, "example.com", "-P", "type")) +} + +func TestDomainListEmpty(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiHandler := mockapi.NewHandler(t) + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + projectID := mockapi.ProjectID() + apiHandler.SetProjects([]*mockapi.Project{{ + ID: projectID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + "domains=/projects/"+projectID+"/domains", + ), + DefaultBranch: "main", + }}) + + main := makeEnv(projectID, "main", "production", "active", nil) + apiHandler.SetEnvironments([]*mockapi.Environment{main}) + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.Run("cc") + + // Empty domain list — the CLI exits with code 1 for "No domains found". + _, stdErr, _ := f.RunCombinedOutput("domains", "-p", projectID) + assert.Contains(t, stdErr, "No domains found") +} diff --git a/integration-tests/integration_test.go b/integration-tests/integration_test.go new file mode 100644 index 00000000..5b056f5d --- /dev/null +++ b/integration-tests/integration_test.go @@ -0,0 +1,74 @@ +package tests + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/upsun/cli/pkg/mockapi" +) + +func TestIntegrationList(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + apiHandler := mockapi.NewHandler(t) + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + projectID := mockapi.ProjectID() + apiHandler.SetProjects([]*mockapi.Project{{ + ID: projectID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + ), + DefaultBranch: "main", + }}) + + main := makeEnv(projectID, "main", "production", "active", nil) + apiHandler.SetEnvironments([]*mockapi.Environment{main}) + + created, _ := time.Parse(time.RFC3339, "2024-06-01T10:00:00Z") + apiHandler.SetProjectIntegrations(projectID, []*mockapi.Integration{ + { + ID: "int1", + Type: "github", + Repository: "org/repo", + CreatedAt: created, + UpdatedAt: created, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID+"/integrations/int1", + "#edit=/projects/"+projectID+"/integrations/int1", + ), + }, + { + ID: "int2", + Type: "webhook", + URL: "https://hooks.example.com/notify", + Events: []string{"environment.push"}, + CreatedAt: created, + UpdatedAt: created, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID+"/integrations/int2", + "#edit=/projects/"+projectID+"/integrations/int2", + ), + }, + }) + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.Run("cc") + + // List integrations. + output := f.Run("integrations", "-p", projectID) + assert.Contains(t, output, "int1") + assert.Contains(t, output, "github") + assert.Contains(t, output, "int2") + assert.Contains(t, output, "webhook") + + // Get a specific integration. + assertTrimmed(t, "github", f.Run("integration:get", "-p", projectID, "int1", "-P", "type")) + assertTrimmed(t, "org/repo", f.Run("integration:get", "-p", projectID, "int1", "-P", "repository")) +} diff --git a/pkg/mockapi/api_server.go b/pkg/mockapi/api_server.go index c214e4af..2b76b3ed 100644 --- a/pkg/mockapi/api_server.go +++ b/pkg/mockapi/api_server.go @@ -87,6 +87,12 @@ func NewHandler(t *testing.T) *Handler { h.Get("/projects/{project_id}/environments/{environment_id}/backups", h.handleListBackups) h.Post("/projects/{project_id}/environments/{environment_id}/backups", h.handleCreateBackup) h.Get("/projects/{project_id}/environments/{environment_id}/deployments/current", h.handleGetCurrentDeployment) + h.Get("/projects/{project_id}/domains", h.handleListProjectDomains) + h.Get("/projects/{project_id}/domains/{name}", h.handleGetProjectDomain) + h.Get("/projects/{project_id}/integrations", h.handleListProjectIntegrations) + h.Get("/projects/{project_id}/integrations/{integration_id}", h.handleGetProjectIntegration) + h.Get("/projects/{project_id}/certificates", h.handleListProjectCertificates) + h.Get("/projects/{project_id}/certificates/{certificate_id}", h.handleGetProjectCertificate) h.Get("/projects/{project_id}/user-access", h.handleProjectUserAccess) h.Get("/ref/projects", h.handleProjectRefs) diff --git a/pkg/mockapi/certificates.go b/pkg/mockapi/certificates.go new file mode 100644 index 00000000..abf6d95f --- /dev/null +++ b/pkg/mockapi/certificates.go @@ -0,0 +1,33 @@ +package mockapi + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" +) + +func (h *Handler) handleListProjectCertificates(w http.ResponseWriter, req *http.Request) { + h.RLock() + defer h.RUnlock() + projectID := chi.URLParam(req, "project_id") + certs := h.projectCertificates[projectID] + if certs == nil { + certs = []*Certificate{} + } + _ = json.NewEncoder(w).Encode(certs) +} + +func (h *Handler) handleGetProjectCertificate(w http.ResponseWriter, req *http.Request) { + h.RLock() + defer h.RUnlock() + projectID := chi.URLParam(req, "project_id") + certID := chi.URLParam(req, "certificate_id") + for _, c := range h.projectCertificates[projectID] { + if c.ID == certID { + _ = json.NewEncoder(w).Encode(c) + return + } + } + w.WriteHeader(http.StatusNotFound) +} diff --git a/pkg/mockapi/domains.go b/pkg/mockapi/domains.go new file mode 100644 index 00000000..db861cb0 --- /dev/null +++ b/pkg/mockapi/domains.go @@ -0,0 +1,34 @@ +package mockapi + +import ( + "encoding/json" + "net/http" + "net/url" + + "github.com/go-chi/chi/v5" +) + +func (h *Handler) handleListProjectDomains(w http.ResponseWriter, req *http.Request) { + h.RLock() + defer h.RUnlock() + projectID := chi.URLParam(req, "project_id") + domains := h.projectDomains[projectID] + if domains == nil { + domains = []*Domain{} + } + _ = json.NewEncoder(w).Encode(domains) +} + +func (h *Handler) handleGetProjectDomain(w http.ResponseWriter, req *http.Request) { + h.RLock() + defer h.RUnlock() + projectID := chi.URLParam(req, "project_id") + domainName, _ := url.PathUnescape(chi.URLParam(req, "name")) + for _, d := range h.projectDomains[projectID] { + if d.Name == domainName { + _ = json.NewEncoder(w).Encode(d) + return + } + } + w.WriteHeader(http.StatusNotFound) +} diff --git a/pkg/mockapi/integrations.go b/pkg/mockapi/integrations.go new file mode 100644 index 00000000..54fb2914 --- /dev/null +++ b/pkg/mockapi/integrations.go @@ -0,0 +1,33 @@ +package mockapi + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" +) + +func (h *Handler) handleListProjectIntegrations(w http.ResponseWriter, req *http.Request) { + h.RLock() + defer h.RUnlock() + projectID := chi.URLParam(req, "project_id") + integrations := h.projectIntegrations[projectID] + if integrations == nil { + integrations = []*Integration{} + } + _ = json.NewEncoder(w).Encode(integrations) +} + +func (h *Handler) handleGetProjectIntegration(w http.ResponseWriter, req *http.Request) { + h.RLock() + defer h.RUnlock() + projectID := chi.URLParam(req, "project_id") + integrationID := chi.URLParam(req, "integration_id") + for _, i := range h.projectIntegrations[projectID] { + if i.ID == integrationID { + _ = json.NewEncoder(w).Encode(i) + return + } + } + w.WriteHeader(http.StatusNotFound) +} diff --git a/pkg/mockapi/model.go b/pkg/mockapi/model.go index 1f103243..8e7ba54d 100644 --- a/pkg/mockapi/model.go +++ b/pkg/mockapi/model.go @@ -292,6 +292,56 @@ type Activity struct { UpdatedAt time.Time `json:"updated_at"` } +type Domain struct { + ID string `json:"id"` + Name string `json:"name"` + RegisteredName string `json:"registered_name,omitempty"` + Type string `json:"type"` + IsDefault bool `json:"is_default"` + ReplacementFor string `json:"replacement_for,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + SSL *DomainSSL `json:"ssl"` + + Links HalLinks `json:"_links"` +} + +type DomainSSL struct { + HasCertificate bool `json:"has_certificate"` +} + +type Integration struct { + ID string `json:"id"` + Type string `json:"type"` + + BaseURL string `json:"base_url,omitempty"` + Token string `json:"token,omitempty"` + Repository string `json:"repository,omitempty"` + + URL string `json:"url,omitempty"` + Events []string `json:"events,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + Links HalLinks `json:"_links"` +} + +type Certificate struct { + ID string `json:"id"` + Domains []string `json:"domains"` + Key string `json:"key,omitempty"` + Issuer string `json:"issuer"` + IsProvisioned bool `json:"is_provisioned"` + + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + Links HalLinks `json:"_links"` +} + type Variable struct { Name string `json:"name"` Value string `json:"value,omitempty"` diff --git a/pkg/mockapi/store.go b/pkg/mockapi/store.go index ff24dc95..10921c13 100644 --- a/pkg/mockapi/store.go +++ b/pkg/mockapi/store.go @@ -19,6 +19,10 @@ type store struct { projectBackups map[string]map[string]*Backup projectVariables map[string][]*Variable envLevelVariables map[string]map[string][]*EnvLevelVariable + + projectDomains map[string][]*Domain + projectIntegrations map[string][]*Integration + projectCertificates map[string][]*Certificate } func (s *store) SetEnvironments(envs []*Environment) { @@ -138,3 +142,30 @@ func (s *store) SetEnvLevelVariables(projectID, environmentID string, vars []*En } s.envLevelVariables[projectID][environmentID] = vars } + +func (s *store) SetProjectDomains(projectID string, domains []*Domain) { + s.Lock() + defer s.Unlock() + if s.projectDomains == nil { + s.projectDomains = make(map[string][]*Domain) + } + s.projectDomains[projectID] = domains +} + +func (s *store) SetProjectIntegrations(projectID string, integrations []*Integration) { + s.Lock() + defer s.Unlock() + if s.projectIntegrations == nil { + s.projectIntegrations = make(map[string][]*Integration) + } + s.projectIntegrations[projectID] = integrations +} + +func (s *store) SetProjectCertificates(projectID string, certs []*Certificate) { + s.Lock() + defer s.Unlock() + if s.projectCertificates == nil { + s.projectCertificates = make(map[string][]*Certificate) + } + s.projectCertificates[projectID] = certs +} From 0e58a80bc30d631f2ea3c34ac7fcccaf057d5b43 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 20 Mar 2026 19:26:08 +0000 Subject: [PATCH 4/8] style: fix gofmt formatting in model.go and certificate_test.go Co-Authored-By: Claude Opus 4.6 (1M context) --- integration-tests/certificate_test.go | 2 +- pkg/mockapi/model.go | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/integration-tests/certificate_test.go b/integration-tests/certificate_test.go index a310d0ee..cc4a0867 100644 --- a/integration-tests/certificate_test.go +++ b/integration-tests/certificate_test.go @@ -43,7 +43,7 @@ func TestCertificateList(t *testing.T) { CreatedAt: created, UpdatedAt: created, Links: mockapi.MakeHALLinks( - "self=/projects/"+projectID+"/certificates/cert1", + "self=/projects/" + projectID + "/certificates/cert1", ), }, }) diff --git a/pkg/mockapi/model.go b/pkg/mockapi/model.go index 8e7ba54d..7d02a9bd 100644 --- a/pkg/mockapi/model.go +++ b/pkg/mockapi/model.go @@ -293,14 +293,14 @@ type Activity struct { } type Domain struct { - ID string `json:"id"` - Name string `json:"name"` - RegisteredName string `json:"registered_name,omitempty"` - Type string `json:"type"` - IsDefault bool `json:"is_default"` - ReplacementFor string `json:"replacement_for,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + RegisteredName string `json:"registered_name,omitempty"` + Type string `json:"type"` + IsDefault bool `json:"is_default"` + ReplacementFor string `json:"replacement_for,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` SSL *DomainSSL `json:"ssl"` @@ -329,11 +329,11 @@ type Integration struct { } type Certificate struct { - ID string `json:"id"` - Domains []string `json:"domains"` - Key string `json:"key,omitempty"` - Issuer string `json:"issuer"` - IsProvisioned bool `json:"is_provisioned"` + ID string `json:"id"` + Domains []string `json:"domains"` + Key string `json:"key,omitempty"` + Issuer string `json:"issuer"` + IsProvisioned bool `json:"is_provisioned"` ExpiresAt time.Time `json:"expires_at"` CreatedAt time.Time `json:"created_at"` From d5015b05d09c63a54235949ff828124de7765d2f Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 20 Mar 2026 19:44:29 +0000 Subject: [PATCH 5/8] fix(test): assert error on empty domain list Address review feedback: assert that the CLI exits with an error when no domains are found, not just that stderr contains the message. Co-Authored-By: Claude Opus 4.6 (1M context) --- integration-tests/domain_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/integration-tests/domain_test.go b/integration-tests/domain_test.go index 029633f1..7ba629f7 100644 --- a/integration-tests/domain_test.go +++ b/integration-tests/domain_test.go @@ -100,6 +100,7 @@ func TestDomainListEmpty(t *testing.T) { f.Run("cc") // Empty domain list — the CLI exits with code 1 for "No domains found". - _, stdErr, _ := f.RunCombinedOutput("domains", "-p", projectID) + _, stdErr, err := f.RunCombinedOutput("domains", "-p", projectID) + assert.Error(t, err) assert.Contains(t, stdErr, "No domains found") } From 9bdfe5b695d639ba3762bcb376ac717c316feabb Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 20 Mar 2026 19:48:27 +0000 Subject: [PATCH 6/8] fix(test): don't assert exit code for empty domain list The CLI currently exits non-zero when no domains are found, but this is inconsistent with other list commands (services, certificates exit 0). Don't assert the exit code either way so the test is resilient to a future fix. Co-Authored-By: Claude Opus 4.6 (1M context) --- integration-tests/domain_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/integration-tests/domain_test.go b/integration-tests/domain_test.go index 7ba629f7..84a9f9e7 100644 --- a/integration-tests/domain_test.go +++ b/integration-tests/domain_test.go @@ -99,8 +99,10 @@ func TestDomainListEmpty(t *testing.T) { f := newCommandFactory(t, apiServer.URL, authServer.URL) f.Run("cc") - // Empty domain list — the CLI exits with code 1 for "No domains found". - _, stdErr, err := f.RunCombinedOutput("domains", "-p", projectID) - assert.Error(t, err) + // Empty domain list outputs a message to stderr. + // Note: the CLI currently exits non-zero for this, which is arguably a bug + // (cf. services/certificates which exit 0 for empty lists). Not asserting + // the exit code so this test won't break if that gets fixed. + _, stdErr, _ := f.RunCombinedOutput("domains", "-p", projectID) assert.Contains(t, stdErr, "No domains found") } From f662af188d53276b2617568052ef432020f95b8c Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 24 Mar 2026 15:41:27 +0000 Subject: [PATCH 7/8] test: add remote path test for environment:relationships Add TestRelationshipsRemote that fetches relationships over SSH using the mock SSH server, matching the pattern used by ssh_test.go and valkey_test.go. Extract shared relationship data into mockRelationships() helper. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../environment_relationships_test.go | 78 ++++++++++++++++++- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/integration-tests/environment_relationships_test.go b/integration-tests/environment_relationships_test.go index 92e11002..a7b8bce5 100644 --- a/integration-tests/environment_relationships_test.go +++ b/integration-tests/environment_relationships_test.go @@ -3,14 +3,19 @@ package tests import ( "encoding/base64" "encoding/json" + "net/http/httptest" + "strconv" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/upsun/cli/pkg/mockapi" + "github.com/upsun/cli/pkg/mockssh" ) -func TestRelationshipsLocal(t *testing.T) { - relationships := map[string]any{ +func mockRelationships() map[string]any { + return map[string]any{ "database": []any{ map[string]any{ "service": "db", @@ -38,8 +43,10 @@ func TestRelationshipsLocal(t *testing.T) { }, }, } +} - data, err := json.Marshal(relationships) +func TestRelationshipsLocal(t *testing.T) { + data, err := json.Marshal(mockRelationships()) require.NoError(t, err) f := &cmdFactory{t: t} @@ -55,3 +62,68 @@ func TestRelationshipsLocal(t *testing.T) { assertTrimmed(t, "redis.internal", f.Run("environment:relationships", "-P", "redis.0.host")) assertTrimmed(t, "3306", f.Run("environment:relationships", "-P", "database.0.port")) } + +func TestRelationshipsRemote(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + sshServer, err := mockssh.NewServer(t, authServer.URL+"/ssh/authority") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := sshServer.Stop(); err != nil { + t.Error(err) + } + }) + + relJSON, err := json.Marshal(mockRelationships()) + require.NoError(t, err) + sshServer.CommandHandler = mockssh.ExecHandler(t.TempDir(), []string{ + "PLATFORM_RELATIONSHIPS=" + base64.StdEncoding.EncodeToString(relJSON), + }) + + projectID := mockapi.ProjectID() + + apiHandler := mockapi.NewHandler(t) + apiHandler.SetMyUser(&mockapi.User{ID: "my-user-id"}) + apiHandler.SetProjects([]*mockapi.Project{{ + ID: projectID, + Links: mockapi.MakeHALLinks( + "self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments", + ), + DefaultBranch: "main", + }}) + + mainEnv := makeEnv(projectID, "main", "production", "active", nil) + mainEnv.SetCurrentDeployment(&mockapi.Deployment{ + WebApps: map[string]mockapi.App{ + "app": {Name: "app", Type: "golang:1.23", Size: "M", Disk: 2048, Mounts: map[string]mockapi.Mount{}}, + }, + Services: map[string]mockapi.App{}, + Workers: map[string]mockapi.Worker{}, + Routes: make(map[string]any), + Links: mockapi.MakeHALLinks("self=/projects/" + projectID + "/environments/main/deployment/current"), + }) + mainEnv.Links["pf:ssh:app:0"] = mockapi.HALLink{HREF: "ssh://app--0@ssh.cli-tests.example.com"} + apiHandler.SetEnvironments([]*mockapi.Environment{mainEnv}) + + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + f := newCommandFactory(t, apiServer.URL, authServer.URL) + f.extraEnv = []string{ + EnvPrefix + "SSH_OPTIONS=HostName 127.0.0.1\nPort " + strconv.Itoa(sshServer.Port()), + EnvPrefix + "SSH_HOST_KEYS=" + sshServer.HostKeyConfig(), + } + f.Run("cc") + + // List all relationships via SSH. + output := f.Run("relationships", "-p", projectID, "-e", ".", "--refresh") + assert.Contains(t, output, "database") + assert.Contains(t, output, "redis") + + // Extract a property via SSH. + assertTrimmed(t, "database.internal", f.Run("relationships", "-p", projectID, "-e", ".", "--refresh", "-P", "database.0.host")) +} From a3731ee228bd9a42dd005946ff703c9b4bae3770 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 10 Apr 2026 12:23:19 +0200 Subject: [PATCH 8/8] style: fix line length in relationships test Break a long line to stay within the 120-character limit. Co-Authored-By: Claude Opus 4.6 (1M context) --- integration-tests/environment_relationships_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/integration-tests/environment_relationships_test.go b/integration-tests/environment_relationships_test.go index a7b8bce5..71438866 100644 --- a/integration-tests/environment_relationships_test.go +++ b/integration-tests/environment_relationships_test.go @@ -125,5 +125,6 @@ func TestRelationshipsRemote(t *testing.T) { assert.Contains(t, output, "redis") // Extract a property via SSH. - assertTrimmed(t, "database.internal", f.Run("relationships", "-p", projectID, "-e", ".", "--refresh", "-P", "database.0.host")) + assertTrimmed(t, "database.internal", + f.Run("relationships", "-p", projectID, "-e", ".", "--refresh", "-P", "database.0.host")) }