test(discussion view): add tests for view command client methods and --replies mode

Add table-driven tests for:

Client (client_impl_test.go):
- TestGetByNumber: field mapping, discussions disabled
- TestGetWithComments: field mapping, forward/backward pagination,
  reply reversal in newest mode, discussions disabled
- TestGetCommentReplies: field mapping, forward/backward pagination,
  reply reversal, discussions disabled, nil node, wrong node type

Command (view_test.go):
- TestNewCmdView_repliesFlags: mutual exclusivity with --comments/--web,
  --order/--limit/--after require --comments or --replies, pagination
  flags work with --replies
- TestViewRun_replies: TTY/non-TTY/JSON output, pagination hints,
  routing assertion (GetCommentReplies called, not GetByNumber or
  GetWithComments)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Max Beizer 2026-04-27 15:42:36 -05:00
parent 99a099928e
commit 11130fd6be
No known key found for this signature in database
2 changed files with 860 additions and 0 deletions

View file

@ -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)
}
})
}
}

View file

@ -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)
}
})
}
}