Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
163 changes: 163 additions & 0 deletions internal/repoapi/client_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading