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 }