package lock import ( "bytes" "io" "net/http" "strings" "testing" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/test" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) func Test_NewCmdLock(t *testing.T) { cases := []struct { name string args string want LockOptions wantErr string tty bool }{ { name: "sets reason", args: "--reason off_topic 451", want: LockOptions{ Reason: "off_topic", IssueNumber: 451, }, }, { name: "no args", wantErr: "accepts 1 arg(s), received 0", }, { name: "no flags", args: "451", want: LockOptions{ IssueNumber: 451, }, }, { name: "issue number argument", args: "451 --repo owner/repo", want: LockOptions{ IssueNumber: 451, }, }, { name: "argument is hash prefixed number", // Escaping is required here to avoid what I think is shellex treating it as a comment. args: "\\#451 --repo owner/repo", want: LockOptions{ IssueNumber: 451, }, }, { name: "argument is a URL", args: "https://github.com/cli/cli/issues/451", want: LockOptions{ IssueNumber: 451, }, }, { name: "argument cannot be parsed to an issue", args: "unparseable", wantErr: "invalid issue format: \"unparseable\"", }, { name: "bad reason", args: "--reason bad 451", wantErr: "invalid reason bad", }, { name: "bad reason tty", args: "--reason bad 451", tty: true, wantErr: "X Invalid reason: bad\n", }, { name: "interactive", args: "451", tty: true, want: LockOptions{ IssueNumber: 451, Interactive: true, }, }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { ios, _, _, _ := iostreams.Test() ios.SetStdoutTTY(tt.tty) ios.SetStdinTTY(tt.tty) ios.SetStderrTTY(tt.tty) f := &cmdutil.Factory{ IOStreams: ios, } var opts *LockOptions cmd := NewCmdLock(f, "issue", func(_ string, o *LockOptions) error { opts = o return nil }) cmd.PersistentFlags().StringP("repo", "R", "", "") argv, err := shlex.Split(tt.args) assert.NoError(t, err) cmd.SetArgs(argv) cmd.SetIn(&bytes.Buffer{}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) _, err = cmd.ExecuteC() if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) return } else { assert.NoError(t, err) } assert.Equal(t, tt.want.Reason, opts.Reason) assert.Equal(t, tt.want.IssueNumber, opts.IssueNumber) assert.Equal(t, tt.want.Interactive, opts.Interactive) }) } } func Test_NewCmdUnlock(t *testing.T) { cases := []struct { name string args string want LockOptions wantErr string tty bool }{ { name: "no args", wantErr: "accepts 1 arg(s), received 0", }, { name: "no flags", args: "451", want: LockOptions{ IssueNumber: 451, }, }, { name: "issue number argument", args: "451 --repo owner/repo", want: LockOptions{ IssueNumber: 451, }, }, { name: "argument is hash prefixed number", // Escaping is required here to avoid what I think is shellex treating it as a comment. args: "\\#451 --repo owner/repo", want: LockOptions{ IssueNumber: 451, }, }, { name: "argument is a URL", args: "https://github.com/cli/cli/issues/451", want: LockOptions{ IssueNumber: 451, }, }, { name: "argument cannot be parsed to an issue", args: "unparseable", wantErr: "invalid issue format: \"unparseable\"", }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { ios, _, _, _ := iostreams.Test() ios.SetStdoutTTY(tt.tty) ios.SetStdinTTY(tt.tty) ios.SetStderrTTY(tt.tty) f := &cmdutil.Factory{ IOStreams: ios, } var opts *LockOptions cmd := NewCmdUnlock(f, "issue", func(_ string, o *LockOptions) error { opts = o return nil }) cmd.PersistentFlags().StringP("repo", "R", "", "") argv, err := shlex.Split(tt.args) assert.NoError(t, err) cmd.SetArgs(argv) cmd.SetIn(&bytes.Buffer{}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) _, err = cmd.ExecuteC() if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) return } else { assert.NoError(t, err) } assert.Equal(t, tt.want.IssueNumber, opts.IssueNumber) }) } } func Test_runLock(t *testing.T) { cases := []struct { name string opts LockOptions promptStubs func(*testing.T, *prompter.PrompterMock) httpStubs func(*testing.T, *httpmock.Registry) wantOut string wantErrOut string wantErr string tty bool state string }{ { name: "lock issue nontty", state: Lock, opts: LockOptions{ IssueNumber: 451, ParentCmd: "issue", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 451, "__typename": "Issue" }}}}`)) reg.Register( httpmock.GraphQL(`mutation LockLockable\b`), httpmock.StringResponse(` { "data": { "lockLockable": { "lockedRecord": { "locked": true }}}}`)) }, }, { name: "lock issue tty", tty: true, opts: LockOptions{ Interactive: true, IssueNumber: 451, ParentCmd: "issue", }, state: Lock, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 451, "title": "traverse the library", "__typename": "Issue" }}}}`)) reg.Register( httpmock.GraphQL(`mutation LockLockable\b`), httpmock.StringResponse(` { "data": { "lockLockable": { "lockedRecord": { "locked": true }}}}`)) }, promptStubs: func(t *testing.T, pm *prompter.PrompterMock) { pm.SelectFunc = func(p, d string, opts []string) (int, error) { if p == "Lock reason?" { assert.Equal(t, []string{"None", "Off topic", "Resolved", "Spam", "Too heated"}, opts) return prompter.IndexFor(opts, "Too heated") } return -1, prompter.NoSuchPromptErr(p) } }, wantOut: "✓ Locked as TOO_HEATED: Issue OWNER/REPO#451 (traverse the library)\n", }, { name: "lock issue with explicit reason tty", tty: true, state: Lock, opts: LockOptions{ IssueNumber: 451, ParentCmd: "issue", Reason: "off_topic", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 451, "title": "traverse the library", "__typename": "Issue" }}}}`)) reg.Register( httpmock.GraphQL(`mutation LockLockable\b`), httpmock.StringResponse(` { "data": { "lockLockable": { "lockedRecord": { "locked": true }}}}`)) }, wantOut: "✓ Locked as OFF_TOPIC: Issue OWNER/REPO#451 (traverse the library)\n", }, { name: "unlock issue tty", tty: true, state: Unlock, opts: LockOptions{ IssueNumber: 451, ParentCmd: "issue", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 451, "locked": true, "title": "traverse the library", "__typename": "Issue" }}}}`)) reg.Register( httpmock.GraphQL(`mutation UnlockLockable\b`), httpmock.StringResponse(` { "data": { "unlockLockable": { "unlockedRecord": { "locked": false }}}}`)) }, wantOut: "✓ Unlocked: Issue OWNER/REPO#451 (traverse the library)\n", }, { name: "unlock issue nontty", state: Unlock, opts: LockOptions{ IssueNumber: 451, ParentCmd: "issue", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 451, "locked": true, "title": "traverse the library", "__typename": "Issue" }}}}`)) reg.Register( httpmock.GraphQL(`mutation UnlockLockable\b`), httpmock.StringResponse(` { "data": { "unlockLockable": { "unlockedRecord": { "locked": false }}}}`)) }, }, { name: "lock issue with explicit reason nontty", state: Lock, opts: LockOptions{ IssueNumber: 451, ParentCmd: "issue", Reason: "off_topic", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 451, "title": "traverse the library", "__typename": "Issue" }}}}`)) reg.Register( httpmock.GraphQL(`mutation LockLockable\b`), httpmock.StringResponse(` { "data": { "lockLockable": { "lockedRecord": { "locked": true }}}}`)) }, }, { name: "relock issue tty", state: Lock, opts: LockOptions{ IssueNumber: 451, ParentCmd: "issue", Reason: "off_topic", }, tty: true, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 451, "locked": true, "title": "traverse the library", "__typename": "Issue" }}}}`)) reg.Register( httpmock.GraphQL(`mutation UnlockLockable\b`), httpmock.StringResponse(` { "data": { "unlockLockable": { "unlockedRecord": { "locked": false }}}}`)) reg.Register( httpmock.GraphQL(`mutation LockLockable\b`), httpmock.StringResponse(` { "data": { "lockLockable": { "lockedRecord": { "locked": true }}}}`)) }, promptStubs: func(t *testing.T, pm *prompter.PrompterMock) { pm.ConfirmFunc = func(p string, d bool) (bool, error) { if p == "Issue OWNER/REPO#451 already locked. Unlock and lock again as OFF_TOPIC?" { return true, nil } return false, prompter.NoSuchPromptErr(p) } }, wantOut: "✓ Locked as OFF_TOPIC: Issue OWNER/REPO#451 (traverse the library)\n", }, { name: "relock issue nontty", state: Lock, opts: LockOptions{ IssueNumber: 451, ParentCmd: "issue", Reason: "off_topic", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 451, "locked": true, "title": "traverse the library", "__typename": "Issue" }}}}`)) }, wantErr: "already locked", }, { name: "lock pr nontty", state: Lock, opts: LockOptions{ IssueNumber: 451, ParentCmd: "pr", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 451, "__typename": "PullRequest" }}}}`)) reg.Register( httpmock.GraphQL(`mutation LockLockable\b`), httpmock.StringResponse(` { "data": { "lockLockable": { "lockedRecord": { "locked": true }}}}`)) }, }, { name: "lock pr tty", tty: true, opts: LockOptions{ Interactive: true, IssueNumber: 451, ParentCmd: "pr", }, state: Lock, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 451, "title": "traverse the library", "__typename": "PullRequest" }}}}`)) reg.Register( httpmock.GraphQL(`mutation LockLockable\b`), httpmock.StringResponse(` { "data": { "lockLockable": { "lockedRecord": { "locked": true }}}}`)) }, promptStubs: func(t *testing.T, pm *prompter.PrompterMock) { pm.SelectFunc = func(p, d string, opts []string) (int, error) { if p == "Lock reason?" { return prompter.IndexFor(opts, "Too heated") } return -1, prompter.NoSuchPromptErr(p) } }, wantOut: "✓ Locked as TOO_HEATED: Pull request OWNER/REPO#451 (traverse the library)\n", }, { name: "lock pr with explicit reason tty", tty: true, state: Lock, opts: LockOptions{ IssueNumber: 451, ParentCmd: "pr", Reason: "off_topic", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 451, "title": "traverse the library", "__typename": "PullRequest" }}}}`)) reg.Register( httpmock.GraphQL(`mutation LockLockable\b`), httpmock.StringResponse(` { "data": { "lockLockable": { "lockedRecord": { "locked": true }}}}`)) }, wantOut: "✓ Locked as OFF_TOPIC: Pull request OWNER/REPO#451 (traverse the library)\n", }, { name: "lock pr with explicit nontty", state: Lock, opts: LockOptions{ IssueNumber: 451, ParentCmd: "pr", Reason: "off_topic", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 451, "title": "traverse the library", "__typename": "PullRequest" }}}}`)) reg.Register( httpmock.GraphQL(`mutation LockLockable\b`), httpmock.StringResponse(` { "data": { "lockLockable": { "lockedRecord": { "locked": true }}}}`)) }, }, { name: "unlock pr tty", state: Unlock, opts: LockOptions{ IssueNumber: 451, ParentCmd: "pr", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 451, "locked": true, "title": "traverse the library", "__typename": "PullRequest" }}}}`)) reg.Register( httpmock.GraphQL(`mutation UnlockLockable\b`), httpmock.StringResponse(` { "data": { "unlockLockable": { "unlockedRecord": { "locked": false }}}}`)) }, }, { name: "unlock pr nontty", tty: true, state: Unlock, opts: LockOptions{ IssueNumber: 451, ParentCmd: "pr", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 451, "locked": true, "title": "traverse the library", "__typename": "PullRequest" }}}}`)) reg.Register( httpmock.GraphQL(`mutation UnlockLockable\b`), httpmock.StringResponse(` { "data": { "unlockLockable": { "unlockedRecord": { "locked": false }}}}`)) }, wantOut: "✓ Unlocked: Pull request OWNER/REPO#451 (traverse the library)\n", }, { name: "relock pr tty", state: Lock, opts: LockOptions{ IssueNumber: 451, ParentCmd: "pr", Reason: "off_topic", }, tty: true, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 451, "locked": true, "title": "traverse the library", "__typename": "PullRequest" }}}}`)) reg.Register( httpmock.GraphQL(`mutation UnlockLockable\b`), httpmock.StringResponse(` { "data": { "unlockLockable": { "unlockedRecord": { "locked": false }}}}`)) reg.Register( httpmock.GraphQL(`mutation LockLockable\b`), httpmock.StringResponse(` { "data": { "lockLockable": { "lockedRecord": { "locked": true }}}}`)) }, promptStubs: func(t *testing.T, pm *prompter.PrompterMock) { pm.ConfirmFunc = func(p string, d bool) (bool, error) { if p == "Pull request OWNER/REPO#451 already locked. Unlock and lock again as OFF_TOPIC?" { return true, nil } return false, prompter.NoSuchPromptErr(p) } }, wantOut: "✓ Locked as OFF_TOPIC: Pull request OWNER/REPO#451 (traverse the library)\n", }, { name: "relock pr nontty", state: Lock, opts: LockOptions{ IssueNumber: 451, ParentCmd: "pr", Reason: "off_topic", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "hasIssuesEnabled": true, "issue": { "number": 451, "locked": true, "title": "traverse the library", "__typename": "PullRequest" }}}}`)) }, wantErr: "already locked", }, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) if tt.httpStubs != nil { tt.httpStubs(t, reg) } pm := &prompter.PrompterMock{} if tt.promptStubs != nil { tt.promptStubs(t, pm) } ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.tty) ios.SetStdinTTY(tt.tty) ios.SetStderrTTY(tt.tty) tt.opts.Prompter = pm tt.opts.IO = ios tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("OWNER/REPO") } tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } err := lockRun(tt.state, &tt.opts) output := &test.CmdOut{ OutBuf: stdout, ErrBuf: stderr, } if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) } else { assert.NoError(t, err) assert.Equal(t, tt.wantOut, output.String()) assert.Equal(t, tt.wantErrOut, output.Stderr()) } }) } } func TestReasons(t *testing.T) { assert.Equal(t, len(reasons), len(reasonsApi)) for _, reason := range reasons { assert.Equal(t, strings.ToUpper(reason), string(*reasonsMap[reason])) } }