1package view 2 3import ( 4 "bytes" 5 "fmt" 6 "io/ioutil" 7 "net/http" 8 "testing" 9 "time" 10 11 "github.com/cli/cli/v2/internal/config" 12 "github.com/cli/cli/v2/internal/ghrepo" 13 "github.com/cli/cli/v2/internal/run" 14 "github.com/cli/cli/v2/pkg/cmdutil" 15 "github.com/cli/cli/v2/pkg/httpmock" 16 "github.com/cli/cli/v2/pkg/iostreams" 17 "github.com/cli/cli/v2/test" 18 "github.com/google/shlex" 19 "github.com/stretchr/testify/assert" 20) 21 22func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { 23 io, _, stdout, stderr := iostreams.Test() 24 io.SetStdoutTTY(isTTY) 25 io.SetStdinTTY(isTTY) 26 io.SetStderrTTY(isTTY) 27 28 factory := &cmdutil.Factory{ 29 IOStreams: io, 30 HttpClient: func() (*http.Client, error) { 31 return &http.Client{Transport: rt}, nil 32 }, 33 Config: func() (config.Config, error) { 34 return config.NewBlankConfig(), nil 35 }, 36 BaseRepo: func() (ghrepo.Interface, error) { 37 return ghrepo.New("OWNER", "REPO"), nil 38 }, 39 } 40 41 cmd := NewCmdView(factory, nil) 42 43 argv, err := shlex.Split(cli) 44 if err != nil { 45 return nil, err 46 } 47 cmd.SetArgs(argv) 48 49 cmd.SetIn(&bytes.Buffer{}) 50 cmd.SetOut(ioutil.Discard) 51 cmd.SetErr(ioutil.Discard) 52 53 _, err = cmd.ExecuteC() 54 return &test.CmdOut{ 55 OutBuf: stdout, 56 ErrBuf: stderr, 57 }, err 58} 59 60func TestIssueView_web(t *testing.T) { 61 io, _, stdout, stderr := iostreams.Test() 62 io.SetStdoutTTY(true) 63 io.SetStderrTTY(true) 64 browser := &cmdutil.TestBrowser{} 65 66 reg := &httpmock.Registry{} 67 defer reg.Verify(t) 68 69 reg.Register( 70 httpmock.GraphQL(`query IssueByNumber\b`), 71 httpmock.StringResponse(` 72 { "data": { "repository": { "hasIssuesEnabled": true, "issue": { 73 "number": 123, 74 "url": "https://github.com/OWNER/REPO/issues/123" 75 } } } } 76 `)) 77 78 _, cmdTeardown := run.Stub() 79 defer cmdTeardown(t) 80 81 err := viewRun(&ViewOptions{ 82 IO: io, 83 Browser: browser, 84 HttpClient: func() (*http.Client, error) { 85 return &http.Client{Transport: reg}, nil 86 }, 87 BaseRepo: func() (ghrepo.Interface, error) { 88 return ghrepo.New("OWNER", "REPO"), nil 89 }, 90 WebMode: true, 91 SelectorArg: "123", 92 }) 93 if err != nil { 94 t.Errorf("error running command `issue view`: %v", err) 95 } 96 97 assert.Equal(t, "", stdout.String()) 98 assert.Equal(t, "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", stderr.String()) 99 browser.Verify(t, "https://github.com/OWNER/REPO/issues/123") 100} 101 102func TestIssueView_nontty_Preview(t *testing.T) { 103 tests := map[string]struct { 104 fixture string 105 expectedOutputs []string 106 }{ 107 "Open issue without metadata": { 108 fixture: "./fixtures/issueView_preview.json", 109 expectedOutputs: []string{ 110 `title:\tix of coins`, 111 `state:\tOPEN`, 112 `comments:\t9`, 113 `author:\tmarseilles`, 114 `assignees:`, 115 `number:\t123\n`, 116 `\*\*bold story\*\*`, 117 }, 118 }, 119 "Open issue with metadata": { 120 fixture: "./fixtures/issueView_previewWithMetadata.json", 121 expectedOutputs: []string{ 122 `title:\tix of coins`, 123 `assignees:\tmarseilles, monaco`, 124 `author:\tmarseilles`, 125 `state:\tOPEN`, 126 `comments:\t9`, 127 `labels:\tone, two, three, four, five`, 128 `projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, 129 `milestone:\tuluru\n`, 130 `number:\t123\n`, 131 `\*\*bold story\*\*`, 132 }, 133 }, 134 "Open issue with empty body": { 135 fixture: "./fixtures/issueView_previewWithEmptyBody.json", 136 expectedOutputs: []string{ 137 `title:\tix of coins`, 138 `state:\tOPEN`, 139 `author:\tmarseilles`, 140 `labels:\ttarot`, 141 `number:\t123\n`, 142 }, 143 }, 144 "Closed issue": { 145 fixture: "./fixtures/issueView_previewClosedState.json", 146 expectedOutputs: []string{ 147 `title:\tix of coins`, 148 `state:\tCLOSED`, 149 `\*\*bold story\*\*`, 150 `author:\tmarseilles`, 151 `labels:\ttarot`, 152 `number:\t123\n`, 153 `\*\*bold story\*\*`, 154 }, 155 }, 156 } 157 for name, tc := range tests { 158 t.Run(name, func(t *testing.T) { 159 http := &httpmock.Registry{} 160 defer http.Verify(t) 161 162 http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) 163 164 output, err := runCommand(http, false, "123") 165 if err != nil { 166 t.Errorf("error running `issue view`: %v", err) 167 } 168 169 assert.Equal(t, "", output.Stderr()) 170 171 //nolint:staticcheck // prefer exact matchers over ExpectLines 172 test.ExpectLines(t, output.String(), tc.expectedOutputs...) 173 }) 174 } 175} 176 177func TestIssueView_tty_Preview(t *testing.T) { 178 tests := map[string]struct { 179 fixture string 180 expectedOutputs []string 181 }{ 182 "Open issue without metadata": { 183 fixture: "./fixtures/issueView_preview.json", 184 expectedOutputs: []string{ 185 `ix of coins #123`, 186 `Open.*marseilles opened about 9 years ago.*9 comments`, 187 `bold story`, 188 `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, 189 }, 190 }, 191 "Open issue with metadata": { 192 fixture: "./fixtures/issueView_previewWithMetadata.json", 193 expectedOutputs: []string{ 194 `ix of coins #123`, 195 `Open.*marseilles opened about 9 years ago.*9 comments`, 196 `8 \x{1f615} • 7 \x{1f440} • 6 \x{2764}\x{fe0f} • 5 \x{1f389} • 4 \x{1f604} • 3 \x{1f680} • 2 \x{1f44e} • 1 \x{1f44d}`, 197 `Assignees:.*marseilles, monaco\n`, 198 `Labels:.*one, two, three, four, five\n`, 199 `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, 200 `Milestone:.*uluru\n`, 201 `bold story`, 202 `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, 203 }, 204 }, 205 "Open issue with empty body": { 206 fixture: "./fixtures/issueView_previewWithEmptyBody.json", 207 expectedOutputs: []string{ 208 `ix of coins #123`, 209 `Open.*marseilles opened about 9 years ago.*9 comments`, 210 `No description provided`, 211 `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, 212 }, 213 }, 214 "Closed issue": { 215 fixture: "./fixtures/issueView_previewClosedState.json", 216 expectedOutputs: []string{ 217 `ix of coins #123`, 218 `Closed.*marseilles opened about 9 years ago.*9 comments`, 219 `bold story`, 220 `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, 221 }, 222 }, 223 } 224 for name, tc := range tests { 225 t.Run(name, func(t *testing.T) { 226 io, _, stdout, stderr := iostreams.Test() 227 io.SetStdoutTTY(true) 228 io.SetStdinTTY(true) 229 io.SetStderrTTY(true) 230 231 httpReg := &httpmock.Registry{} 232 defer httpReg.Verify(t) 233 234 httpReg.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture)) 235 236 opts := ViewOptions{ 237 IO: io, 238 Now: func() time.Time { 239 t, _ := time.Parse(time.RFC822, "03 Nov 20 15:04 UTC") 240 return t 241 }, 242 HttpClient: func() (*http.Client, error) { 243 return &http.Client{Transport: httpReg}, nil 244 }, 245 BaseRepo: func() (ghrepo.Interface, error) { 246 return ghrepo.New("OWNER", "REPO"), nil 247 }, 248 SelectorArg: "123", 249 } 250 251 err := viewRun(&opts) 252 assert.NoError(t, err) 253 254 assert.Equal(t, "", stderr.String()) 255 256 //nolint:staticcheck // prefer exact matchers over ExpectLines 257 test.ExpectLines(t, stdout.String(), tc.expectedOutputs...) 258 }) 259 } 260} 261 262func TestIssueView_web_notFound(t *testing.T) { 263 http := &httpmock.Registry{} 264 defer http.Verify(t) 265 266 http.Register( 267 httpmock.GraphQL(`query IssueByNumber\b`), 268 httpmock.StringResponse(` 269 { "errors": [ 270 { "message": "Could not resolve to an Issue with the number of 9999." } 271 ] } 272 `), 273 ) 274 275 _, cmdTeardown := run.Stub() 276 defer cmdTeardown(t) 277 278 _, err := runCommand(http, true, "-w 9999") 279 if err == nil || err.Error() != "GraphQL: Could not resolve to an Issue with the number of 9999." { 280 t.Errorf("error running command `issue view`: %v", err) 281 } 282} 283 284func TestIssueView_disabledIssues(t *testing.T) { 285 http := &httpmock.Registry{} 286 defer http.Verify(t) 287 288 http.Register( 289 httpmock.GraphQL(`query IssueByNumber\b`), 290 httpmock.StringResponse(` 291 { 292 "data": 293 { "repository": { 294 "id": "REPOID", 295 "hasIssuesEnabled": false 296 } 297 }, 298 "errors": [ 299 { 300 "type": "NOT_FOUND", 301 "path": [ 302 "repository", 303 "issue" 304 ], 305 "message": "Could not resolve to an issue or pull request with the number of 6666." 306 } 307 ] 308 } 309 `), 310 ) 311 312 _, err := runCommand(http, true, `6666`) 313 if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { 314 t.Errorf("error running command `issue view`: %v", err) 315 } 316} 317 318func TestIssueView_tty_Comments(t *testing.T) { 319 tests := map[string]struct { 320 cli string 321 fixtures map[string]string 322 expectedOutputs []string 323 wantsErr bool 324 }{ 325 "without comments flag": { 326 cli: "123", 327 fixtures: map[string]string{ 328 "IssueByNumber": "./fixtures/issueView_previewSingleComment.json", 329 }, 330 expectedOutputs: []string{ 331 `some title #123`, 332 `some body`, 333 `———————— Not showing 5 comments ————————`, 334 `marseilles \(Collaborator\) • Jan 1, 2020 • Newest comment`, 335 `Comment 5`, 336 `Use --comments to view the full conversation`, 337 `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, 338 }, 339 }, 340 "with comments flag": { 341 cli: "123 --comments", 342 fixtures: map[string]string{ 343 "IssueByNumber": "./fixtures/issueView_previewSingleComment.json", 344 "CommentsForIssue": "./fixtures/issueView_previewFullComments.json", 345 }, 346 expectedOutputs: []string{ 347 `some title #123`, 348 `some body`, 349 `monalisa • Jan 1, 2020 • Edited`, 350 `1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f} • 4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680} • 7 \x{1f44e} • 8 \x{1f44d}`, 351 `Comment 1`, 352 `johnnytest \(Contributor\) • Jan 1, 2020`, 353 `Comment 2`, 354 `elvisp \(Member\) • Jan 1, 2020`, 355 `Comment 3`, 356 `loislane \(Owner\) • Jan 1, 2020`, 357 `Comment 4`, 358 `sam-spam • This comment has been marked as spam`, 359 `marseilles \(Collaborator\) • Jan 1, 2020 • Newest comment`, 360 `Comment 5`, 361 `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, 362 }, 363 }, 364 "with invalid comments flag": { 365 cli: "123 --comments 3", 366 wantsErr: true, 367 }, 368 } 369 for name, tc := range tests { 370 t.Run(name, func(t *testing.T) { 371 http := &httpmock.Registry{} 372 defer http.Verify(t) 373 for name, file := range tc.fixtures { 374 name := fmt.Sprintf(`query %s\b`, name) 375 http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) 376 } 377 output, err := runCommand(http, true, tc.cli) 378 if tc.wantsErr { 379 assert.Error(t, err) 380 return 381 } 382 assert.NoError(t, err) 383 assert.Equal(t, "", output.Stderr()) 384 //nolint:staticcheck // prefer exact matchers over ExpectLines 385 test.ExpectLines(t, output.String(), tc.expectedOutputs...) 386 }) 387 } 388} 389 390func TestIssueView_nontty_Comments(t *testing.T) { 391 tests := map[string]struct { 392 cli string 393 fixtures map[string]string 394 expectedOutputs []string 395 wantsErr bool 396 }{ 397 "without comments flag": { 398 cli: "123", 399 fixtures: map[string]string{ 400 "IssueByNumber": "./fixtures/issueView_previewSingleComment.json", 401 }, 402 expectedOutputs: []string{ 403 `title:\tsome title`, 404 `state:\tOPEN`, 405 `author:\tmarseilles`, 406 `comments:\t6`, 407 `number:\t123`, 408 `some body`, 409 }, 410 }, 411 "with comments flag": { 412 cli: "123 --comments", 413 fixtures: map[string]string{ 414 "IssueByNumber": "./fixtures/issueView_previewSingleComment.json", 415 "CommentsForIssue": "./fixtures/issueView_previewFullComments.json", 416 }, 417 expectedOutputs: []string{ 418 `author:\tmonalisa`, 419 `association:\t`, 420 `edited:\ttrue`, 421 `Comment 1`, 422 `author:\tjohnnytest`, 423 `association:\tcontributor`, 424 `edited:\tfalse`, 425 `Comment 2`, 426 `author:\telvisp`, 427 `association:\tmember`, 428 `edited:\tfalse`, 429 `Comment 3`, 430 `author:\tloislane`, 431 `association:\towner`, 432 `edited:\tfalse`, 433 `Comment 4`, 434 `author:\tmarseilles`, 435 `association:\tcollaborator`, 436 `edited:\tfalse`, 437 `Comment 5`, 438 }, 439 }, 440 "with invalid comments flag": { 441 cli: "123 --comments 3", 442 wantsErr: true, 443 }, 444 } 445 for name, tc := range tests { 446 t.Run(name, func(t *testing.T) { 447 http := &httpmock.Registry{} 448 defer http.Verify(t) 449 for name, file := range tc.fixtures { 450 name := fmt.Sprintf(`query %s\b`, name) 451 http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) 452 } 453 output, err := runCommand(http, false, tc.cli) 454 if tc.wantsErr { 455 assert.Error(t, err) 456 return 457 } 458 assert.NoError(t, err) 459 assert.Equal(t, "", output.Stderr()) 460 //nolint:staticcheck // prefer exact matchers over ExpectLines 461 test.ExpectLines(t, output.String(), tc.expectedOutputs...) 462 }) 463 } 464} 465