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 }