diff --git a/pkg/cmd/discussion/client/client_impl_test.go b/pkg/cmd/discussion/client/client_impl_test.go index 0a6392798..462d84915 100644 --- a/pkg/cmd/discussion/client/client_impl_test.go +++ b/pkg/cmd/discussion/client/client_impl_test.go @@ -1006,3 +1006,578 @@ func TestListCategories(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// GetByNumber +// --------------------------------------------------------------------------- + +// getByNumberResp builds a mock DiscussionMinimal JSON response. +func getByNumberResp(hasDiscussions bool, commentTotal int, node string) string { + return heredoc.Docf(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": %t, + "discussion": %s + } + } + } + `, hasDiscussions, wrapCommentTotal(node, commentTotal)) +} + +// wrapCommentTotal merges a `comments` block into a discussion JSON node. +func wrapCommentTotal(node string, total int) string { + // Insert "comments":{"totalCount": N} right before the closing brace. + trimmed := strings.TrimRight(node, " \t\n") + trimmed = trimmed[:len(trimmed)-1] // strip trailing } + return fmt.Sprintf(`%s, "comments": {"totalCount": %d}}`, trimmed, total) +} + +func TestGetByNumber(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + tests := []struct { + name string + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantDisc func(*testing.T, *Discussion) + }{ + { + name: "maps all fields", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + node := heredoc.Doc(` + { + "id": "D_1", + "number": 42, + "title": "Test Discussion", + "body": "This is a test", + "url": "https://github.com/OWNER/REPO/discussions/42", + "closed": false, + "stateReason": "", + "isAnswered": false, + "answerChosenAt": "0001-01-01T00:00:00Z", + "author": {"__typename": "User", "login": "alice"}, + "category": {"id": "C1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false}, + "answerChosenBy": null, + "labels": {"nodes": []}, + "reactionGroups": [{"content": "THUMBS_UP", "users": {"totalCount": 3}}], + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-02T00:00:00Z", + "closedAt": "0001-01-01T00:00:00Z", + "locked": false + } + `) + reg.Register( + httpmock.GraphQL(`query DiscussionMinimal\b`), + httpmock.StringResponse(getByNumberResp(true, 5, node)), + ) + }, + wantDisc: func(t *testing.T, d *Discussion) { + assert.Equal(t, "D_1", d.ID) + assert.Equal(t, 42, d.Number) + assert.Equal(t, "Test Discussion", d.Title) + assert.Equal(t, "This is a test", d.Body) + assert.Equal(t, "alice", d.Author.Login) + assert.Equal(t, 5, d.Comments.TotalCount) + require.Len(t, d.ReactionGroups, 1) + assert.Equal(t, "THUMBS_UP", d.ReactionGroups[0].Content) + assert.Equal(t, 3, d.ReactionGroups[0].TotalCount) + }, + }, + { + name: "discussions disabled", + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + node := minimalNode("D_1", "Test") + reg.Register( + httpmock.GraphQL(`query DiscussionMinimal\b`), + httpmock.StringResponse(getByNumberResp(false, 0, node)), + ) + }, + wantErr: "discussions disabled", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + + c := newTestDiscussionClient(reg) + d, err := c.GetByNumber(repo, 42) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, d) + if tt.wantDisc != nil { + tt.wantDisc(t, d) + } + }) + } +} + +// --------------------------------------------------------------------------- +// GetWithComments +// --------------------------------------------------------------------------- + +// getWithCommentsResp builds a mock DiscussionWithComments JSON response. +func getWithCommentsResp(hasDiscussions bool, node string, commentNodes string, commentTotal int, hasNext, hasPrev bool, endCursor, startCursor string) string { + return heredoc.Docf(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": %t, + "discussion": %s + } + } + } + `, hasDiscussions, wrapCommentsBlock(node, commentNodes, commentTotal, hasNext, hasPrev, endCursor, startCursor)) +} + +func wrapCommentsBlock(node string, commentNodes string, total int, hasNext, hasPrev bool, endCursor, startCursor string) string { + trimmed := strings.TrimRight(node, " \t\n") + trimmed = trimmed[:len(trimmed)-1] + return fmt.Sprintf(`%s, "comments": {"totalCount": %d, "pageInfo": {"endCursor": %q, "hasNextPage": %t, "startCursor": %q, "hasPreviousPage": %t}, "nodes": [%s]}}`, + trimmed, total, endCursor, hasNext, startCursor, hasPrev, commentNodes) +} + +// commentNode builds a JSON comment node with nested replies. +func commentNode(id, login, body string, isAnswer bool, replyNodes string, replyTotal int) string { + return heredoc.Docf(` + { + "id": %q, + "url": "https://github.com/OWNER/REPO/discussions/1#comment-%s", + "author": {"__typename": "User", "login": %q}, + "body": %q, + "createdAt": "2025-01-01T00:00:00Z", + "isAnswer": %t, + "upvoteCount": 0, + "reactionGroups": [], + "replies": {"totalCount": %d, "nodes": [%s]} + } + `, id, id, login, body, isAnswer, replyTotal, replyNodes) +} + +// replyNode builds a JSON reply node. +func replyNode(id, login, body string) string { + return heredoc.Docf(` + { + "id": %q, + "url": "https://github.com/OWNER/REPO/discussions/1#reply-%s", + "author": {"__typename": "User", "login": %q}, + "body": %q, + "createdAt": "2025-02-01T00:00:00Z", + "isAnswer": false, + "upvoteCount": 0, + "reactionGroups": [] + } + `, id, id, login, body) +} + +func TestGetWithComments(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + tests := []struct { + name string + limit int + after string + newest bool + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantComments int + wantTotal int + wantCursor string + wantNext string + wantDirection DiscussionCommentListDirection + wantDisc func(*testing.T, *Discussion) + }{ + { + name: "maps comments with replies", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reply := replyNode("R1", "hubot", "Thanks!") + comment := commentNode("C1", "octocat", "Main comment", true, reply, 1) + node := minimalNode("D_1", "Test Discussion") + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(getWithCommentsResp(true, node, comment, 1, false, false, "", "")), + ) + }, + wantComments: 1, + wantTotal: 1, + wantDirection: DiscussionCommentListDirectionForward, + wantDisc: func(t *testing.T, d *Discussion) { + c := d.Comments.Comments[0] + assert.Equal(t, "C1", c.ID) + assert.Equal(t, "octocat", c.Author.Login) + assert.True(t, c.IsAnswer) + require.Len(t, c.Replies.Comments, 1) + assert.Equal(t, "R1", c.Replies.Comments[0].ID) + assert.Equal(t, "hubot", c.Replies.Comments[0].Author.Login) + }, + }, + { + name: "pagination forward", + limit: 5, + after: "CUR_A", + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + comment := commentNode("C1", "alice", "Hello", false, "", 0) + node := minimalNode("D_1", "Test") + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(getWithCommentsResp(true, node, comment, 3, true, false, "CUR_B", "")), + ) + }, + wantComments: 1, + wantTotal: 3, + wantCursor: "CUR_A", + wantNext: "CUR_B", + wantDirection: DiscussionCommentListDirectionForward, + }, + { + name: "pagination backward newest", + limit: 5, + after: "CUR_X", + newest: true, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + c1 := commentNode("C1", "alice", "First", false, "", 0) + c2 := commentNode("C2", "bob", "Second", false, "", 0) + node := minimalNode("D_1", "Test") + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(getWithCommentsResp(true, node, c1+","+c2, 5, false, true, "", "CUR_Y")), + ) + }, + wantComments: 2, + wantTotal: 5, + wantCursor: "CUR_X", + wantNext: "CUR_Y", + wantDirection: DiscussionCommentListDirectionBackward, + wantDisc: func(t *testing.T, d *Discussion) { + // Newest mode reverses the order + assert.Equal(t, "C2", d.Comments.Comments[0].ID) + assert.Equal(t, "C1", d.Comments.Comments[1].ID) + }, + }, + { + name: "no more pages", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + comment := commentNode("C1", "alice", "Only one", false, "", 0) + node := minimalNode("D_1", "Test") + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(getWithCommentsResp(true, node, comment, 1, false, false, "", "")), + ) + }, + wantComments: 1, + wantTotal: 1, + wantNext: "", + wantDirection: DiscussionCommentListDirectionForward, + }, + { + name: "discussions disabled", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + node := minimalNode("D_1", "Test") + reg.Register( + httpmock.GraphQL(`query DiscussionWithComments\b`), + httpmock.StringResponse(getWithCommentsResp(false, node, "", 0, false, false, "", "")), + ) + }, + wantErr: "discussions disabled", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + + c := newTestDiscussionClient(reg) + d, err := c.GetWithComments(repo, 1, tt.limit, tt.after, tt.newest) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, d) + assert.Len(t, d.Comments.Comments, tt.wantComments) + assert.Equal(t, tt.wantTotal, d.Comments.TotalCount) + assert.Equal(t, tt.wantCursor, d.Comments.Cursor) + assert.Equal(t, tt.wantNext, d.Comments.NextCursor) + assert.Equal(t, tt.wantDirection, d.Comments.Direction) + if tt.wantDisc != nil { + tt.wantDisc(t, d) + } + }) + } +} + +// --------------------------------------------------------------------------- +// GetCommentReplies +// --------------------------------------------------------------------------- + +// getCommentRepliesResp builds a mock DiscussionCommentReplies JSON response. +// The shurcooL graphql library treats inline fragments as transparent — the +// comment fields are placed directly inside the "node" object, not nested +// under a "DiscussionComment" key. +func getCommentRepliesResp(hasDiscussions bool, discNode string, commentNode *string, replyNodes string, replyTotal int, hasNext, hasPrev bool, endCursor, startCursor string) string { + nodeBlock := "null" + if commentNode != nil { + nodeBlock = wrapRepliesBlock(*commentNode, replyNodes, replyTotal, hasNext, hasPrev, endCursor, startCursor) + } + return heredoc.Docf(` + { + "data": { + "repository": { + "hasDiscussionsEnabled": %t, + "discussion": %s + }, + "node": %s + } + } + `, hasDiscussions, discNode, nodeBlock) +} + +func wrapRepliesBlock(commentJSON string, replyNodes string, total int, hasNext, hasPrev bool, endCursor, startCursor string) string { + trimmed := strings.TrimRight(commentJSON, " \t\n") + trimmed = trimmed[:len(trimmed)-1] + return fmt.Sprintf(`%s, "replies": {"totalCount": %d, "pageInfo": {"endCursor": %q, "hasNextPage": %t, "startCursor": %q, "hasPreviousPage": %t}, "nodes": [%s]}}`, + trimmed, total, endCursor, hasNext, startCursor, hasPrev, replyNodes) +} + +// bareCommentNode builds a comment JSON node without replies (used for GetCommentReplies). +func bareCommentNode(id, login, body string, isAnswer bool) string { + return heredoc.Docf(` + { + "id": %q, + "url": "https://github.com/OWNER/REPO/discussions/1#comment-%s", + "author": {"__typename": "User", "login": %q}, + "body": %q, + "createdAt": "2025-01-01T00:00:00Z", + "isAnswer": %t, + "upvoteCount": 2, + "reactionGroups": [{"content": "HEART", "users": {"totalCount": 1}}] + } + `, id, id, login, body, isAnswer) +} + +func TestGetCommentReplies(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + tests := []struct { + name string + commentID string + limit int + after string + newest bool + httpStubs func(*testing.T, *httpmock.Registry) + wantErr string + wantReplies int + wantTotal int + wantCursor string + wantNext string + wantDirection DiscussionCommentListDirection + wantDisc func(*testing.T, *Discussion) + }{ + { + name: "maps all fields", + commentID: "DC_abc", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + discNode := minimalNode("D_1", "Test Discussion") + comment := bareCommentNode("DC_abc", "octocat", "Top-level comment", true) + reply := replyNode("R1", "hubot", "A reply") + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(getCommentRepliesResp(true, discNode, &comment, reply, 1, false, false, "", "")), + ) + }, + wantReplies: 1, + wantTotal: 1, + wantDirection: DiscussionCommentListDirectionForward, + wantDisc: func(t *testing.T, d *Discussion) { + assert.Equal(t, "Test Discussion", d.Title) + require.Len(t, d.Comments.Comments, 1) + c := d.Comments.Comments[0] + assert.Equal(t, "DC_abc", c.ID) + assert.Equal(t, "octocat", c.Author.Login) + assert.Equal(t, "Top-level comment", c.Body) + assert.True(t, c.IsAnswer) + assert.Equal(t, 2, c.UpvoteCount) + require.Len(t, c.ReactionGroups, 1) + assert.Equal(t, "HEART", c.ReactionGroups[0].Content) + require.Len(t, c.Replies.Comments, 1) + assert.Equal(t, "R1", c.Replies.Comments[0].ID) + assert.Equal(t, "hubot", c.Replies.Comments[0].Author.Login) + }, + }, + { + name: "pagination forward oldest", + commentID: "DC_abc", + limit: 5, + after: "CUR_A", + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + discNode := minimalNode("D_1", "Test") + comment := bareCommentNode("DC_abc", "alice", "Comment", false) + r1 := replyNode("R1", "bob", "Reply 1") + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(getCommentRepliesResp(true, discNode, &comment, r1, 3, true, false, "CUR_B", "")), + ) + }, + wantReplies: 1, + wantTotal: 3, + wantCursor: "CUR_A", + wantNext: "CUR_B", + wantDirection: DiscussionCommentListDirectionForward, + }, + { + name: "pagination backward newest reverses replies", + commentID: "DC_abc", + limit: 5, + after: "CUR_X", + newest: true, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + discNode := minimalNode("D_1", "Test") + comment := bareCommentNode("DC_abc", "alice", "Comment", false) + r1 := replyNode("R1", "bob", "Older") + r2 := replyNode("R2", "carol", "Newer") + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(getCommentRepliesResp(true, discNode, &comment, r1+","+r2, 5, false, true, "", "CUR_Y")), + ) + }, + wantReplies: 2, + wantTotal: 5, + wantCursor: "CUR_X", + wantNext: "CUR_Y", + wantDirection: DiscussionCommentListDirectionBackward, + wantDisc: func(t *testing.T, d *Discussion) { + replies := d.Comments.Comments[0].Replies.Comments + assert.Equal(t, "R2", replies[0].ID, "newest mode should reverse replies") + assert.Equal(t, "R1", replies[1].ID) + }, + }, + { + name: "no more pages", + commentID: "DC_abc", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + discNode := minimalNode("D_1", "Test") + comment := bareCommentNode("DC_abc", "alice", "Comment", false) + r1 := replyNode("R1", "bob", "Only reply") + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(getCommentRepliesResp(true, discNode, &comment, r1, 1, false, false, "", "")), + ) + }, + wantReplies: 1, + wantTotal: 1, + wantNext: "", + wantDirection: DiscussionCommentListDirectionForward, + }, + { + name: "discussions disabled", + commentID: "DC_abc", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + discNode := minimalNode("D_1", "Test") + comment := bareCommentNode("DC_abc", "alice", "Comment", false) + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(getCommentRepliesResp(false, discNode, &comment, "", 0, false, false, "", "")), + ) + }, + wantErr: "discussions disabled", + }, + { + name: "node not found nil", + commentID: "DC_invalid", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + discNode := minimalNode("D_1", "Test") + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(getCommentRepliesResp(true, discNode, nil, "", 0, false, false, "", "")), + ) + }, + wantErr: "comment DC_invalid not found", + }, + { + name: "node is not a discussion comment", + commentID: "I_notacomment", + limit: 10, + newest: false, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + discNode := minimalNode("D_1", "Test") + // Return a node with an empty DiscussionComment (wrong type) + emptyComment := `{"id":"","url":"","author":{"__typename":"User","login":""},"body":"","createdAt":"0001-01-01T00:00:00Z","isAnswer":false,"upvoteCount":0,"reactionGroups":[]}` + reg.Register( + httpmock.GraphQL(`query DiscussionCommentReplies\b`), + httpmock.StringResponse(getCommentRepliesResp(true, discNode, &emptyComment, "", 0, false, false, "", "")), + ) + }, + wantErr: "node I_notacomment is not a discussion comment", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + if tt.httpStubs != nil { + tt.httpStubs(t, reg) + } + + c := newTestDiscussionClient(reg) + d, err := c.GetCommentReplies(repo, 1, tt.commentID, tt.limit, tt.after, tt.newest) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, d) + require.Len(t, d.Comments.Comments, 1, "GetCommentReplies should return exactly one comment") + + comment := d.Comments.Comments[0] + assert.Len(t, comment.Replies.Comments, tt.wantReplies) + assert.Equal(t, tt.wantTotal, comment.Replies.TotalCount) + assert.Equal(t, tt.wantCursor, comment.Replies.Cursor) + assert.Equal(t, tt.wantNext, comment.Replies.NextCursor) + assert.Equal(t, tt.wantDirection, comment.Replies.Direction) + if tt.wantDisc != nil { + tt.wantDisc(t, d) + } + }) + } +} diff --git a/pkg/cmd/discussion/view/view_test.go b/pkg/cmd/discussion/view/view_test.go index 1ae739168..3f5e0348c 100644 --- a/pkg/cmd/discussion/view/view_test.go +++ b/pkg/cmd/discussion/view/view_test.go @@ -860,3 +860,288 @@ func TestViewRun_jsonWithoutComments_usesGetByNumber(t *testing.T) { assert.Equal(t, 1, len(mock.GetByNumberCalls())) assert.Equal(t, 0, len(mock.GetWithCommentsCalls())) } + +// --------------------------------------------------------------------------- +// --replies flag validation +// --------------------------------------------------------------------------- + +func TestNewCmdView_repliesFlags(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "replies with comments is mutually exclusive", + args: []string{"123", "--replies", "DC_abc", "--comments"}, + wantErr: "specify only one of --comments, --replies, or --web", + }, + { + name: "replies with web is mutually exclusive", + args: []string{"123", "--replies", "DC_abc", "--web"}, + wantErr: "specify only one of --comments, --replies, or --web", + }, + { + name: "order requires comments or replies", + args: []string{"123", "--order", "newest"}, + wantErr: "--order requires --comments or --replies", + }, + { + name: "limit requires comments or replies", + args: []string{"123", "--limit", "5"}, + wantErr: "--limit requires --comments or --replies", + }, + { + name: "after requires comments or replies", + args: []string{"123", "--after", "CURSOR"}, + wantErr: "--after requires --comments or --replies", + }, + { + name: "order works with replies", + args: []string{"123", "--replies", "DC_abc", "--order", "oldest"}, + }, + { + name: "limit works with replies", + args: []string{"123", "--replies", "DC_abc", "--limit", "10"}, + }, + { + name: "after works with replies", + args: []string{"123", "--replies", "DC_abc", "--after", "CURSOR"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + ios, _, _, _ := iostreams.Test() + f.IOStreams = ios + f.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + f.Browser = &browser.Stub{} + + cmd := NewCmdView(f, func(opts *ViewOptions) error { + return nil + }) + + cmd.SetArgs(tt.args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +// --------------------------------------------------------------------------- +// --replies viewRun tests (table-driven) +// --------------------------------------------------------------------------- + +func testDiscussionWithReplies(nextCursor string) *client.Discussion { + d := testDiscussion() + d.Comments = client.DiscussionCommentList{ + TotalCount: 1, + Comments: []client.DiscussionComment{ + { + ID: "DC_abc", + URL: "https://github.com/OWNER/REPO/discussions/123#discussioncomment-1", + Author: client.DiscussionActor{Login: "octocat"}, + Body: "This is the parent comment", + CreatedAt: time.Date(2025, 3, 2, 0, 0, 0, 0, time.UTC), + IsAnswer: true, + ReactionGroups: []client.ReactionGroup{ + {Content: "THUMBS_UP", TotalCount: 3}, + }, + Replies: client.DiscussionCommentList{ + TotalCount: 2, + NextCursor: nextCursor, + Comments: []client.DiscussionComment{ + { + ID: "R1", + URL: "https://github.com/OWNER/REPO/discussions/123#discussioncomment-2", + Author: client.DiscussionActor{Login: "hubot"}, + Body: "First reply", + CreatedAt: time.Date(2025, 3, 2, 1, 0, 0, 0, time.UTC), + }, + { + ID: "R2", + URL: "https://github.com/OWNER/REPO/discussions/123#discussioncomment-3", + Author: client.DiscussionActor{Login: "monalisa"}, + Body: "Second reply", + CreatedAt: time.Date(2025, 3, 2, 2, 0, 0, 0, time.UTC), + }, + }, + }, + }, + }, + } + return d +} + +func TestViewRun_replies(t *testing.T) { + tests := []struct { + name string + tty bool + replies string + limit int + after string + order string + exporter cmdutil.Exporter + nextCursor string + wantContains []string + wantExcludes []string + wantClient func(*testing.T, *client.DiscussionClientMock) + }{ + { + name: "tty renders comment and replies", + tty: true, + replies: "DC_abc", + limit: 30, + order: "newest", + wantContains: []string{ + "octocat", + "This is the parent comment", + "✓ Answer", + "hubot", + "First reply", + "monalisa", + "Second reply", + }, + }, + { + name: "tty shows pagination hint", + tty: true, + replies: "DC_abc", + limit: 30, + order: "newest", + nextCursor: "NEXT_CUR", + wantContains: []string{ + "--after NEXT_CUR", + }, + }, + { + name: "tty no pagination hint when no next cursor", + tty: true, + replies: "DC_abc", + limit: 30, + order: "newest", + wantExcludes: []string{ + "--after", + }, + }, + { + name: "nontty raw output", + tty: false, + replies: "DC_abc", + limit: 30, + order: "oldest", + wantContains: []string{ + "comment:\toctocat\t", + "answer", + "replies:\t2", + "This is the parent comment", + "hubot", + "First reply", + }, + }, + { + name: "nontty shows next cursor", + tty: false, + replies: "DC_abc", + limit: 30, + order: "oldest", + nextCursor: "NEXT_CUR_456", + wantContains: []string{ + "next:\tNEXT_CUR_456", + }, + }, + { + name: "json output", + tty: false, + replies: "DC_abc", + limit: 30, + order: "newest", + exporter: func() cmdutil.Exporter { + e := cmdutil.NewJSONExporter() + e.SetFields(discussionFields) + return e + }(), + wantContains: []string{ + `"totalCount"`, + `"isAnswer":true`, + `"octocat"`, + }, + }, + { + name: "routes to GetCommentReplies only", + tty: false, + replies: "DC_abc", + limit: 10, + after: "CUR_A", + order: "oldest", + wantClient: func(t *testing.T, mock *client.DiscussionClientMock) { + require.Equal(t, 1, len(mock.GetCommentRepliesCalls())) + assert.Equal(t, 0, len(mock.GetByNumberCalls())) + assert.Equal(t, 0, len(mock.GetWithCommentsCalls())) + + call := mock.GetCommentRepliesCalls()[0] + assert.Equal(t, "DC_abc", call.CommentID) + assert.Equal(t, 10, call.Limit) + assert.Equal(t, "CUR_A", call.After) + assert.Equal(t, false, call.Newest) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(tt.tty) + ios.SetStderrTTY(tt.tty) + + d := testDiscussionWithReplies(tt.nextCursor) + mock := &client.DiscussionClientMock{ + GetCommentRepliesFunc: func(repo ghrepo.Interface, number int, commentID string, limit int, after string, newest bool) (*client.Discussion, error) { + return d, nil + }, + } + + opts := &ViewOptions{ + IO: ios, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Client: func() (client.DiscussionClient, error) { + return mock, nil + }, + DiscussionNumber: 123, + Replies: tt.replies, + Limit: tt.limit, + After: tt.after, + Order: tt.order, + Exporter: tt.exporter, + Now: func() time.Time { return time.Date(2025, 3, 4, 0, 0, 0, 0, time.UTC) }, + } + + err := viewRun(opts) + require.NoError(t, err) + + out := stdout.String() + for _, s := range tt.wantContains { + assert.Contains(t, out, s) + } + for _, s := range tt.wantExcludes { + assert.NotContains(t, out, s) + } + if tt.wantClient != nil { + tt.wantClient(t, mock) + } + }) + } +}