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:
parent
99a099928e
commit
11130fd6be
2 changed files with 860 additions and 0 deletions
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue