github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/api/api_test.go (about)

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