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, 2020Newest 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				`monalisaJan  1, 2020Edited`,
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-spamThis comment has been marked as spam`,
359				`marseilles \(Collaborator\) • Jan  1, 2020Newest 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