github.com/abdfnx/gh-api@v0.0.0-20210414084727-f5432eec23b8/pkg/cmd/api/api_test.go (about)

     1  package api
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"fmt"
     7  	"io/ioutil"
     8  	"net/http"
     9  	"os"
    10  	"path/filepath"
    11  	"strings"
    12  	"testing"
    13  	"time"
    14  
    15  	"github.com/MakeNowJust/heredoc"
    16  	"github.com/abdfnx/gh-api/git"
    17  	"github.com/abdfnx/gh-api/internal/config"
    18  	"github.com/abdfnx/gh-api/internal/ghrepo"
    19  	"github.com/abdfnx/gh-api/pkg/cmdutil"
    20  	"github.com/abdfnx/gh-api/pkg/iostreams"
    21  	"github.com/google/shlex"
    22  	"github.com/stretchr/testify/assert"
    23  	"github.com/stretchr/testify/require"
    24  )
    25  
    26  func Test_NewCmdApi(t *testing.T) {
    27  	f := &cmdutil.Factory{}
    28  
    29  	tests := []struct {
    30  		name     string
    31  		cli      string
    32  		wants    ApiOptions
    33  		wantsErr bool
    34  	}{
    35  		{
    36  			name: "no flags",
    37  			cli:  "graphql",
    38  			wants: ApiOptions{
    39  				Hostname:            "",
    40  				RequestMethod:       "GET",
    41  				RequestMethodPassed: false,
    42  				RequestPath:         "graphql",
    43  				RequestInputFile:    "",
    44  				RawFields:           []string(nil),
    45  				MagicFields:         []string(nil),
    46  				RequestHeaders:      []string(nil),
    47  				ShowResponseHeaders: false,
    48  				Paginate:            false,
    49  				Silent:              false,
    50  				CacheTTL:            0,
    51  				Template:            "",
    52  				FilterOutput:        "",
    53  			},
    54  			wantsErr: false,
    55  		},
    56  		{
    57  			name: "override method",
    58  			cli:  "repos/octocat/Spoon-Knife -XDELETE",
    59  			wants: ApiOptions{
    60  				Hostname:            "",
    61  				RequestMethod:       "DELETE",
    62  				RequestMethodPassed: true,
    63  				RequestPath:         "repos/octocat/Spoon-Knife",
    64  				RequestInputFile:    "",
    65  				RawFields:           []string(nil),
    66  				MagicFields:         []string(nil),
    67  				RequestHeaders:      []string(nil),
    68  				ShowResponseHeaders: false,
    69  				Paginate:            false,
    70  				Silent:              false,
    71  				CacheTTL:            0,
    72  				Template:            "",
    73  				FilterOutput:        "",
    74  			},
    75  			wantsErr: false,
    76  		},
    77  		{
    78  			name: "with fields",
    79  			cli:  "graphql -f query=QUERY -F body=@file.txt",
    80  			wants: ApiOptions{
    81  				Hostname:            "",
    82  				RequestMethod:       "GET",
    83  				RequestMethodPassed: false,
    84  				RequestPath:         "graphql",
    85  				RequestInputFile:    "",
    86  				RawFields:           []string{"query=QUERY"},
    87  				MagicFields:         []string{"body=@file.txt"},
    88  				RequestHeaders:      []string(nil),
    89  				ShowResponseHeaders: false,
    90  				Paginate:            false,
    91  				Silent:              false,
    92  				CacheTTL:            0,
    93  				Template:            "",
    94  				FilterOutput:        "",
    95  			},
    96  			wantsErr: false,
    97  		},
    98  		{
    99  			name: "with headers",
   100  			cli:  "user -H 'accept: text/plain' -i",
   101  			wants: ApiOptions{
   102  				Hostname:            "",
   103  				RequestMethod:       "GET",
   104  				RequestMethodPassed: false,
   105  				RequestPath:         "user",
   106  				RequestInputFile:    "",
   107  				RawFields:           []string(nil),
   108  				MagicFields:         []string(nil),
   109  				RequestHeaders:      []string{"accept: text/plain"},
   110  				ShowResponseHeaders: true,
   111  				Paginate:            false,
   112  				Silent:              false,
   113  				CacheTTL:            0,
   114  				Template:            "",
   115  				FilterOutput:        "",
   116  			},
   117  			wantsErr: false,
   118  		},
   119  		{
   120  			name: "with pagination",
   121  			cli:  "repos/OWNER/REPO/issues --paginate",
   122  			wants: ApiOptions{
   123  				Hostname:            "",
   124  				RequestMethod:       "GET",
   125  				RequestMethodPassed: false,
   126  				RequestPath:         "repos/OWNER/REPO/issues",
   127  				RequestInputFile:    "",
   128  				RawFields:           []string(nil),
   129  				MagicFields:         []string(nil),
   130  				RequestHeaders:      []string(nil),
   131  				ShowResponseHeaders: false,
   132  				Paginate:            true,
   133  				Silent:              false,
   134  				CacheTTL:            0,
   135  				Template:            "",
   136  				FilterOutput:        "",
   137  			},
   138  			wantsErr: false,
   139  		},
   140  		{
   141  			name: "with silenced output",
   142  			cli:  "repos/OWNER/REPO/issues --silent",
   143  			wants: ApiOptions{
   144  				Hostname:            "",
   145  				RequestMethod:       "GET",
   146  				RequestMethodPassed: false,
   147  				RequestPath:         "repos/OWNER/REPO/issues",
   148  				RequestInputFile:    "",
   149  				RawFields:           []string(nil),
   150  				MagicFields:         []string(nil),
   151  				RequestHeaders:      []string(nil),
   152  				ShowResponseHeaders: false,
   153  				Paginate:            false,
   154  				Silent:              true,
   155  				CacheTTL:            0,
   156  				Template:            "",
   157  				FilterOutput:        "",
   158  			},
   159  			wantsErr: false,
   160  		},
   161  		{
   162  			name:     "POST pagination",
   163  			cli:      "-XPOST repos/OWNER/REPO/issues --paginate",
   164  			wantsErr: true,
   165  		},
   166  		{
   167  			name: "GraphQL pagination",
   168  			cli:  "-XPOST graphql --paginate",
   169  			wants: ApiOptions{
   170  				Hostname:            "",
   171  				RequestMethod:       "POST",
   172  				RequestMethodPassed: true,
   173  				RequestPath:         "graphql",
   174  				RequestInputFile:    "",
   175  				RawFields:           []string(nil),
   176  				MagicFields:         []string(nil),
   177  				RequestHeaders:      []string(nil),
   178  				ShowResponseHeaders: false,
   179  				Paginate:            true,
   180  				Silent:              false,
   181  				CacheTTL:            0,
   182  				Template:            "",
   183  				FilterOutput:        "",
   184  			},
   185  			wantsErr: false,
   186  		},
   187  		{
   188  			name:     "input pagination",
   189  			cli:      "--input repos/OWNER/REPO/issues --paginate",
   190  			wantsErr: true,
   191  		},
   192  		{
   193  			name: "with request body from file",
   194  			cli:  "user --input myfile",
   195  			wants: ApiOptions{
   196  				Hostname:            "",
   197  				RequestMethod:       "GET",
   198  				RequestMethodPassed: false,
   199  				RequestPath:         "user",
   200  				RequestInputFile:    "myfile",
   201  				RawFields:           []string(nil),
   202  				MagicFields:         []string(nil),
   203  				RequestHeaders:      []string(nil),
   204  				ShowResponseHeaders: false,
   205  				Paginate:            false,
   206  				Silent:              false,
   207  				CacheTTL:            0,
   208  				Template:            "",
   209  				FilterOutput:        "",
   210  			},
   211  			wantsErr: false,
   212  		},
   213  		{
   214  			name:     "no arguments",
   215  			cli:      "",
   216  			wantsErr: true,
   217  		},
   218  		{
   219  			name: "with hostname",
   220  			cli:  "graphql --hostname tom.petty",
   221  			wants: ApiOptions{
   222  				Hostname:            "tom.petty",
   223  				RequestMethod:       "GET",
   224  				RequestMethodPassed: false,
   225  				RequestPath:         "graphql",
   226  				RequestInputFile:    "",
   227  				RawFields:           []string(nil),
   228  				MagicFields:         []string(nil),
   229  				RequestHeaders:      []string(nil),
   230  				ShowResponseHeaders: false,
   231  				Paginate:            false,
   232  				Silent:              false,
   233  				CacheTTL:            0,
   234  				Template:            "",
   235  				FilterOutput:        "",
   236  			},
   237  			wantsErr: false,
   238  		},
   239  		{
   240  			name: "with cache",
   241  			cli:  "user --cache 5m",
   242  			wants: ApiOptions{
   243  				Hostname:            "",
   244  				RequestMethod:       "GET",
   245  				RequestMethodPassed: false,
   246  				RequestPath:         "user",
   247  				RequestInputFile:    "",
   248  				RawFields:           []string(nil),
   249  				MagicFields:         []string(nil),
   250  				RequestHeaders:      []string(nil),
   251  				ShowResponseHeaders: false,
   252  				Paginate:            false,
   253  				Silent:              false,
   254  				CacheTTL:            time.Minute * 5,
   255  				Template:            "",
   256  				FilterOutput:        "",
   257  			},
   258  			wantsErr: false,
   259  		},
   260  		{
   261  			name: "with template",
   262  			cli:  "user -t 'hello {{.name}}'",
   263  			wants: ApiOptions{
   264  				Hostname:            "",
   265  				RequestMethod:       "GET",
   266  				RequestMethodPassed: false,
   267  				RequestPath:         "user",
   268  				RequestInputFile:    "",
   269  				RawFields:           []string(nil),
   270  				MagicFields:         []string(nil),
   271  				RequestHeaders:      []string(nil),
   272  				ShowResponseHeaders: false,
   273  				Paginate:            false,
   274  				Silent:              false,
   275  				CacheTTL:            0,
   276  				Template:            "hello {{.name}}",
   277  				FilterOutput:        "",
   278  			},
   279  			wantsErr: false,
   280  		},
   281  		{
   282  			name: "with jq filter",
   283  			cli:  "user -q .name",
   284  			wants: ApiOptions{
   285  				Hostname:            "",
   286  				RequestMethod:       "GET",
   287  				RequestMethodPassed: false,
   288  				RequestPath:         "user",
   289  				RequestInputFile:    "",
   290  				RawFields:           []string(nil),
   291  				MagicFields:         []string(nil),
   292  				RequestHeaders:      []string(nil),
   293  				ShowResponseHeaders: false,
   294  				Paginate:            false,
   295  				Silent:              false,
   296  				CacheTTL:            0,
   297  				Template:            "",
   298  				FilterOutput:        ".name",
   299  			},
   300  			wantsErr: false,
   301  		},
   302  		{
   303  			name:     "--silent with --jq",
   304  			cli:      "user --silent -q .foo",
   305  			wantsErr: true,
   306  		},
   307  		{
   308  			name:     "--silent with --template",
   309  			cli:      "user --silent -t '{{.foo}}'",
   310  			wantsErr: true,
   311  		},
   312  		{
   313  			name:     "--jq with --template",
   314  			cli:      "user --jq .foo -t '{{.foo}}'",
   315  			wantsErr: true,
   316  		},
   317  	}
   318  	for _, tt := range tests {
   319  		t.Run(tt.name, func(t *testing.T) {
   320  			var opts *ApiOptions
   321  			cmd := NewCmdApi(f, func(o *ApiOptions) error {
   322  				opts = o
   323  				return nil
   324  			})
   325  
   326  			argv, err := shlex.Split(tt.cli)
   327  			assert.NoError(t, err)
   328  			cmd.SetArgs(argv)
   329  			cmd.SetIn(&bytes.Buffer{})
   330  			cmd.SetOut(&bytes.Buffer{})
   331  			cmd.SetErr(&bytes.Buffer{})
   332  			_, err = cmd.ExecuteC()
   333  			if tt.wantsErr {
   334  				assert.Error(t, err)
   335  				return
   336  			}
   337  			assert.NoError(t, err)
   338  
   339  			assert.Equal(t, tt.wants.Hostname, opts.Hostname)
   340  			assert.Equal(t, tt.wants.RequestMethod, opts.RequestMethod)
   341  			assert.Equal(t, tt.wants.RequestMethodPassed, opts.RequestMethodPassed)
   342  			assert.Equal(t, tt.wants.RequestPath, opts.RequestPath)
   343  			assert.Equal(t, tt.wants.RequestInputFile, opts.RequestInputFile)
   344  			assert.Equal(t, tt.wants.RawFields, opts.RawFields)
   345  			assert.Equal(t, tt.wants.MagicFields, opts.MagicFields)
   346  			assert.Equal(t, tt.wants.RequestHeaders, opts.RequestHeaders)
   347  			assert.Equal(t, tt.wants.ShowResponseHeaders, opts.ShowResponseHeaders)
   348  			assert.Equal(t, tt.wants.Paginate, opts.Paginate)
   349  			assert.Equal(t, tt.wants.Silent, opts.Silent)
   350  			assert.Equal(t, tt.wants.CacheTTL, opts.CacheTTL)
   351  			assert.Equal(t, tt.wants.Template, opts.Template)
   352  			assert.Equal(t, tt.wants.FilterOutput, opts.FilterOutput)
   353  		})
   354  	}
   355  }
   356  
   357  func Test_apiRun(t *testing.T) {
   358  	tests := []struct {
   359  		name         string
   360  		options      ApiOptions
   361  		httpResponse *http.Response
   362  		err          error
   363  		stdout       string
   364  		stderr       string
   365  	}{
   366  		{
   367  			name: "success",
   368  			httpResponse: &http.Response{
   369  				StatusCode: 200,
   370  				Body:       ioutil.NopCloser(bytes.NewBufferString(`bam!`)),
   371  			},
   372  			err:    nil,
   373  			stdout: `bam!`,
   374  			stderr: ``,
   375  		},
   376  		{
   377  			name: "show response headers",
   378  			options: ApiOptions{
   379  				ShowResponseHeaders: true,
   380  			},
   381  			httpResponse: &http.Response{
   382  				Proto:      "HTTP/1.1",
   383  				Status:     "200 Okey-dokey",
   384  				StatusCode: 200,
   385  				Body:       ioutil.NopCloser(bytes.NewBufferString(`body`)),
   386  				Header:     http.Header{"Content-Type": []string{"text/plain"}},
   387  			},
   388  			err:    nil,
   389  			stdout: "HTTP/1.1 200 Okey-dokey\nContent-Type: text/plain\r\n\r\nbody",
   390  			stderr: ``,
   391  		},
   392  		{
   393  			name: "success 204",
   394  			httpResponse: &http.Response{
   395  				StatusCode: 204,
   396  				Body:       nil,
   397  			},
   398  			err:    nil,
   399  			stdout: ``,
   400  			stderr: ``,
   401  		},
   402  		{
   403  			name: "REST error",
   404  			httpResponse: &http.Response{
   405  				StatusCode: 400,
   406  				Body:       ioutil.NopCloser(bytes.NewBufferString(`{"message": "THIS IS FINE"}`)),
   407  				Header:     http.Header{"Content-Type": []string{"application/json; charset=utf-8"}},
   408  			},
   409  			err:    cmdutil.SilentError,
   410  			stdout: `{"message": "THIS IS FINE"}`,
   411  			stderr: "gh: THIS IS FINE (HTTP 400)\n",
   412  		},
   413  		{
   414  			name: "REST string errors",
   415  			httpResponse: &http.Response{
   416  				StatusCode: 400,
   417  				Body:       ioutil.NopCloser(bytes.NewBufferString(`{"errors": ["ALSO", "FINE"]}`)),
   418  				Header:     http.Header{"Content-Type": []string{"application/json; charset=utf-8"}},
   419  			},
   420  			err:    cmdutil.SilentError,
   421  			stdout: `{"errors": ["ALSO", "FINE"]}`,
   422  			stderr: "gh: ALSO\nFINE\n",
   423  		},
   424  		{
   425  			name: "GraphQL error",
   426  			options: ApiOptions{
   427  				RequestPath: "graphql",
   428  			},
   429  			httpResponse: &http.Response{
   430  				StatusCode: 200,
   431  				Body:       ioutil.NopCloser(bytes.NewBufferString(`{"errors": [{"message":"AGAIN"}, {"message":"FINE"}]}`)),
   432  				Header:     http.Header{"Content-Type": []string{"application/json; charset=utf-8"}},
   433  			},
   434  			err:    cmdutil.SilentError,
   435  			stdout: `{"errors": [{"message":"AGAIN"}, {"message":"FINE"}]}`,
   436  			stderr: "gh: AGAIN\nFINE\n",
   437  		},
   438  		{
   439  			name: "failure",
   440  			httpResponse: &http.Response{
   441  				StatusCode: 502,
   442  				Body:       ioutil.NopCloser(bytes.NewBufferString(`gateway timeout`)),
   443  			},
   444  			err:    cmdutil.SilentError,
   445  			stdout: `gateway timeout`,
   446  			stderr: "gh: HTTP 502\n",
   447  		},
   448  		{
   449  			name: "silent",
   450  			options: ApiOptions{
   451  				Silent: true,
   452  			},
   453  			httpResponse: &http.Response{
   454  				StatusCode: 200,
   455  				Body:       ioutil.NopCloser(bytes.NewBufferString(`body`)),
   456  			},
   457  			err:    nil,
   458  			stdout: ``,
   459  			stderr: ``,
   460  		},
   461  		{
   462  			name: "show response headers even when silent",
   463  			options: ApiOptions{
   464  				ShowResponseHeaders: true,
   465  				Silent:              true,
   466  			},
   467  			httpResponse: &http.Response{
   468  				Proto:      "HTTP/1.1",
   469  				Status:     "200 Okey-dokey",
   470  				StatusCode: 200,
   471  				Body:       ioutil.NopCloser(bytes.NewBufferString(`body`)),
   472  				Header:     http.Header{"Content-Type": []string{"text/plain"}},
   473  			},
   474  			err:    nil,
   475  			stdout: "HTTP/1.1 200 Okey-dokey\nContent-Type: text/plain\r\n\r\n",
   476  			stderr: ``,
   477  		},
   478  		{
   479  			name: "output template",
   480  			options: ApiOptions{
   481  				Template: `{{.status}}`,
   482  			},
   483  			httpResponse: &http.Response{
   484  				StatusCode: 200,
   485  				Body:       ioutil.NopCloser(bytes.NewBufferString(`{"status":"not a cat"}`)),
   486  				Header:     http.Header{"Content-Type": []string{"application/json"}},
   487  			},
   488  			err:    nil,
   489  			stdout: "not a cat",
   490  			stderr: ``,
   491  		},
   492  		{
   493  			name: "jq filter",
   494  			options: ApiOptions{
   495  				FilterOutput: `.[].name`,
   496  			},
   497  			httpResponse: &http.Response{
   498  				StatusCode: 200,
   499  				Body:       ioutil.NopCloser(bytes.NewBufferString(`[{"name":"Mona"},{"name":"Hubot"}]`)),
   500  				Header:     http.Header{"Content-Type": []string{"application/json"}},
   501  			},
   502  			err:    nil,
   503  			stdout: "Mona\nHubot\n",
   504  			stderr: ``,
   505  		},
   506  	}
   507  
   508  	for _, tt := range tests {
   509  		t.Run(tt.name, func(t *testing.T) {
   510  			io, _, stdout, stderr := iostreams.Test()
   511  
   512  			tt.options.IO = io
   513  			tt.options.Config = func() (config.Config, error) { return config.NewBlankConfig(), nil }
   514  			tt.options.HttpClient = func() (*http.Client, error) {
   515  				var tr roundTripper = func(req *http.Request) (*http.Response, error) {
   516  					resp := tt.httpResponse
   517  					resp.Request = req
   518  					return resp, nil
   519  				}
   520  				return &http.Client{Transport: tr}, nil
   521  			}
   522  
   523  			err := apiRun(&tt.options)
   524  			if err != tt.err {
   525  				t.Errorf("expected error %v, got %v", tt.err, err)
   526  			}
   527  
   528  			if stdout.String() != tt.stdout {
   529  				t.Errorf("expected output %q, got %q", tt.stdout, stdout.String())
   530  			}
   531  			if stderr.String() != tt.stderr {
   532  				t.Errorf("expected error output %q, got %q", tt.stderr, stderr.String())
   533  			}
   534  		})
   535  	}
   536  }
   537  
   538  func Test_apiRun_paginationREST(t *testing.T) {
   539  	io, _, stdout, stderr := iostreams.Test()
   540  
   541  	requestCount := 0
   542  	responses := []*http.Response{
   543  		{
   544  			StatusCode: 200,
   545  			Body:       ioutil.NopCloser(bytes.NewBufferString(`{"page":1}`)),
   546  			Header: http.Header{
   547  				"Link": []string{`<https://api.github.com/repositories/1227/issues?page=2>; rel="next", <https://api.github.com/repositories/1227/issues?page=3>; rel="last"`},
   548  			},
   549  		},
   550  		{
   551  			StatusCode: 200,
   552  			Body:       ioutil.NopCloser(bytes.NewBufferString(`{"page":2}`)),
   553  			Header: http.Header{
   554  				"Link": []string{`<https://api.github.com/repositories/1227/issues?page=3>; rel="next", <https://api.github.com/repositories/1227/issues?page=3>; rel="last"`},
   555  			},
   556  		},
   557  		{
   558  			StatusCode: 200,
   559  			Body:       ioutil.NopCloser(bytes.NewBufferString(`{"page":3}`)),
   560  			Header:     http.Header{},
   561  		},
   562  	}
   563  
   564  	options := ApiOptions{
   565  		IO: io,
   566  		HttpClient: func() (*http.Client, error) {
   567  			var tr roundTripper = func(req *http.Request) (*http.Response, error) {
   568  				resp := responses[requestCount]
   569  				resp.Request = req
   570  				requestCount++
   571  				return resp, nil
   572  			}
   573  			return &http.Client{Transport: tr}, nil
   574  		},
   575  		Config: func() (config.Config, error) {
   576  			return config.NewBlankConfig(), nil
   577  		},
   578  
   579  		RequestPath: "issues",
   580  		Paginate:    true,
   581  	}
   582  
   583  	err := apiRun(&options)
   584  	assert.NoError(t, err)
   585  
   586  	assert.Equal(t, `{"page":1}{"page":2}{"page":3}`, stdout.String(), "stdout")
   587  	assert.Equal(t, "", stderr.String(), "stderr")
   588  
   589  	assert.Equal(t, "https://api.github.com/issues?per_page=100", responses[0].Request.URL.String())
   590  	assert.Equal(t, "https://api.github.com/repositories/1227/issues?page=2", responses[1].Request.URL.String())
   591  	assert.Equal(t, "https://api.github.com/repositories/1227/issues?page=3", responses[2].Request.URL.String())
   592  }
   593  
   594  func Test_apiRun_paginationGraphQL(t *testing.T) {
   595  	io, _, stdout, stderr := iostreams.Test()
   596  
   597  	requestCount := 0
   598  	responses := []*http.Response{
   599  		{
   600  			StatusCode: 200,
   601  			Header:     http.Header{"Content-Type": []string{`application/json`}},
   602  			Body: ioutil.NopCloser(bytes.NewBufferString(`{
   603  				"data": {
   604  					"nodes": ["page one"],
   605  					"pageInfo": {
   606  						"endCursor": "PAGE1_END",
   607  						"hasNextPage": true
   608  					}
   609  				}
   610  			}`)),
   611  		},
   612  		{
   613  			StatusCode: 200,
   614  			Header:     http.Header{"Content-Type": []string{`application/json`}},
   615  			Body: ioutil.NopCloser(bytes.NewBufferString(`{
   616  				"data": {
   617  					"nodes": ["page two"],
   618  					"pageInfo": {
   619  						"endCursor": "PAGE2_END",
   620  						"hasNextPage": false
   621  					}
   622  				}
   623  			}`)),
   624  		},
   625  	}
   626  
   627  	options := ApiOptions{
   628  		IO: io,
   629  		HttpClient: func() (*http.Client, error) {
   630  			var tr roundTripper = func(req *http.Request) (*http.Response, error) {
   631  				resp := responses[requestCount]
   632  				resp.Request = req
   633  				requestCount++
   634  				return resp, nil
   635  			}
   636  			return &http.Client{Transport: tr}, nil
   637  		},
   638  		Config: func() (config.Config, error) {
   639  			return config.NewBlankConfig(), nil
   640  		},
   641  
   642  		RequestMethod: "POST",
   643  		RequestPath:   "graphql",
   644  		Paginate:      true,
   645  	}
   646  
   647  	err := apiRun(&options)
   648  	require.NoError(t, err)
   649  
   650  	assert.Contains(t, stdout.String(), `"page one"`)
   651  	assert.Contains(t, stdout.String(), `"page two"`)
   652  	assert.Equal(t, "", stderr.String(), "stderr")
   653  
   654  	var requestData struct {
   655  		Variables map[string]interface{}
   656  	}
   657  
   658  	bb, err := ioutil.ReadAll(responses[0].Request.Body)
   659  	require.NoError(t, err)
   660  	err = json.Unmarshal(bb, &requestData)
   661  	require.NoError(t, err)
   662  	_, hasCursor := requestData.Variables["endCursor"].(string)
   663  	assert.Equal(t, false, hasCursor)
   664  
   665  	bb, err = ioutil.ReadAll(responses[1].Request.Body)
   666  	require.NoError(t, err)
   667  	err = json.Unmarshal(bb, &requestData)
   668  	require.NoError(t, err)
   669  	endCursor, hasCursor := requestData.Variables["endCursor"].(string)
   670  	assert.Equal(t, true, hasCursor)
   671  	assert.Equal(t, "PAGE1_END", endCursor)
   672  }
   673  
   674  func Test_apiRun_inputFile(t *testing.T) {
   675  	tests := []struct {
   676  		name          string
   677  		inputFile     string
   678  		inputContents []byte
   679  
   680  		contentLength    int64
   681  		expectedContents []byte
   682  	}{
   683  		{
   684  			name:          "stdin",
   685  			inputFile:     "-",
   686  			inputContents: []byte("I WORK OUT"),
   687  			contentLength: 0,
   688  		},
   689  		{
   690  			name:          "from file",
   691  			inputFile:     "gh-test-file",
   692  			inputContents: []byte("I WORK OUT"),
   693  			contentLength: 10,
   694  		},
   695  	}
   696  	for _, tt := range tests {
   697  		t.Run(tt.name, func(t *testing.T) {
   698  			io, stdin, _, _ := iostreams.Test()
   699  			resp := &http.Response{StatusCode: 204}
   700  
   701  			inputFile := tt.inputFile
   702  			if tt.inputFile == "-" {
   703  				_, _ = stdin.Write(tt.inputContents)
   704  			} else {
   705  				f, err := ioutil.TempFile("", tt.inputFile)
   706  				if err != nil {
   707  					t.Fatal(err)
   708  				}
   709  				_, _ = f.Write(tt.inputContents)
   710  				f.Close()
   711  				t.Cleanup(func() { os.Remove(f.Name()) })
   712  				inputFile = f.Name()
   713  			}
   714  
   715  			var bodyBytes []byte
   716  			options := ApiOptions{
   717  				RequestPath:      "hello",
   718  				RequestInputFile: inputFile,
   719  				RawFields:        []string{"a=b", "c=d"},
   720  
   721  				IO: io,
   722  				HttpClient: func() (*http.Client, error) {
   723  					var tr roundTripper = func(req *http.Request) (*http.Response, error) {
   724  						var err error
   725  						if bodyBytes, err = ioutil.ReadAll(req.Body); err != nil {
   726  							return nil, err
   727  						}
   728  						resp.Request = req
   729  						return resp, nil
   730  					}
   731  					return &http.Client{Transport: tr}, nil
   732  				},
   733  				Config: func() (config.Config, error) {
   734  					return config.NewBlankConfig(), nil
   735  				},
   736  			}
   737  
   738  			err := apiRun(&options)
   739  			if err != nil {
   740  				t.Errorf("got error %v", err)
   741  			}
   742  
   743  			assert.Equal(t, "POST", resp.Request.Method)
   744  			assert.Equal(t, "/hello?a=b&c=d", resp.Request.URL.RequestURI())
   745  			assert.Equal(t, tt.contentLength, resp.Request.ContentLength)
   746  			assert.Equal(t, "", resp.Request.Header.Get("Content-Type"))
   747  			assert.Equal(t, tt.inputContents, bodyBytes)
   748  		})
   749  	}
   750  }
   751  
   752  func Test_apiRun_cache(t *testing.T) {
   753  	io, _, stdout, stderr := iostreams.Test()
   754  
   755  	requestCount := 0
   756  	options := ApiOptions{
   757  		IO: io,
   758  		HttpClient: func() (*http.Client, error) {
   759  			var tr roundTripper = func(req *http.Request) (*http.Response, error) {
   760  				requestCount++
   761  				return &http.Response{
   762  					Request:    req,
   763  					StatusCode: 204,
   764  				}, nil
   765  			}
   766  			return &http.Client{Transport: tr}, nil
   767  		},
   768  		Config: func() (config.Config, error) {
   769  			return config.NewBlankConfig(), nil
   770  		},
   771  
   772  		RequestPath: "issues",
   773  		CacheTTL:    time.Minute,
   774  	}
   775  
   776  	t.Cleanup(func() {
   777  		cacheDir := filepath.Join(os.TempDir(), "gh-cli-cache")
   778  		os.RemoveAll(cacheDir)
   779  	})
   780  
   781  	err := apiRun(&options)
   782  	assert.NoError(t, err)
   783  	err = apiRun(&options)
   784  	assert.NoError(t, err)
   785  
   786  	assert.Equal(t, 1, requestCount)
   787  	assert.Equal(t, "", stdout.String(), "stdout")
   788  	assert.Equal(t, "", stderr.String(), "stderr")
   789  }
   790  
   791  func Test_parseFields(t *testing.T) {
   792  	io, stdin, _, _ := iostreams.Test()
   793  	fmt.Fprint(stdin, "pasted contents")
   794  
   795  	opts := ApiOptions{
   796  		IO: io,
   797  		RawFields: []string{
   798  			"robot=Hubot",
   799  			"destroyer=false",
   800  			"helper=true",
   801  			"location=@work",
   802  		},
   803  		MagicFields: []string{
   804  			"input=@-",
   805  			"enabled=true",
   806  			"victories=123",
   807  		},
   808  	}
   809  
   810  	params, err := parseFields(&opts)
   811  	if err != nil {
   812  		t.Fatalf("parseFields error: %v", err)
   813  	}
   814  
   815  	expect := map[string]interface{}{
   816  		"robot":     "Hubot",
   817  		"destroyer": "false",
   818  		"helper":    "true",
   819  		"location":  "@work",
   820  		"input":     []byte("pasted contents"),
   821  		"enabled":   true,
   822  		"victories": 123,
   823  	}
   824  	assert.Equal(t, expect, params)
   825  }
   826  
   827  func Test_magicFieldValue(t *testing.T) {
   828  	f, err := ioutil.TempFile("", "gh-test")
   829  	if err != nil {
   830  		t.Fatal(err)
   831  	}
   832  	fmt.Fprint(f, "file contents")
   833  	f.Close()
   834  	t.Cleanup(func() { os.Remove(f.Name()) })
   835  
   836  	io, _, _, _ := iostreams.Test()
   837  
   838  	type args struct {
   839  		v    string
   840  		opts *ApiOptions
   841  	}
   842  	tests := []struct {
   843  		name    string
   844  		args    args
   845  		want    interface{}
   846  		wantErr bool
   847  	}{
   848  		{
   849  			name:    "string",
   850  			args:    args{v: "hello"},
   851  			want:    "hello",
   852  			wantErr: false,
   853  		},
   854  		{
   855  			name:    "bool true",
   856  			args:    args{v: "true"},
   857  			want:    true,
   858  			wantErr: false,
   859  		},
   860  		{
   861  			name:    "bool false",
   862  			args:    args{v: "false"},
   863  			want:    false,
   864  			wantErr: false,
   865  		},
   866  		{
   867  			name:    "null",
   868  			args:    args{v: "null"},
   869  			want:    nil,
   870  			wantErr: false,
   871  		},
   872  		{
   873  			name: "placeholder",
   874  			args: args{
   875  				v: ":owner",
   876  				opts: &ApiOptions{
   877  					IO: io,
   878  					BaseRepo: func() (ghrepo.Interface, error) {
   879  						return ghrepo.New("hubot", "robot-uprising"), nil
   880  					},
   881  				},
   882  			},
   883  			want:    "hubot",
   884  			wantErr: false,
   885  		},
   886  		{
   887  			name: "file",
   888  			args: args{
   889  				v:    "@" + f.Name(),
   890  				opts: &ApiOptions{IO: io},
   891  			},
   892  			want:    []byte("file contents"),
   893  			wantErr: false,
   894  		},
   895  		{
   896  			name: "file error",
   897  			args: args{
   898  				v:    "@",
   899  				opts: &ApiOptions{IO: io},
   900  			},
   901  			want:    nil,
   902  			wantErr: true,
   903  		},
   904  	}
   905  	for _, tt := range tests {
   906  		t.Run(tt.name, func(t *testing.T) {
   907  			got, err := magicFieldValue(tt.args.v, tt.args.opts)
   908  			if (err != nil) != tt.wantErr {
   909  				t.Errorf("magicFieldValue() error = %v, wantErr %v", err, tt.wantErr)
   910  				return
   911  			}
   912  			if tt.wantErr {
   913  				return
   914  			}
   915  			assert.Equal(t, tt.want, got)
   916  		})
   917  	}
   918  }
   919  
   920  func Test_openUserFile(t *testing.T) {
   921  	f, err := ioutil.TempFile("", "gh-test")
   922  	if err != nil {
   923  		t.Fatal(err)
   924  	}
   925  	fmt.Fprint(f, "file contents")
   926  	f.Close()
   927  	t.Cleanup(func() { os.Remove(f.Name()) })
   928  
   929  	file, length, err := openUserFile(f.Name(), nil)
   930  	if err != nil {
   931  		t.Fatal(err)
   932  	}
   933  	defer file.Close()
   934  
   935  	fb, err := ioutil.ReadAll(file)
   936  	if err != nil {
   937  		t.Fatal(err)
   938  	}
   939  
   940  	assert.Equal(t, int64(13), length)
   941  	assert.Equal(t, "file contents", string(fb))
   942  }
   943  
   944  func Test_fillPlaceholders(t *testing.T) {
   945  	type args struct {
   946  		value string
   947  		opts  *ApiOptions
   948  	}
   949  	tests := []struct {
   950  		name    string
   951  		args    args
   952  		want    string
   953  		wantErr bool
   954  	}{
   955  		{
   956  			name: "no changes",
   957  			args: args{
   958  				value: "repos/owner/repo/releases",
   959  				opts: &ApiOptions{
   960  					BaseRepo: nil,
   961  				},
   962  			},
   963  			want:    "repos/owner/repo/releases",
   964  			wantErr: false,
   965  		},
   966  		{
   967  			name: "has substitutes",
   968  			args: args{
   969  				value: "repos/:owner/:repo/releases",
   970  				opts: &ApiOptions{
   971  					BaseRepo: func() (ghrepo.Interface, error) {
   972  						return ghrepo.New("hubot", "robot-uprising"), nil
   973  					},
   974  				},
   975  			},
   976  			want:    "repos/hubot/robot-uprising/releases",
   977  			wantErr: false,
   978  		},
   979  		{
   980  			name: "has branch placeholder",
   981  			args: args{
   982  				value: "repos/abdfnx/gh-api/branches/:branch/protection/required_status_checks",
   983  				opts: &ApiOptions{
   984  					BaseRepo: func() (ghrepo.Interface, error) {
   985  						return ghrepo.New("cli", "cli"), nil
   986  					},
   987  					Branch: func() (string, error) {
   988  						return "trunk", nil
   989  					},
   990  				},
   991  			},
   992  			want:    "repos/abdfnx/gh-api/branches/trunk/protection/required_status_checks",
   993  			wantErr: false,
   994  		},
   995  		{
   996  			name: "has branch placeholder and git is in detached head",
   997  			args: args{
   998  				value: "repos/:owner/:repo/branches/:branch",
   999  				opts: &ApiOptions{
  1000  					BaseRepo: func() (ghrepo.Interface, error) {
  1001  						return ghrepo.New("cli", "cli"), nil
  1002  					},
  1003  					Branch: func() (string, error) {
  1004  						return "", git.ErrNotOnAnyBranch
  1005  					},
  1006  				},
  1007  			},
  1008  			want:    "repos/:owner/:repo/branches/:branch",
  1009  			wantErr: true,
  1010  		},
  1011  		{
  1012  			name: "no greedy substitutes",
  1013  			args: args{
  1014  				value: ":ownership/:repository",
  1015  				opts: &ApiOptions{
  1016  					BaseRepo: nil,
  1017  				},
  1018  			},
  1019  			want:    ":ownership/:repository",
  1020  			wantErr: false,
  1021  		},
  1022  	}
  1023  	for _, tt := range tests {
  1024  		t.Run(tt.name, func(t *testing.T) {
  1025  			got, err := fillPlaceholders(tt.args.value, tt.args.opts)
  1026  			if (err != nil) != tt.wantErr {
  1027  				t.Errorf("fillPlaceholders() error = %v, wantErr %v", err, tt.wantErr)
  1028  				return
  1029  			}
  1030  			if got != tt.want {
  1031  				t.Errorf("fillPlaceholders() got = %v, want %v", got, tt.want)
  1032  			}
  1033  		})
  1034  	}
  1035  }
  1036  
  1037  func Test_previewNamesToMIMETypes(t *testing.T) {
  1038  	tests := []struct {
  1039  		name     string
  1040  		previews []string
  1041  		want     string
  1042  	}{
  1043  		{
  1044  			name:     "single",
  1045  			previews: []string{"nebula"},
  1046  			want:     "application/vnd.github.nebula-preview+json",
  1047  		},
  1048  		{
  1049  			name:     "multiple",
  1050  			previews: []string{"nebula", "baptiste", "squirrel-girl"},
  1051  			want:     "application/vnd.github.nebula-preview+json, application/vnd.github.baptiste-preview, application/vnd.github.squirrel-girl-preview",
  1052  		},
  1053  	}
  1054  	for _, tt := range tests {
  1055  		t.Run(tt.name, func(t *testing.T) {
  1056  			if got := previewNamesToMIMETypes(tt.previews); got != tt.want {
  1057  				t.Errorf("previewNamesToMIMETypes() = %q, want %q", got, tt.want)
  1058  			}
  1059  		})
  1060  	}
  1061  }
  1062  
  1063  func Test_processResponse_template(t *testing.T) {
  1064  	io, _, stdout, stderr := iostreams.Test()
  1065  
  1066  	resp := http.Response{
  1067  		StatusCode: 200,
  1068  		Header: map[string][]string{
  1069  			"Content-Type": {"application/json"},
  1070  		},
  1071  		Body: ioutil.NopCloser(strings.NewReader(`[
  1072  			{
  1073  				"title": "First title",
  1074  				"labels": [{"name":"bug"}, {"name":"help wanted"}]
  1075  			},
  1076  			{
  1077  				"title": "Second but not last"
  1078  			},
  1079  			{
  1080  				"title": "Alas, tis' the end",
  1081  				"labels": [{}, {"name":"feature"}]
  1082  			}
  1083  		]`)),
  1084  	}
  1085  
  1086  	_, err := processResponse(&resp, &ApiOptions{
  1087  		IO:       io,
  1088  		Template: `{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " }}){{"\n"}}{{end}}`,
  1089  	}, ioutil.Discard)
  1090  	require.NoError(t, err)
  1091  
  1092  	assert.Equal(t, heredoc.Doc(`
  1093  		First title (bug, help wanted)
  1094  		Second but not last ()
  1095  		Alas, tis' the end (, feature)
  1096  	`), stdout.String())
  1097  	assert.Equal(t, "", stderr.String())
  1098  }