diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..4ee7d44 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,189 @@ +package config_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/cloud-ru/evolution-devservices-cli/internal/config" +) + +// withTempConfig points EDS_CONFIG at a fresh, non-existent file inside a +// per-test temp dir so tests never touch the developer's real config. +func withTempConfig(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.json") + t.Setenv("EDS_CONFIG", path) + return path +} + +// clearEnv resets every env var Load() consults so tests aren't affected by +// whatever happens to be exported in the shell running `go test`. +func clearEnv(t *testing.T) { + t.Helper() + for _, k := range []string{ + "EDS_PROJECT_ID", "EDS_REPO_API_URL", "EDS_API_KEY", + "EDS_REPO_GIT_HOST", "EDS_WF_API_URL", + } { + t.Setenv(k, "") + } +} + +func TestLoad_Defaults(t *testing.T) { + withTempConfig(t) + clearEnv(t) + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.APIURL != config.DefaultAPIURL { + t.Errorf("APIURL = %q, want %q", cfg.APIURL, config.DefaultAPIURL) + } + if cfg.GitHost != config.DefaultGitHost { + t.Errorf("GitHost = %q, want %q", cfg.GitHost, config.DefaultGitHost) + } + if cfg.WorkflowAPIURL != config.DefaultWorkflowAPIURL { + t.Errorf("WorkflowAPIURL = %q, want %q", cfg.WorkflowAPIURL, config.DefaultWorkflowAPIURL) + } + if cfg.APIKey != "" { + t.Errorf("APIKey = %q, want empty", cfg.APIKey) + } +} + +func TestLoad_FileOverridesDefaults(t *testing.T) { + path := withTempConfig(t) + clearEnv(t) + + writeConfig(t, path, config.Config{ + APIURL: "https://file.example/api", + ProjectID: "proj-from-file", + APIKey: "key-from-file", + }) + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.APIURL != "https://file.example/api" { + t.Errorf("APIURL = %q, want file value", cfg.APIURL) + } + if cfg.ProjectID != "proj-from-file" { + t.Errorf("ProjectID = %q, want file value", cfg.ProjectID) + } + if cfg.APIKey != "key-from-file" { + t.Errorf("APIKey = %q, want file value", cfg.APIKey) + } + // GitHost was left empty in the file, so it should fall back to the default. + if cfg.GitHost != config.DefaultGitHost { + t.Errorf("GitHost = %q, want default %q when unset in file", cfg.GitHost, config.DefaultGitHost) + } +} + +func TestLoad_EnvOverridesFile(t *testing.T) { + path := withTempConfig(t) + clearEnv(t) + + writeConfig(t, path, config.Config{ + APIURL: "https://file.example/api", + ProjectID: "proj-from-file", + APIKey: "key-from-file", + }) + + t.Setenv("EDS_REPO_API_URL", "https://env.example/api") + t.Setenv("EDS_API_KEY", "key-from-env") + t.Setenv("EDS_PROJECT_ID", "proj-from-env") + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.APIURL != "https://env.example/api" { + t.Errorf("APIURL = %q, want env value", cfg.APIURL) + } + if cfg.APIKey != "key-from-env" { + t.Errorf("APIKey = %q, want env value", cfg.APIKey) + } + if cfg.ProjectID != "proj-from-env" { + t.Errorf("ProjectID = %q, want env value", cfg.ProjectID) + } +} + +func TestLoad_MissingFileIsNotAnError(t *testing.T) { + withTempConfig(t) // path deliberately left non-existent + clearEnv(t) + + if _, err := config.Load(); err != nil { + t.Fatalf("Load() with no config file on disk should not error, got %v", err) + } +} + +func TestSaveThenLoad_RoundTrips(t *testing.T) { + path := withTempConfig(t) + clearEnv(t) + + cfg := &config.Config{ + APIURL: "https://saved.example/api", + ProjectID: "saved-project", + APIKey: "saved-key", + GitHost: "https://saved.example/git", + } + if err := cfg.Save(); err != nil { + t.Fatalf("Save() error = %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat saved config: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("config file mode = %o, want 0600", perm) + } + + got, err := config.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.APIURL != cfg.APIURL || got.ProjectID != cfg.ProjectID || + got.APIKey != cfg.APIKey || got.GitHost != cfg.GitHost { + t.Errorf("round-tripped config = %+v, want %+v", got, cfg) + } +} + +func TestLoad_TildeInEDSConfigExpandsToHome(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) // os.UserHomeDir() honors $HOME on Unix + t.Setenv("USERPROFILE", home) // ... and on Windows + t.Setenv("EDS_CONFIG", "~/eds-test/config.json") + clearEnv(t) + + cfg := &config.Config{ProjectID: "tilde-project"} + if err := cfg.Save(); err != nil { + t.Fatalf("Save() error = %v", err) + } + + wantPath := filepath.Join(home, "eds-test", "config.json") + if _, err := os.Stat(wantPath); err != nil { + t.Fatalf("expected config at %s, stat error = %v", wantPath, err) + } + + got, err := config.Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.ProjectID != "tilde-project" { + t.Errorf("ProjectID = %q, want %q", got.ProjectID, "tilde-project") + } +} + +func writeConfig(t *testing.T, path string, cfg config.Config) { + t.Helper() + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } +} diff --git a/internal/repoapi/client_test.go b/internal/repoapi/client_test.go new file mode 100644 index 0000000..4ebfd06 --- /dev/null +++ b/internal/repoapi/client_test.go @@ -0,0 +1,163 @@ +package repoapi_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cloud-ru/evolution-devservices-cli/internal/repoapi" +) + +func TestDo_SuccessDecodesJSONAndSendsAPIKey(t *testing.T) { + var gotAPIKey, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAPIKey = r.Header.Get("X-API-KEY") + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"abc123","name":"demo"}`)) + })) + defer srv.Close() + + c := repoapi.New(srv.URL, "test-api-key", "proj-1") + + var out struct { + ID string `json:"id"` + Name string `json:"name"` + } + err := c.Do(context.Background(), http.MethodGet, "/repository/abc123", nil, nil, &out) + if err != nil { + t.Fatalf("Do() error = %v", err) + } + if out.ID != "abc123" || out.Name != "demo" { + t.Errorf("decoded = %+v, want id=abc123 name=demo", out) + } + if gotAPIKey != "test-api-key" { + t.Errorf("X-API-KEY header = %q, want %q", gotAPIKey, "test-api-key") + } + if gotPath != "/repository/abc123" { + t.Errorf("request path = %q, want %q", gotPath, "/repository/abc123") + } +} + +func TestDo_ErrorShape_ErrorField(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid name"}`)) + })) + defer srv.Close() + + c := repoapi.New(srv.URL, "key", "proj-1") + err := c.Do(context.Background(), http.MethodPost, "/repository", nil, map[string]string{"name": ""}, nil) + + var apiErr *repoapi.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error = %v, want *repoapi.APIError", err) + } + if apiErr.StatusCode != http.StatusBadRequest { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusBadRequest) + } + if apiErr.Message != "invalid name" { + t.Errorf("Message = %q, want %q", apiErr.Message, "invalid name") + } +} + +func TestDo_ErrorShape_MessageField(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"something broke"}`)) + })) + defer srv.Close() + + c := repoapi.New(srv.URL, "key", "proj-1") + err := c.Do(context.Background(), http.MethodGet, "/repository/x", nil, nil, nil) + + var apiErr *repoapi.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error = %v, want *repoapi.APIError", err) + } + if apiErr.Message != "something broke" { + t.Errorf("Message = %q, want %q", apiErr.Message, "something broke") + } +} + +func TestDo_ErrorShape_ValidationErrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"errors":{"name":["is required"]}}`)) + })) + defer srv.Close() + + c := repoapi.New(srv.URL, "key", "proj-1") + err := c.Do(context.Background(), http.MethodPost, "/repository", nil, map[string]string{}, nil) + + var apiErr *repoapi.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error = %v, want *repoapi.APIError", err) + } + if apiErr.Message != "name: is required" { + t.Errorf("Message = %q, want %q", apiErr.Message, "name: is required") + } +} + +func TestDo_ErrorShape_EmptyBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + c := repoapi.New(srv.URL, "key", "proj-1") + err := c.Do(context.Background(), http.MethodGet, "/repository/x", nil, nil, nil) + + var apiErr *repoapi.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error = %v, want *repoapi.APIError", err) + } + if got := apiErr.Error(); got != "api error 500: (empty response body)" { + t.Errorf("Error() = %q, want %q", got, "api error 500: (empty response body)") + } +} + +func TestDo_RequestIDIsCapturedFromHeaderAndIncludedInError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Request-Id", "req-42") + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`{"error":"upstream down"}`)) + })) + defer srv.Close() + + c := repoapi.New(srv.URL, "key", "proj-1") + err := c.Do(context.Background(), http.MethodGet, "/repository/x", nil, nil, nil) + + var apiErr *repoapi.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error = %v, want *repoapi.APIError", err) + } + if apiErr.RequestID != "req-42" { + t.Errorf("RequestID = %q, want %q", apiErr.RequestID, "req-42") + } + want := "api error 502: upstream down (request-id: req-42)" + if got := apiErr.Error(); got != want { + t.Errorf("Error() = %q, want %q", got, want) + } +} + +func TestDo_2xxWithNoOutTargetDoesNotError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := repoapi.New(srv.URL, "key", "proj-1") + if err := c.Do(context.Background(), http.MethodDelete, "/repository/x", nil, nil, nil); err != nil { + t.Fatalf("Do() error = %v", err) + } +} + +func TestNew_ExposesProjectID(t *testing.T) { + c := repoapi.New("https://example.invalid", "key", "proj-42") + if got := c.ProjectID(); got != "proj-42" { + t.Errorf("ProjectID() = %q, want %q", got, "proj-42") + } +} diff --git a/internal/workflowapi/client_test.go b/internal/workflowapi/client_test.go new file mode 100644 index 0000000..497fea5 --- /dev/null +++ b/internal/workflowapi/client_test.go @@ -0,0 +1,148 @@ +package workflowapi_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cloud-ru/evolution-devservices-cli/internal/workflowapi" +) + +func TestDo_SuccessDecodesJSONAndSendsAPIKey(t *testing.T) { + var gotAPIKey, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAPIKey = r.Header.Get("X-API-KEY") + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"run-1","status":"running"}`)) + })) + defer srv.Close() + + c := workflowapi.New(srv.URL, "test-api-key", "proj-1") + + var out struct { + ID string `json:"id"` + Status string `json:"status"` + } + err := c.Do(context.Background(), http.MethodGet, "/run/run-1", nil, nil, &out) + if err != nil { + t.Fatalf("Do() error = %v", err) + } + if out.ID != "run-1" || out.Status != "running" { + t.Errorf("decoded = %+v, want id=run-1 status=running", out) + } + if gotAPIKey != "test-api-key" { + t.Errorf("X-API-KEY header = %q, want %q", gotAPIKey, "test-api-key") + } + if gotPath != "/run/run-1" { + t.Errorf("request path = %q, want %q", gotPath, "/run/run-1") + } +} + +func TestDo_SetTokenChangesSubsequentRequests(t *testing.T) { + var gotAPIKey string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAPIKey = r.Header.Get("X-API-KEY") + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := workflowapi.New(srv.URL, "old-key", "proj-1") + c.SetToken("new-key") + + if err := c.Do(context.Background(), http.MethodGet, "/run/x", nil, nil, nil); err != nil { + t.Fatalf("Do() error = %v", err) + } + if gotAPIKey != "new-key" { + t.Errorf("X-API-KEY header = %q, want %q (after SetToken)", gotAPIKey, "new-key") + } +} + +func TestDo_ErrorShape_ErrorField(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid pipeline"}`)) + })) + defer srv.Close() + + c := workflowapi.New(srv.URL, "key", "proj-1") + err := c.Do(context.Background(), http.MethodPost, "/pipeline/x/run", nil, map[string]string{}, nil) + + var apiErr *workflowapi.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error = %v, want *workflowapi.APIError", err) + } + if apiErr.Message != "invalid pipeline" { + t.Errorf("Message = %q, want %q", apiErr.Message, "invalid pipeline") + } +} + +func TestDo_ErrorShape_ValidationErrors(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"errors":{"branch":["is required"]}}`)) + })) + defer srv.Close() + + c := workflowapi.New(srv.URL, "key", "proj-1") + err := c.Do(context.Background(), http.MethodPost, "/application", nil, map[string]string{}, nil) + + var apiErr *workflowapi.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error = %v, want *workflowapi.APIError", err) + } + if apiErr.Message != "branch: is required" { + t.Errorf("Message = %q, want %q", apiErr.Message, "branch: is required") + } +} + +func TestDo_ErrorShape_EmptyBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + c := workflowapi.New(srv.URL, "key", "proj-1") + err := c.Do(context.Background(), http.MethodGet, "/run/x", nil, nil, nil) + + var apiErr *workflowapi.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error = %v, want *workflowapi.APIError", err) + } + if got := apiErr.Error(); got != "workflow api error 500: (empty response body)" { + t.Errorf("Error() = %q, want %q", got, "workflow api error 500: (empty response body)") + } +} + +func TestDo_RequestIDIsCapturedFromHeaderAndIncludedInError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Request-Id", "wf-req-7") + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte(`{"error":"upstream down"}`)) + })) + defer srv.Close() + + c := workflowapi.New(srv.URL, "key", "proj-1") + err := c.Do(context.Background(), http.MethodGet, "/run/x", nil, nil, nil) + + var apiErr *workflowapi.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error = %v, want *workflowapi.APIError", err) + } + if apiErr.RequestID != "wf-req-7" { + t.Errorf("RequestID = %q, want %q", apiErr.RequestID, "wf-req-7") + } + want := "workflow api error 502: upstream down (request-id: wf-req-7)" + if got := apiErr.Error(); got != want { + t.Errorf("Error() = %q, want %q", got, want) + } +} + +func TestNew_ExposesProjectID(t *testing.T) { + c := workflowapi.New("https://example.invalid", "key", "proj-42") + if got := c.ProjectID(); got != "proj-42" { + t.Errorf("ProjectID() = %q, want %q", got, "proj-42") + } +}