github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/pr/edit/edit_test.go (about)

     1  package edit
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"net/http"
     7  	"os"
     8  	"path/filepath"
     9  	"testing"
    10  
    11  	"github.com/ungtb10d/cli/v2/api"
    12  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    13  	shared "github.com/ungtb10d/cli/v2/pkg/cmd/pr/shared"
    14  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    15  	"github.com/ungtb10d/cli/v2/pkg/httpmock"
    16  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    17  	"github.com/google/shlex"
    18  	"github.com/stretchr/testify/assert"
    19  	"github.com/stretchr/testify/require"
    20  )
    21  
    22  func TestNewCmdEdit(t *testing.T) {
    23  	tmpFile := filepath.Join(t.TempDir(), "my-body.md")
    24  	err := os.WriteFile(tmpFile, []byte("a body from file"), 0600)
    25  	require.NoError(t, err)
    26  
    27  	tests := []struct {
    28  		name     string
    29  		input    string
    30  		stdin    string
    31  		output   EditOptions
    32  		wantsErr bool
    33  	}{
    34  		{
    35  			name:  "no argument",
    36  			input: "",
    37  			output: EditOptions{
    38  				SelectorArg: "",
    39  				Interactive: true,
    40  			},
    41  			wantsErr: false,
    42  		},
    43  		{
    44  			name:     "two arguments",
    45  			input:    "1 2",
    46  			output:   EditOptions{},
    47  			wantsErr: true,
    48  		},
    49  		{
    50  			name:  "pull request number argument",
    51  			input: "23",
    52  			output: EditOptions{
    53  				SelectorArg: "23",
    54  				Interactive: true,
    55  			},
    56  			wantsErr: false,
    57  		},
    58  		{
    59  			name:  "title flag",
    60  			input: "23 --title test",
    61  			output: EditOptions{
    62  				SelectorArg: "23",
    63  				Editable: shared.Editable{
    64  					Title: shared.EditableString{
    65  						Value:  "test",
    66  						Edited: true,
    67  					},
    68  				},
    69  			},
    70  			wantsErr: false,
    71  		},
    72  		{
    73  			name:  "body flag",
    74  			input: "23 --body test",
    75  			output: EditOptions{
    76  				SelectorArg: "23",
    77  				Editable: shared.Editable{
    78  					Body: shared.EditableString{
    79  						Value:  "test",
    80  						Edited: true,
    81  					},
    82  				},
    83  			},
    84  			wantsErr: false,
    85  		},
    86  		{
    87  			name:  "body from stdin",
    88  			input: "23 --body-file -",
    89  			stdin: "this is on standard input",
    90  			output: EditOptions{
    91  				SelectorArg: "23",
    92  				Editable: shared.Editable{
    93  					Body: shared.EditableString{
    94  						Value:  "this is on standard input",
    95  						Edited: true,
    96  					},
    97  				},
    98  			},
    99  			wantsErr: false,
   100  		},
   101  		{
   102  			name:  "body from file",
   103  			input: fmt.Sprintf("23 --body-file '%s'", tmpFile),
   104  			output: EditOptions{
   105  				SelectorArg: "23",
   106  				Editable: shared.Editable{
   107  					Body: shared.EditableString{
   108  						Value:  "a body from file",
   109  						Edited: true,
   110  					},
   111  				},
   112  			},
   113  			wantsErr: false,
   114  		},
   115  		{
   116  			name:  "base flag",
   117  			input: "23 --base base-branch-name",
   118  			output: EditOptions{
   119  				SelectorArg: "23",
   120  				Editable: shared.Editable{
   121  					Base: shared.EditableString{
   122  						Value:  "base-branch-name",
   123  						Edited: true,
   124  					},
   125  				},
   126  			},
   127  			wantsErr: false,
   128  		},
   129  		{
   130  			name:  "add-reviewer flag",
   131  			input: "23 --add-reviewer monalisa,owner/core",
   132  			output: EditOptions{
   133  				SelectorArg: "23",
   134  				Editable: shared.Editable{
   135  					Reviewers: shared.EditableSlice{
   136  						Add:    []string{"monalisa", "owner/core"},
   137  						Edited: true,
   138  					},
   139  				},
   140  			},
   141  			wantsErr: false,
   142  		},
   143  		{
   144  			name:  "remove-reviewer flag",
   145  			input: "23 --remove-reviewer monalisa,owner/core",
   146  			output: EditOptions{
   147  				SelectorArg: "23",
   148  				Editable: shared.Editable{
   149  					Reviewers: shared.EditableSlice{
   150  						Remove: []string{"monalisa", "owner/core"},
   151  						Edited: true,
   152  					},
   153  				},
   154  			},
   155  			wantsErr: false,
   156  		},
   157  		{
   158  			name:  "add-assignee flag",
   159  			input: "23 --add-assignee monalisa,hubot",
   160  			output: EditOptions{
   161  				SelectorArg: "23",
   162  				Editable: shared.Editable{
   163  					Assignees: shared.EditableSlice{
   164  						Add:    []string{"monalisa", "hubot"},
   165  						Edited: true,
   166  					},
   167  				},
   168  			},
   169  			wantsErr: false,
   170  		},
   171  		{
   172  			name:  "remove-assignee flag",
   173  			input: "23 --remove-assignee monalisa,hubot",
   174  			output: EditOptions{
   175  				SelectorArg: "23",
   176  				Editable: shared.Editable{
   177  					Assignees: shared.EditableSlice{
   178  						Remove: []string{"monalisa", "hubot"},
   179  						Edited: true,
   180  					},
   181  				},
   182  			},
   183  			wantsErr: false,
   184  		},
   185  		{
   186  			name:  "add-label flag",
   187  			input: "23 --add-label feature,TODO,bug",
   188  			output: EditOptions{
   189  				SelectorArg: "23",
   190  				Editable: shared.Editable{
   191  					Labels: shared.EditableSlice{
   192  						Add:    []string{"feature", "TODO", "bug"},
   193  						Edited: true,
   194  					},
   195  				},
   196  			},
   197  			wantsErr: false,
   198  		},
   199  		{
   200  			name:  "remove-label flag",
   201  			input: "23 --remove-label feature,TODO,bug",
   202  			output: EditOptions{
   203  				SelectorArg: "23",
   204  				Editable: shared.Editable{
   205  					Labels: shared.EditableSlice{
   206  						Remove: []string{"feature", "TODO", "bug"},
   207  						Edited: true,
   208  					},
   209  				},
   210  			},
   211  			wantsErr: false,
   212  		},
   213  		{
   214  			name:  "add-project flag",
   215  			input: "23 --add-project Cleanup,Roadmap",
   216  			output: EditOptions{
   217  				SelectorArg: "23",
   218  				Editable: shared.Editable{
   219  					Projects: shared.EditableSlice{
   220  						Add:    []string{"Cleanup", "Roadmap"},
   221  						Edited: true,
   222  					},
   223  				},
   224  			},
   225  			wantsErr: false,
   226  		},
   227  		{
   228  			name:  "remove-project flag",
   229  			input: "23 --remove-project Cleanup,Roadmap",
   230  			output: EditOptions{
   231  				SelectorArg: "23",
   232  				Editable: shared.Editable{
   233  					Projects: shared.EditableSlice{
   234  						Remove: []string{"Cleanup", "Roadmap"},
   235  						Edited: true,
   236  					},
   237  				},
   238  			},
   239  			wantsErr: false,
   240  		},
   241  		{
   242  			name:  "milestone flag",
   243  			input: "23 --milestone GA",
   244  			output: EditOptions{
   245  				SelectorArg: "23",
   246  				Editable: shared.Editable{
   247  					Milestone: shared.EditableString{
   248  						Value:  "GA",
   249  						Edited: true,
   250  					},
   251  				},
   252  			},
   253  			wantsErr: false,
   254  		},
   255  	}
   256  	for _, tt := range tests {
   257  		t.Run(tt.name, func(t *testing.T) {
   258  			ios, stdin, _, _ := iostreams.Test()
   259  			ios.SetStdoutTTY(true)
   260  			ios.SetStdinTTY(true)
   261  			ios.SetStderrTTY(true)
   262  
   263  			if tt.stdin != "" {
   264  				_, _ = stdin.WriteString(tt.stdin)
   265  			}
   266  
   267  			f := &cmdutil.Factory{
   268  				IOStreams: ios,
   269  			}
   270  
   271  			argv, err := shlex.Split(tt.input)
   272  			assert.NoError(t, err)
   273  
   274  			var gotOpts *EditOptions
   275  			cmd := NewCmdEdit(f, func(opts *EditOptions) error {
   276  				gotOpts = opts
   277  				return nil
   278  			})
   279  			cmd.Flags().BoolP("help", "x", false, "")
   280  
   281  			cmd.SetArgs(argv)
   282  			cmd.SetIn(&bytes.Buffer{})
   283  			cmd.SetOut(&bytes.Buffer{})
   284  			cmd.SetErr(&bytes.Buffer{})
   285  
   286  			_, err = cmd.ExecuteC()
   287  			if tt.wantsErr {
   288  				assert.Error(t, err)
   289  				return
   290  			}
   291  
   292  			assert.NoError(t, err)
   293  			assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg)
   294  			assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
   295  			assert.Equal(t, tt.output.Editable, gotOpts.Editable)
   296  		})
   297  	}
   298  }
   299  
   300  func Test_editRun(t *testing.T) {
   301  	tests := []struct {
   302  		name      string
   303  		input     *EditOptions
   304  		httpStubs func(*testing.T, *httpmock.Registry)
   305  		stdout    string
   306  		stderr    string
   307  	}{
   308  		{
   309  			name: "non-interactive",
   310  			input: &EditOptions{
   311  				SelectorArg: "123",
   312  				Finder: shared.NewMockFinder("123", &api.PullRequest{
   313  					URL: "https://github.com/OWNER/REPO/pull/123",
   314  				}, ghrepo.New("OWNER", "REPO")),
   315  				Interactive: false,
   316  				Editable: shared.Editable{
   317  					Title: shared.EditableString{
   318  						Value:  "new title",
   319  						Edited: true,
   320  					},
   321  					Body: shared.EditableString{
   322  						Value:  "new body",
   323  						Edited: true,
   324  					},
   325  					Base: shared.EditableString{
   326  						Value:  "base-branch-name",
   327  						Edited: true,
   328  					},
   329  					Reviewers: shared.EditableSlice{
   330  						Add:    []string{"OWNER/core", "OWNER/external", "monalisa", "hubot"},
   331  						Remove: []string{"dependabot"},
   332  						Edited: true,
   333  					},
   334  					Assignees: shared.EditableSlice{
   335  						Add:    []string{"monalisa", "hubot"},
   336  						Remove: []string{"octocat"},
   337  						Edited: true,
   338  					},
   339  					Labels: shared.EditableSlice{
   340  						Add:    []string{"feature", "TODO", "bug"},
   341  						Remove: []string{"docs"},
   342  						Edited: true,
   343  					},
   344  					Projects: shared.EditableSlice{
   345  						Add:    []string{"Cleanup", "Roadmap"},
   346  						Remove: []string{"Features"},
   347  						Edited: true,
   348  					},
   349  					Milestone: shared.EditableString{
   350  						Value:  "GA",
   351  						Edited: true,
   352  					},
   353  				},
   354  				Fetcher: testFetcher{},
   355  			},
   356  			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
   357  				mockRepoMetadata(t, reg, false)
   358  				mockPullRequestUpdate(t, reg)
   359  				mockPullRequestReviewersUpdate(t, reg)
   360  				mockPullRequestUpdateLabels(t, reg)
   361  			},
   362  			stdout: "https://github.com/OWNER/REPO/pull/123\n",
   363  		},
   364  		{
   365  			name: "non-interactive skip reviewers",
   366  			input: &EditOptions{
   367  				SelectorArg: "123",
   368  				Finder: shared.NewMockFinder("123", &api.PullRequest{
   369  					URL: "https://github.com/OWNER/REPO/pull/123",
   370  				}, ghrepo.New("OWNER", "REPO")),
   371  				Interactive: false,
   372  				Editable: shared.Editable{
   373  					Title: shared.EditableString{
   374  						Value:  "new title",
   375  						Edited: true,
   376  					},
   377  					Body: shared.EditableString{
   378  						Value:  "new body",
   379  						Edited: true,
   380  					},
   381  					Base: shared.EditableString{
   382  						Value:  "base-branch-name",
   383  						Edited: true,
   384  					},
   385  					Assignees: shared.EditableSlice{
   386  						Add:    []string{"monalisa", "hubot"},
   387  						Remove: []string{"octocat"},
   388  						Edited: true,
   389  					},
   390  					Labels: shared.EditableSlice{
   391  						Add:    []string{"feature", "TODO", "bug"},
   392  						Remove: []string{"docs"},
   393  						Edited: true,
   394  					},
   395  					Projects: shared.EditableSlice{
   396  						Value:  []string{"Cleanup", "Roadmap"},
   397  						Remove: []string{"Features"},
   398  						Edited: true,
   399  					},
   400  					Milestone: shared.EditableString{
   401  						Value:  "GA",
   402  						Edited: true,
   403  					},
   404  				},
   405  				Fetcher: testFetcher{},
   406  			},
   407  			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
   408  				mockRepoMetadata(t, reg, true)
   409  				mockPullRequestUpdate(t, reg)
   410  				mockPullRequestUpdateLabels(t, reg)
   411  			},
   412  			stdout: "https://github.com/OWNER/REPO/pull/123\n",
   413  		},
   414  		{
   415  			name: "interactive",
   416  			input: &EditOptions{
   417  				SelectorArg: "123",
   418  				Finder: shared.NewMockFinder("123", &api.PullRequest{
   419  					URL: "https://github.com/OWNER/REPO/pull/123",
   420  				}, ghrepo.New("OWNER", "REPO")),
   421  				Interactive:     true,
   422  				Surveyor:        testSurveyor{},
   423  				Fetcher:         testFetcher{},
   424  				EditorRetriever: testEditorRetriever{},
   425  			},
   426  			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
   427  				mockRepoMetadata(t, reg, false)
   428  				mockPullRequestUpdate(t, reg)
   429  				mockPullRequestReviewersUpdate(t, reg)
   430  			},
   431  			stdout: "https://github.com/OWNER/REPO/pull/123\n",
   432  		},
   433  		{
   434  			name: "interactive skip reviewers",
   435  			input: &EditOptions{
   436  				SelectorArg: "123",
   437  				Finder: shared.NewMockFinder("123", &api.PullRequest{
   438  					URL: "https://github.com/OWNER/REPO/pull/123",
   439  				}, ghrepo.New("OWNER", "REPO")),
   440  				Interactive:     true,
   441  				Surveyor:        testSurveyor{skipReviewers: true},
   442  				Fetcher:         testFetcher{},
   443  				EditorRetriever: testEditorRetriever{},
   444  			},
   445  			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
   446  				mockRepoMetadata(t, reg, true)
   447  				mockPullRequestUpdate(t, reg)
   448  			},
   449  			stdout: "https://github.com/OWNER/REPO/pull/123\n",
   450  		},
   451  	}
   452  	for _, tt := range tests {
   453  		ios, _, stdout, stderr := iostreams.Test()
   454  		ios.SetStdoutTTY(true)
   455  		ios.SetStdinTTY(true)
   456  		ios.SetStderrTTY(true)
   457  
   458  		reg := &httpmock.Registry{}
   459  		defer reg.Verify(t)
   460  		tt.httpStubs(t, reg)
   461  
   462  		httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
   463  
   464  		tt.input.IO = ios
   465  		tt.input.HttpClient = httpClient
   466  
   467  		t.Run(tt.name, func(t *testing.T) {
   468  			err := editRun(tt.input)
   469  			assert.NoError(t, err)
   470  			assert.Equal(t, tt.stdout, stdout.String())
   471  			assert.Equal(t, tt.stderr, stderr.String())
   472  		})
   473  	}
   474  }
   475  
   476  func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry, skipReviewers bool) {
   477  	reg.Register(
   478  		httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
   479  		httpmock.StringResponse(`
   480  		{ "data": { "repository": { "assignableUsers": {
   481  			"nodes": [
   482  				{ "login": "hubot", "id": "HUBOTID" },
   483  				{ "login": "MonaLisa", "id": "MONAID" }
   484  			],
   485  			"pageInfo": { "hasNextPage": false }
   486  		} } } }
   487  		`))
   488  	reg.Register(
   489  		httpmock.GraphQL(`query RepositoryLabelList\b`),
   490  		httpmock.StringResponse(`
   491  		{ "data": { "repository": { "labels": {
   492  			"nodes": [
   493  				{ "name": "feature", "id": "FEATUREID" },
   494  				{ "name": "TODO", "id": "TODOID" },
   495  				{ "name": "bug", "id": "BUGID" },
   496  				{ "name": "docs", "id": "DOCSID" }
   497  			],
   498  			"pageInfo": { "hasNextPage": false }
   499  		} } } }
   500  		`))
   501  	reg.Register(
   502  		httpmock.GraphQL(`query RepositoryMilestoneList\b`),
   503  		httpmock.StringResponse(`
   504  		{ "data": { "repository": { "milestones": {
   505  			"nodes": [
   506  				{ "title": "GA", "id": "GAID" },
   507  				{ "title": "Big One.oh", "id": "BIGONEID" }
   508  			],
   509  			"pageInfo": { "hasNextPage": false }
   510  		} } } }
   511  		`))
   512  	reg.Register(
   513  		httpmock.GraphQL(`query RepositoryProjectList\b`),
   514  		httpmock.StringResponse(`
   515  		{ "data": { "repository": { "projects": {
   516  			"nodes": [
   517  				{ "name": "Cleanup", "id": "CLEANUPID" },
   518  				{ "name": "Roadmap", "id": "ROADMAPID" }
   519  			],
   520  			"pageInfo": { "hasNextPage": false }
   521  		} } } }
   522  		`))
   523  	reg.Register(
   524  		httpmock.GraphQL(`query OrganizationProjectList\b`),
   525  		httpmock.StringResponse(`
   526  		{ "data": { "organization": { "projects": {
   527  			"nodes": [
   528  				{ "name": "Triage", "id": "TRIAGEID" }
   529  			],
   530  			"pageInfo": { "hasNextPage": false }
   531  		} } } }
   532  		`))
   533  	if !skipReviewers {
   534  		reg.Register(
   535  			httpmock.GraphQL(`query OrganizationTeamList\b`),
   536  			httpmock.StringResponse(`
   537  		{ "data": { "organization": { "teams": {
   538  			"nodes": [
   539  				{ "slug": "external", "id": "EXTERNALID" },
   540  				{ "slug": "core", "id": "COREID" }
   541  			],
   542  			"pageInfo": { "hasNextPage": false }
   543  		} } } }
   544  		`))
   545  	}
   546  }
   547  
   548  func mockPullRequestUpdate(t *testing.T, reg *httpmock.Registry) {
   549  	reg.Register(
   550  		httpmock.GraphQL(`mutation PullRequestUpdate\b`),
   551  		httpmock.StringResponse(`{}`))
   552  }
   553  
   554  func mockPullRequestReviewersUpdate(t *testing.T, reg *httpmock.Registry) {
   555  	reg.Register(
   556  		httpmock.GraphQL(`mutation PullRequestUpdateRequestReviews\b`),
   557  		httpmock.StringResponse(`{}`))
   558  }
   559  
   560  func mockPullRequestUpdateLabels(t *testing.T, reg *httpmock.Registry) {
   561  	reg.Register(
   562  		httpmock.GraphQL(`mutation LabelAdd\b`),
   563  		httpmock.GraphQLMutation(`
   564  		{ "data": { "addLabelsToLabelable": { "__typename": "" } } }`,
   565  			func(inputs map[string]interface{}) {}),
   566  	)
   567  	reg.Register(
   568  		httpmock.GraphQL(`mutation LabelRemove\b`),
   569  		httpmock.GraphQLMutation(`
   570  		{ "data": { "removeLabelsFromLabelable": { "__typename": "" } } }`,
   571  			func(inputs map[string]interface{}) {}),
   572  	)
   573  }
   574  
   575  type testFetcher struct{}
   576  type testSurveyor struct {
   577  	skipReviewers bool
   578  }
   579  type testEditorRetriever struct{}
   580  
   581  func (f testFetcher) EditableOptionsFetch(client *api.Client, repo ghrepo.Interface, opts *shared.Editable) error {
   582  	return shared.FetchOptions(client, repo, opts)
   583  }
   584  
   585  func (s testSurveyor) FieldsToEdit(e *shared.Editable) error {
   586  	e.Title.Edited = true
   587  	e.Body.Edited = true
   588  	if !s.skipReviewers {
   589  		e.Reviewers.Edited = true
   590  	}
   591  	e.Assignees.Edited = true
   592  	e.Labels.Edited = true
   593  	e.Projects.Edited = true
   594  	e.Milestone.Edited = true
   595  	return nil
   596  }
   597  
   598  func (s testSurveyor) EditFields(e *shared.Editable, _ string) error {
   599  	e.Title.Value = "new title"
   600  	e.Body.Value = "new body"
   601  	if !s.skipReviewers {
   602  		e.Reviewers.Value = []string{"monalisa", "hubot", "OWNER/core", "OWNER/external"}
   603  	}
   604  	e.Assignees.Value = []string{"monalisa", "hubot"}
   605  	e.Labels.Value = []string{"feature", "TODO", "bug"}
   606  	e.Projects.Value = []string{"Cleanup", "Roadmap"}
   607  	e.Milestone.Value = "GA"
   608  	return nil
   609  }
   610  
   611  func (t testEditorRetriever) Retrieve() (string, error) {
   612  	return "vim", nil
   613  }