diff --git a/go.mod b/go.mod index 036286e..f4b1960 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/go-playground/validator/v10 v10.20.0 github.com/gofrs/flock v0.8.1 github.com/mattn/go-isatty v0.0.20 + github.com/oklog/ulid/v2 v2.1.0 github.com/platformsh/platformify v0.2.11 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 diff --git a/go.sum b/go.sum index 1af28a2..c84281e 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,9 @@ github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= +github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/platformsh/platformify v0.2.11 h1:9TRej4tDgQahRfl1tDOGaCry79yXYXbzDR1ZMdOPsU8= diff --git a/internal/mockapi/api_server.go b/internal/mockapi/api_server.go index 283646e..9616e4a 100644 --- a/internal/mockapi/api_server.go +++ b/internal/mockapi/api_server.go @@ -45,7 +45,9 @@ func NewHandler(t *testing.T) *Handler { }) h.Mux.Get("/organizations", h.handleListOrgs) + h.Mux.Post("/organizations", h.handleCreateOrg) h.Mux.Get("/organizations/{id}", h.handleGetOrg) + h.Mux.Patch("/organizations/{id}", h.handlePatchOrg) h.Mux.Get("/users/{id}/organizations", h.handleListOrgs) h.Mux.Get("/ref/organizations", h.handleOrgRefs) diff --git a/internal/mockapi/orgs.go b/internal/mockapi/orgs.go index f8547ca..98be492 100644 --- a/internal/mockapi/orgs.go +++ b/internal/mockapi/orgs.go @@ -1,13 +1,16 @@ package mockapi import ( + "crypto/rand" "encoding/json" "net/http" + "net/url" "path" "slices" "strings" "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" "github.com/stretchr/testify/require" ) @@ -38,7 +41,7 @@ func (h *Handler) handleListOrgs(w http.ResponseWriter, _ *http.Request) { orgs = append(orgs, o) ownerIDs[o.Owner] = struct{}{} } - slices.SortFunc(orgs, func(a, b *Org) int { return strings.Compare(a.ID, b.ID) }) + slices.SortFunc(orgs, func(a, b *Org) int { return strings.Compare(a.Name, b.Name) }) _ = json.NewEncoder(w).Encode(struct { Items []*Org `json:"items"` Links HalLinks `json:"_links"` @@ -53,7 +56,6 @@ func (h *Handler) handleGetOrg(w http.ResponseWriter, req *http.Request) { defer h.store.RUnlock() var org *Org - // TODO why doesn't Chi decode this? orgID := chi.URLParam(req, "id") if strings.HasPrefix(orgID, "name%3D") { name := strings.TrimPrefix(orgID, "name%3D") @@ -74,3 +76,45 @@ func (h *Handler) handleGetOrg(w http.ResponseWriter, req *http.Request) { _ = json.NewEncoder(w).Encode(org) } + +func (h *Handler) handleCreateOrg(w http.ResponseWriter, req *http.Request) { + h.store.Lock() + defer h.store.Unlock() + var org Org + err := json.NewDecoder(req.Body).Decode(&org) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + for _, o := range h.store.orgs { + if o.Name == org.Name { + w.WriteHeader(http.StatusConflict) + return + } + } + org.ID = ulid.MustNew(ulid.Now(), rand.Reader).String() + org.Owner = h.store.myUser.ID + org.Capabilities = []string{} + org.Links = MakeHALLinks("self=/organizations/" + url.PathEscape(org.ID)) + h.store.orgs[org.ID] = &org + _ = json.NewEncoder(w).Encode(&org) +} + +func (h *Handler) handlePatchOrg(w http.ResponseWriter, req *http.Request) { + h.store.Lock() + defer h.store.Unlock() + projectID := chi.URLParam(req, "id") + p, ok := h.store.orgs[projectID] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + patched := *p + err := json.NewDecoder(req.Body).Decode(&patched) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + h.store.orgs[projectID] = &patched + _ = json.NewEncoder(w).Encode(&patched) +} diff --git a/tests/app_list_test.go b/tests/app_list_test.go index e765562..83cb1ec 100644 --- a/tests/app_list_test.go +++ b/tests/app_list_test.go @@ -1,10 +1,7 @@ package tests import ( - "bytes" - "io" "net/http/httptest" - "os" "strings" "testing" @@ -23,14 +20,16 @@ func TestAppList(t *testing.T) { apiServer := httptest.NewServer(apiHandler) defer apiServer.Close() + projectID := "nu8ohgeizah1a" + apiHandler.SetProjects([]*mockapi.Project{{ - ID: mockProjectID, - Links: mockapi.MakeHALLinks("self=/projects/"+mockProjectID, - "environments=/projects/"+mockProjectID+"/environments"), + ID: projectID, + Links: mockapi.MakeHALLinks("self=/projects/"+projectID, + "environments=/projects/"+projectID+"/environments"), DefaultBranch: "main", }}) - main := makeEnv(mockProjectID, "main", "production", "active", nil) + main := makeEnv(projectID, "main", "production", "active", nil) main.SetCurrentDeployment(&mockapi.Deployment{ WebApps: map[string]mockapi.App{ "app": {Name: "app", Type: "golang:1.23", Size: "AUTO"}, @@ -43,14 +42,14 @@ func TestAppList(t *testing.T) { Worker: mockapi.WorkerInfo{Commands: mockapi.Commands{Start: "sleep 60"}}, }, }, - Links: mockapi.MakeHALLinks("self=/projects/" + mockProjectID + "/environments/main/deployment/current"), + Links: mockapi.MakeHALLinks("self=/projects/" + projectID + "/environments/main/deployment/current"), }) envs := []*mockapi.Environment{ main, - makeEnv(mockProjectID, "staging", "staging", "active", "main"), - makeEnv(mockProjectID, "dev", "development", "active", "staging"), - makeEnv(mockProjectID, "fix", "development", "inactive", "dev"), + makeEnv(projectID, "staging", "staging", "active", "main"), + makeEnv(projectID, "dev", "development", "active", "staging"), + makeEnv(projectID, "fix", "development", "inactive", "dev"), } apiHandler.SetEnvironments(envs) @@ -60,7 +59,7 @@ func TestAppList(t *testing.T) { assert.Equal(t, strings.TrimLeft(` Name Type app golang:1.23 -`, "\n"), run("apps", "-p", mockProjectID, "-e", ".", "--refresh", "--format", "tsv")) +`, "\n"), run("apps", "-p", projectID, "-e", ".", "--refresh", "--format", "tsv")) assert.Equal(t, strings.TrimLeft(` +--------------+-------------+-------------------+ @@ -68,15 +67,10 @@ app golang:1.23 +--------------+-------------+-------------------+ | app--worker1 | golang:1.23 | start: 'sleep 60' | +--------------+-------------+-------------------+ -`, "\n"), run("workers", "-v", "-p", mockProjectID, "-e", ".")) +`, "\n"), run("workers", "-v", "-p", projectID, "-e", ".")) - servicesCmd := authenticatedCommand(t, apiServer.URL, authServer.URL, - "services", "-p", mockProjectID, "-e", "main") - stdErrBuf := bytes.Buffer{} - servicesCmd.Stderr = &stdErrBuf - if testing.Verbose() { - servicesCmd.Stderr = io.MultiWriter(&stdErrBuf, os.Stderr) - } - require.NoError(t, servicesCmd.Run()) - assert.Contains(t, stdErrBuf.String(), "No services found") + runCombinedOutput := runnerCombinedOutput(t, apiServer.URL, authServer.URL) + co, err := runCombinedOutput("services", "-p", projectID, "-e", "main") + require.NoError(t, err) + assert.Contains(t, co, "No services found") } diff --git a/tests/auth_info_test.go b/tests/auth_info_test.go index 888113f..c0fe036 100644 --- a/tests/auth_info_test.go +++ b/tests/auth_info_test.go @@ -46,7 +46,7 @@ func TestAuthInfo(t *testing.T) { | email | my-user@example.com | | phone_number_verified | true | +-----------------------+---------------------+ -`, "\n"), run("auth:info", "-v")) +`, "\n"), run("auth:info", "-v", "--refresh")) assert.Equal(t, "my-user-id\n", run("auth:info", "-P", "id")) } diff --git a/tests/org_create_test.go b/tests/org_create_test.go new file mode 100644 index 0000000..1c68b0b --- /dev/null +++ b/tests/org_create_test.go @@ -0,0 +1,63 @@ +package tests + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/platformsh/cli/internal/mockapi" +) + +func TestOrgCreate(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + myUserID := "user-for-org-create-test" + + apiHandler := mockapi.NewHandler(t) + apiHandler.SetMyUser(&mockapi.User{ID: myUserID}) + apiHandler.SetOrgs([]*mockapi.Org{ + makeOrg("org-id-1", "acme", "ACME Inc.", myUserID), + }) + + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + run := runnerWithAuth(t, apiServer.URL, authServer.URL) + + // TODO disable the cache? + run("cc") + + assert.Equal(t, strings.TrimLeft(` ++------+-----------+--------------------------------------+ +| Name | Label | Owner email | ++------+-----------+--------------------------------------+ +| acme | ACME Inc. | user-for-org-create-test@example.com | ++------+-----------+--------------------------------------+ +`, "\n"), run("orgs")) + + runCombinedOutput := runnerCombinedOutput(t, apiServer.URL, authServer.URL) + + co, err := runCombinedOutput("org:create", "--name", "hooli", "--yes") + assert.Error(t, err) + assert.Contains(t, co, "--country is required") + + co, err = runCombinedOutput("org:create", "--name", "hooli", "--yes", "--country", "XY") + assert.Error(t, err) + assert.Contains(t, co, "Invalid country: XY") + + co, err = runCombinedOutput("org:create", "--name", "hooli", "--yes", "--country", "US") + assert.NoError(t, err) + assert.Contains(t, co, "Hooli") + + assert.Equal(t, strings.TrimLeft(` ++-------+-----------+--------------------------------------+ +| Name | Label | Owner email | ++-------+-----------+--------------------------------------+ +| acme | ACME Inc. | user-for-org-create-test@example.com | +| hooli | Hooli | user-for-org-create-test@example.com | ++-------+-----------+--------------------------------------+ +`, "\n"), run("orgs")) +} diff --git a/tests/org_info_test.go b/tests/org_info_test.go new file mode 100644 index 0000000..2391037 --- /dev/null +++ b/tests/org_info_test.go @@ -0,0 +1,44 @@ +package tests + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/platformsh/cli/internal/mockapi" +) + +func TestOrgInfo(t *testing.T) { + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + + myUserID := "user-for-org-info-test" + + apiHandler := mockapi.NewHandler(t) + apiHandler.SetMyUser(&mockapi.User{ID: myUserID}) + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + + apiHandler.SetOrgs([]*mockapi.Org{ + makeOrg("org-id-1", "org-1", "Org 1", myUserID), + }) + + run := runnerWithAuth(t, apiServer.URL, authServer.URL) + + assert.Contains(t, run("org:info", "-o", "org-1", "--format", "csv", "--refresh"), `Property,Value +id,org-id-1 +name,org-1 +label,Org 1 +owner_id,user-for-org-info-test +capabilities,`) + + assert.Equal(t, "Org 1\n", run("org:info", "-o", "org-1", "label")) + + runCombinedOutput := runnerCombinedOutput(t, apiServer.URL, authServer.URL) + co, err := runCombinedOutput("org:info", "-o", "org-1", "label", "New Label") + assert.NoError(t, err) + assert.Contains(t, co, "Property label set to: New Label\n") + + assert.Equal(t, "New Label\n", run("org:info", "-o", "org-1", "label")) +} diff --git a/tests/org_list_test.go b/tests/org_list_test.go index b10dc72..74fac41 100644 --- a/tests/org_list_test.go +++ b/tests/org_list_test.go @@ -35,31 +35,32 @@ func TestOrgList(t *testing.T) { | Name | Label | Owner email | +--------------+--------------------------------+-----------------------+ | acme | ACME Inc. | user-id-1@example.com | -| four-seasons | Four Seasons Total Landscaping | user-id-1@example.com | | duff | Duff Beer | user-id-2@example.com | +| four-seasons | Four Seasons Total Landscaping | user-id-1@example.com | +--------------+--------------------------------+-----------------------+ `, "\n"), run("orgs")) assert.Equal(t, strings.TrimLeft(` Name Label Owner email acme ACME Inc. user-id-1@example.com -four-seasons Four Seasons Total Landscaping user-id-1@example.com duff Duff Beer user-id-2@example.com +four-seasons Four Seasons Total Landscaping user-id-1@example.com `, "\n"), run("orgs", "--format", "plain")) assert.Equal(t, strings.TrimLeft(` org-id-1,acme -org-id-2,four-seasons org-id-3,duff +org-id-2,four-seasons `, "\n"), run("orgs", "--format", "csv", "--columns", "id,name", "--no-header")) } func makeOrg(id, name, label, owner string) *mockapi.Org { return &mockapi.Org{ - ID: id, - Name: name, - Label: label, - Owner: owner, - Links: mockapi.MakeHALLinks("self=/organizations/" + url.PathEscape(id)), + ID: id, + Name: name, + Label: label, + Owner: owner, + Capabilities: []string{}, + Links: mockapi.MakeHALLinks("self=/organizations/" + url.PathEscape(id)), } } diff --git a/tests/tests.go b/tests/tests.go index 354acbd..c22b541 100644 --- a/tests/tests.go +++ b/tests/tests.go @@ -5,6 +5,7 @@ package tests import ( + "bytes" "os" "os/exec" "path/filepath" @@ -75,6 +76,7 @@ func authenticatedCommand(t *testing.T, apiURL, authURL string, args ...string) } // runnerWithAuth returns a function to authenticate and run a CLI command, returning stdout output. +// This asserts that the command has not failed. func runnerWithAuth(t *testing.T, apiURL, authURL string) func(args ...string) string { return func(args ...string) string { cmd := authenticatedCommand(t, apiURL, authURL, args...) @@ -84,6 +86,18 @@ func runnerWithAuth(t *testing.T, apiURL, authURL string) func(args ...string) s } } +// runnerCombinedOutput returns a function to authenticate and run a CLI command, returning combined output. +func runnerCombinedOutput(t *testing.T, apiURL, authURL string) func(args ...string) (string, error) { + return func(args ...string) (string, error) { + cmd := authenticatedCommand(t, apiURL, authURL, args...) + var b bytes.Buffer + cmd.Stdout = &b + cmd.Stderr = &b + err := cmd.Run() + return b.String(), err + } +} + const EnvPrefix = "TEST_CLI_" func testEnv() []string {