package api import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "reflect" "strconv" "testing" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" ghmock "github.com/cli/cli/v2/internal/gh/mock" "github.com/cli/cli/v2/pkg/cmdutil" ) func generateCodespaceList(start int, end int) []*Codespace { codespacesList := []*Codespace{} for i := start; i < end; i++ { codespacesList = append(codespacesList, &Codespace{ Name: fmt.Sprintf("codespace-%d", i), }) } return codespacesList } func createFakeListEndpointServer(t *testing.T, initialTotal int, finalTotal int) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/user/codespaces" { t.Fatal("Incorrect path") } page := 1 if r.URL.Query().Get("page") != "" { page, _ = strconv.Atoi(r.URL.Query().Get("page")) } per_page := 0 if r.URL.Query().Get("per_page") != "" { per_page, _ = strconv.Atoi(r.URL.Query().Get("per_page")) } response := struct { Codespaces []*Codespace `json:"codespaces"` TotalCount int `json:"total_count"` }{ Codespaces: []*Codespace{}, TotalCount: finalTotal, } switch page { case 1: response.Codespaces = generateCodespaceList(0, per_page) response.TotalCount = initialTotal w.Header().Set("Link", fmt.Sprintf(`; rel="last", ; rel="next"`, r.Host, per_page)) case 2: response.Codespaces = generateCodespaceList(per_page, per_page*2) response.TotalCount = finalTotal w.Header().Set("Link", fmt.Sprintf(`; rel="next"`, r.Host, per_page)) case 3: response.Codespaces = generateCodespaceList(per_page*2, per_page*3-per_page/2) response.TotalCount = finalTotal default: t.Fatal("Should not check extra page") } data, _ := json.Marshal(response) fmt.Fprint(w, string(data)) })) } func createFakeCreateEndpointServer(t *testing.T, wantStatus int) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // create endpoint if r.URL.Path == "/user/codespaces" { body := r.Body if body == nil { t.Fatal("No body") } defer body.Close() var params startCreateRequest err := json.NewDecoder(body).Decode(¶ms) if err != nil { t.Fatal("error:", err) } if params.RepositoryID != 1 { t.Fatal("Expected RepositoryID to be 1. Got: ", params.RepositoryID) } if params.IdleTimeoutMinutes != 10 { t.Fatal("Expected IdleTimeoutMinutes to be 10. Got: ", params.IdleTimeoutMinutes) } if *params.RetentionPeriodMinutes != 0 { t.Fatal("Expected RetentionPeriodMinutes to be 0. Got: ", *params.RetentionPeriodMinutes) } response := Codespace{ Name: "codespace-1", DisplayName: params.DisplayName, } if wantStatus == 0 { wantStatus = http.StatusCreated } w.WriteHeader(wantStatus) enc := json.NewEncoder(w) _ = enc.Encode(&response) return } // get endpoint hit for testing pending status if r.URL.Path == "/user/codespaces/codespace-1" { response := Codespace{ Name: "codespace-1", State: CodespaceStateAvailable, } w.WriteHeader(http.StatusOK) enc := json.NewEncoder(w) _ = enc.Encode(&response) return } t.Fatal("Incorrect path") })) } func createHttpClient() (*http.Client, error) { return &http.Client{}, nil } func TestNew_APIURL_dotcomConfig(t *testing.T) { t.Setenv("GITHUB_API_URL", "") t.Setenv("GITHUB_SERVER_URL", "https://github.com") cfg := &ghmock.ConfigMock{ AuthenticationFunc: func() gh.AuthConfig { return &config.AuthConfig{} }, } f := &cmdutil.Factory{ Config: func() (gh.Config, error) { return cfg, nil }, } api := New(f) if api.githubAPI != "https://api.github.com" { t.Fatalf("expected https://api.github.com, got %s", api.githubAPI) } if len(cfg.AuthenticationCalls()) != 1 { t.Fatalf("API url was not pulled from the config") } } func TestNew_APIURL_customConfig(t *testing.T) { t.Setenv("GITHUB_API_URL", "") t.Setenv("GITHUB_SERVER_URL", "https://github.mycompany.com") cfg := &ghmock.ConfigMock{ AuthenticationFunc: func() gh.AuthConfig { authCfg := &config.AuthConfig{} authCfg.SetDefaultHost("github.mycompany.com", "GH_HOST") return authCfg }, } f := &cmdutil.Factory{ Config: func() (gh.Config, error) { return cfg, nil }, } api := New(f) if api.githubAPI != "https://github.mycompany.com/api/v3" { t.Fatalf("expected https://github.mycompany.com/api/v3, got %s", api.githubAPI) } if len(cfg.AuthenticationCalls()) != 1 { t.Fatalf("API url was not pulled from the config") } } func TestNew_APIURL_env(t *testing.T) { t.Setenv("GITHUB_API_URL", "https://api.mycompany.com") t.Setenv("GITHUB_SERVER_URL", "https://mycompany.com") cfg := &ghmock.ConfigMock{ AuthenticationFunc: func() gh.AuthConfig { return &config.AuthConfig{} }, } f := &cmdutil.Factory{ Config: func() (gh.Config, error) { return cfg, nil }, } api := New(f) if api.githubAPI != "https://api.mycompany.com" { t.Fatalf("expected https://api.mycompany.com, got %s", api.githubAPI) } if len(cfg.AuthenticationCalls()) != 0 { t.Fatalf("Configuration was checked instead of using the GITHUB_API_URL environment variable") } } func TestNew_APIURL_dotcomFallback(t *testing.T) { t.Setenv("GITHUB_API_URL", "") f := &cmdutil.Factory{ Config: func() (gh.Config, error) { return nil, errors.New("Failed to load") }, } api := New(f) if api.githubAPI != "https://api.github.com" { t.Fatalf("expected https://api.github.com, got %s", api.githubAPI) } } func TestNew_ServerURL_dotcomConfig(t *testing.T) { t.Setenv("GITHUB_SERVER_URL", "") t.Setenv("GITHUB_API_URL", "https://api.github.com") cfg := &ghmock.ConfigMock{ AuthenticationFunc: func() gh.AuthConfig { return &config.AuthConfig{} }, } f := &cmdutil.Factory{ Config: func() (gh.Config, error) { return cfg, nil }, } api := New(f) if api.githubServer != "https://github.com" { t.Fatalf("expected https://github.com, got %s", api.githubServer) } if len(cfg.AuthenticationCalls()) != 1 { t.Fatalf("Server url was not pulled from the config") } } func TestNew_ServerURL_customConfig(t *testing.T) { t.Setenv("GITHUB_SERVER_URL", "") t.Setenv("GITHUB_API_URL", "https://github.mycompany.com/api/v3") cfg := &ghmock.ConfigMock{ AuthenticationFunc: func() gh.AuthConfig { authCfg := &config.AuthConfig{} authCfg.SetDefaultHost("github.mycompany.com", "GH_HOST") return authCfg }, } f := &cmdutil.Factory{ Config: func() (gh.Config, error) { return cfg, nil }, } api := New(f) if api.githubServer != "https://github.mycompany.com" { t.Fatalf("expected https://github.mycompany.com, got %s", api.githubServer) } if len(cfg.AuthenticationCalls()) != 1 { t.Fatalf("Server url was not pulled from the config") } } func TestNew_ServerURL_env(t *testing.T) { t.Setenv("GITHUB_SERVER_URL", "https://mycompany.com") t.Setenv("GITHUB_API_URL", "https://api.mycompany.com") cfg := &ghmock.ConfigMock{ AuthenticationFunc: func() gh.AuthConfig { return &config.AuthConfig{} }, } f := &cmdutil.Factory{ Config: func() (gh.Config, error) { return cfg, nil }, } api := New(f) if api.githubServer != "https://mycompany.com" { t.Fatalf("expected https://mycompany.com, got %s", api.githubServer) } if len(cfg.AuthenticationCalls()) != 0 { t.Fatalf("Configuration was checked instead of using the GITHUB_SERVER_URL environment variable") } } func TestNew_ServerURL_dotcomFallback(t *testing.T) { t.Setenv("GITHUB_SERVER_URL", "") f := &cmdutil.Factory{ Config: func() (gh.Config, error) { return nil, errors.New("Failed to load") }, } api := New(f) if api.githubServer != "https://github.com" { t.Fatalf("expected https://github.com, got %s", api.githubServer) } } func TestCreateCodespaces(t *testing.T) { svr := createFakeCreateEndpointServer(t, http.StatusCreated) defer svr.Close() api := API{ githubAPI: svr.URL, client: createHttpClient, } ctx := context.TODO() retentionPeriod := 0 params := &CreateCodespaceParams{ RepositoryID: 1, IdleTimeoutMinutes: 10, RetentionPeriodMinutes: &retentionPeriod, } codespace, err := api.CreateCodespace(ctx, params) if err != nil { t.Fatal(err) } if codespace.Name != "codespace-1" { t.Fatalf("expected codespace-1, got %s", codespace.Name) } if codespace.DisplayName != "" { t.Fatalf("expected display name empty, got %q", codespace.DisplayName) } } func TestCreateCodespaces_displayName(t *testing.T) { svr := createFakeCreateEndpointServer(t, http.StatusCreated) defer svr.Close() api := API{ githubAPI: svr.URL, client: createHttpClient, } retentionPeriod := 0 codespace, err := api.CreateCodespace(context.Background(), &CreateCodespaceParams{ RepositoryID: 1, IdleTimeoutMinutes: 10, RetentionPeriodMinutes: &retentionPeriod, DisplayName: "clucky cuckoo", }) if err != nil { t.Fatal(err) } if codespace.DisplayName != "clucky cuckoo" { t.Fatalf("expected display name %q, got %q", "clucky cuckoo", codespace.DisplayName) } } func TestCreateCodespaces_Pending(t *testing.T) { svr := createFakeCreateEndpointServer(t, http.StatusAccepted) defer svr.Close() api := API{ githubAPI: svr.URL, client: createHttpClient, retryBackoff: 0, } ctx := context.TODO() retentionPeriod := 0 params := &CreateCodespaceParams{ RepositoryID: 1, IdleTimeoutMinutes: 10, RetentionPeriodMinutes: &retentionPeriod, } codespace, err := api.CreateCodespace(ctx, params) if err != nil { t.Fatal(err) } if codespace.Name != "codespace-1" { t.Fatalf("expected codespace-1, got %s", codespace.Name) } } func TestListCodespaces_limited(t *testing.T) { svr := createFakeListEndpointServer(t, 200, 200) defer svr.Close() api := API{ githubAPI: svr.URL, client: createHttpClient, } ctx := context.TODO() codespaces, err := api.ListCodespaces(ctx, ListCodespacesOptions{Limit: 200}) if err != nil { t.Fatal(err) } if len(codespaces) != 200 { t.Fatalf("expected 200 codespace, got %d", len(codespaces)) } if codespaces[0].Name != "codespace-0" { t.Fatalf("expected codespace-0, got %s", codespaces[0].Name) } if codespaces[199].Name != "codespace-199" { t.Fatalf("expected codespace-199, got %s", codespaces[0].Name) } } func TestListCodespaces_unlimited(t *testing.T) { svr := createFakeListEndpointServer(t, 200, 200) defer svr.Close() api := API{ githubAPI: svr.URL, client: createHttpClient, } ctx := context.TODO() codespaces, err := api.ListCodespaces(ctx, ListCodespacesOptions{}) if err != nil { t.Fatal(err) } if len(codespaces) != 250 { t.Fatalf("expected 250 codespace, got %d", len(codespaces)) } if codespaces[0].Name != "codespace-0" { t.Fatalf("expected codespace-0, got %s", codespaces[0].Name) } if codespaces[249].Name != "codespace-249" { t.Fatalf("expected codespace-249, got %s", codespaces[0].Name) } } func TestGetRepoSuggestions(t *testing.T) { tests := []struct { searchText string // The input search string queryText string // The wanted query string (based off searchText) sort string // (Optional) The RepoSearchParameters.Sort param maxRepos string // (Optional) The RepoSearchParameters.MaxRepos param }{ { searchText: "test", queryText: "test", }, { searchText: "org/repo", queryText: "repo user:org", }, { searchText: "org/repo/extra", queryText: "repo/extra user:org", }, { searchText: "test", queryText: "test", sort: "stars", maxRepos: "1000", }, } for _, tt := range tests { runRepoSearchTest(t, tt.searchText, tt.queryText, tt.sort, tt.maxRepos) } } func createFakeSearchReposServer(t *testing.T, wantSearchText string, wantSort string, wantPerPage string, responseRepos []*Repository) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/search/repositories" { t.Error("Incorrect path") return } query := r.URL.Query() got := fmt.Sprintf("q=%q sort=%s per_page=%s", query.Get("q"), query.Get("sort"), query.Get("per_page")) want := fmt.Sprintf("q=%q sort=%s per_page=%s", wantSearchText+" in:name", wantSort, wantPerPage) if got != want { t.Errorf("for query, got %s, want %s", got, want) return } response := struct { Items []*Repository `json:"items"` }{ responseRepos, } if err := json.NewEncoder(w).Encode(response); err != nil { t.Error(err) } })) } func runRepoSearchTest(t *testing.T, searchText, wantQueryText, wantSort, wantMaxRepos string) { wantRepoNames := []string{"repo1", "repo2"} apiResponseRepositories := make([]*Repository, 0) for _, name := range wantRepoNames { apiResponseRepositories = append(apiResponseRepositories, &Repository{FullName: name}) } svr := createFakeSearchReposServer(t, wantQueryText, wantSort, wantMaxRepos, apiResponseRepositories) defer svr.Close() api := API{ githubAPI: svr.URL, client: createHttpClient, } ctx := context.Background() searchParameters := RepoSearchParameters{} if len(wantSort) > 0 { searchParameters.Sort = wantSort } if len(wantMaxRepos) > 0 { searchParameters.MaxRepos, _ = strconv.Atoi(wantMaxRepos) } gotRepoNames, err := api.GetCodespaceRepoSuggestions(ctx, searchText, searchParameters) if err != nil { t.Fatal(err) } gotNamesStr := fmt.Sprintf("%v", gotRepoNames) wantNamesStr := fmt.Sprintf("%v", wantRepoNames) if gotNamesStr != wantNamesStr { t.Fatalf("got repo names %s, want %s", gotNamesStr, wantNamesStr) } } func TestRetries(t *testing.T) { var callCount int csName := "test_codespace" handler := func(w http.ResponseWriter, r *http.Request) { if callCount == 3 { err := json.NewEncoder(w).Encode(Codespace{ Name: csName, }) if err != nil { t.Fatal(err) } return } callCount++ w.WriteHeader(502) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler(w, r) })) t.Cleanup(srv.Close) a := &API{ githubAPI: srv.URL, client: createHttpClient, } cs, err := a.GetCodespace(context.Background(), "test", false) if err != nil { t.Fatal(err) } if callCount != 3 { t.Fatalf("expected at least 2 retries but got %d", callCount) } if cs.Name != csName { t.Fatalf("expected codespace name to be %q but got %q", csName, cs.Name) } callCount = 0 handler = func(w http.ResponseWriter, _ *http.Request) { callCount++ err := json.NewEncoder(w).Encode(Codespace{ Name: csName, }) if err != nil { t.Fatal(err) } } cs, err = a.GetCodespace(context.Background(), "test", false) if err != nil { t.Fatal(err) } if callCount != 1 { t.Fatalf("expected no retries but got %d calls", callCount) } if cs.Name != csName { t.Fatalf("expected codespace name to be %q but got %q", csName, cs.Name) } } func TestCodespace_ExportData(t *testing.T) { type fields struct { Name string CreatedAt string DisplayName string LastUsedAt string Owner User Repository Repository State string GitStatus CodespaceGitStatus Connection CodespaceConnection Machine CodespaceMachine } type args struct { fields []string } tests := []struct { name string fields fields args args want map[string]interface{} }{ { name: "just name", fields: fields{ Name: "test", }, args: args{ fields: []string{"name"}, }, want: map[string]interface{}{ "name": "test", }, }, { name: "just owner", fields: fields{ Owner: User{ Login: "test", }, }, args: args{ fields: []string{"owner"}, }, want: map[string]interface{}{ "owner": "test", }, }, { name: "just machine", fields: fields{ Machine: CodespaceMachine{ Name: "test", }, }, args: args{ fields: []string{"machineName"}, }, want: map[string]interface{}{ "machineName": "test", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Codespace{ Name: tt.fields.Name, CreatedAt: tt.fields.CreatedAt, DisplayName: tt.fields.DisplayName, LastUsedAt: tt.fields.LastUsedAt, Owner: tt.fields.Owner, Repository: tt.fields.Repository, State: tt.fields.State, GitStatus: tt.fields.GitStatus, Connection: tt.fields.Connection, Machine: tt.fields.Machine, } if got := c.ExportData(tt.args.fields); !reflect.DeepEqual(got, tt.want) { t.Errorf("Codespace.ExportData() = %v, want %v", got, tt.want) } }) } } func createFakeEditServer(t *testing.T, codespaceName string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { checkPath := "/user/codespaces/" + codespaceName if r.URL.Path != checkPath { t.Fatal("Incorrect path") } if r.Method != http.MethodPatch { t.Fatal("Incorrect method") } body := r.Body if body == nil { t.Fatal("No body") } defer body.Close() var data map[string]interface{} err := json.NewDecoder(body).Decode(&data) if err != nil { t.Fatal(err) } if data["display_name"] != "changeTo" { t.Fatal("Incorrect display name") } response := Codespace{ DisplayName: "changeTo", } responseData, _ := json.Marshal(response) fmt.Fprint(w, string(responseData)) })) } func TestAPI_EditCodespace(t *testing.T) { type args struct { ctx context.Context codespaceName string params *EditCodespaceParams } tests := []struct { name string args args want *Codespace wantErr bool }{ { name: "success", args: args{ ctx: context.Background(), codespaceName: "test", params: &EditCodespaceParams{ DisplayName: "changeTo", }, }, want: &Codespace{ DisplayName: "changeTo", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { svr := createFakeEditServer(t, tt.args.codespaceName) defer svr.Close() a := &API{ client: createHttpClient, githubAPI: svr.URL, } got, err := a.EditCodespace(tt.args.ctx, tt.args.codespaceName, tt.args.params) if (err != nil) != tt.wantErr { t.Errorf("API.EditCodespace() error = %v, wantErr %v", err, tt.wantErr) return } if !reflect.DeepEqual(got, tt.want) { t.Errorf("API.EditCodespace() = %v, want %v", got.DisplayName, tt.want.DisplayName) } }) } } func createFakeEditPendingOpServer() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPatch { w.WriteHeader(http.StatusUnprocessableEntity) return } if r.Method == http.MethodGet { response := Codespace{ PendingOperation: true, PendingOperationDisabledReason: "Some pending operation", } responseData, _ := json.Marshal(response) fmt.Fprint(w, string(responseData)) return } })) } func TestAPI_EditCodespacePendingOperation(t *testing.T) { svr := createFakeEditPendingOpServer() defer svr.Close() a := &API{ client: createHttpClient, githubAPI: svr.URL, } _, err := a.EditCodespace(context.Background(), "disabledCodespace", &EditCodespaceParams{DisplayName: "some silly name"}) if err == nil { t.Error("Expected pending operation error, but got nothing") } if err.Error() != "codespace is disabled while it has a pending operation: Some pending operation" { t.Errorf("Expected pending operation error, but got %v", err) } }