package client import ( "fmt" "net/http" "strings" "testing" "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func newTestDiscussionClient(reg *httpmock.Registry) DiscussionClient { httpClient := &http.Client{} httpmock.ReplaceTripper(httpClient, reg) return NewDiscussionClient(httpClient) } // minimalNode returns a minimal JSON discussion node with the given id and title. func minimalNode(id, title string) string { return heredoc.Docf(` { "id": %q, "number": 1, "title": %q, "body": "", "url": "", "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": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", "locked": false } `, id, title) } // minimalNodes returns count comma-separated minimal JSON discussion nodes. func minimalNodes(count int) string { nodes := make([]string, count) for i := range nodes { nodes[i] = minimalNode(fmt.Sprintf("D%d", i+1), fmt.Sprintf("Discussion %d", i+1)) } return strings.Join(nodes, ",") } // listResp builds a mock repository.discussions JSON response. func listResp(hasNext bool, cursor string, total int, nodes string) string { return heredoc.Docf(` { "data": { "repository": { "hasDiscussionsEnabled": true, "discussions": { "totalCount": %d, "pageInfo": { "hasNextPage": %t, "endCursor": %q }, "nodes": [%s] } } } } `, total, hasNext, cursor, nodes) } // searchResp builds a mock search JSON response. func searchResp(hasNext bool, cursor string, count int, nodes string) string { return heredoc.Docf(` { "data": { "search": { "discussionCount": %d, "pageInfo": { "hasNextPage": %t, "endCursor": %q }, "nodes": [%s] } } } `, count, hasNext, cursor, nodes) } func TestList(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") richNode := heredoc.Doc(` { "id": "D_rich1", "number": 42, "title": "Rich discussion", "body": "body text here", "url": "https://github.com/OWNER/REPO/discussions/42", "closed": true, "stateReason": "RESOLVED", "isAnswered": true, "answerChosenAt": "2024-06-01T12:00:00Z", "author": { "__typename": "User", "login": "alice", "id": "U1", "name": "Alice" }, "category": { "id": "C1", "name": "Q&A", "slug": "q-a", "emoji": ":question:", "isAnswerable": true }, "answerChosenBy": { "__typename": "User", "login": "bob", "id": "U2", "name": "Bob" }, "labels": { "nodes": [ {"id": "L1", "name": "bug", "color": "d73a4a"}, {"id": "L2", "name": "enhancement", "color": "a2eeef"} ] }, "reactionGroups": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-06-02T00:00:00Z", "closedAt": "2024-06-01T00:00:00Z", "locked": true } `) emptyResp := listResp(false, "", 0, "") disabledResp := heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": false, "discussions": { "totalCount": 0, "pageInfo": { "hasNextPage": false, "endCursor": null }, "nodes": [] } } } } `) tests := []struct { name string filters ListFilters after string limit int httpStubs func(*testing.T, *httpmock.Registry) wantErr string wantTotal int wantLen int wantCursor string wantTitles []string wantSingleDisc *Discussion }{ { name: "maps all fields", limit: 10, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.StringResponse(listResp(false, "", 1, richNode)), ) }, wantTotal: 1, wantLen: 1, wantSingleDisc: &Discussion{ ID: "D_rich1", Number: 42, Title: "Rich discussion", Body: "body text here", URL: "https://github.com/OWNER/REPO/discussions/42", Closed: true, StateReason: "RESOLVED", Author: DiscussionActor{ ID: "U1", Login: "alice", Name: "Alice", }, Category: DiscussionCategory{ ID: "C1", Name: "Q&A", Slug: "q-a", Emoji: ":question:", IsAnswerable: true, }, Labels: []DiscussionLabel{ {ID: "L1", Name: "bug", Color: "d73a4a"}, {ID: "L2", Name: "enhancement", Color: "a2eeef"}, }, Answered: true, AnswerChosenAt: time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC), AnswerChosenBy: &DiscussionActor{ ID: "U2", Login: "bob", Name: "Bob", }, Comments: DiscussionCommentList{}, CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2024, 6, 2, 0, 0, 0, 0, time.UTC), ClosedAt: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC), Locked: true, }, }, { name: "empty list", limit: 10, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.StringResponse(emptyResp), ) }, wantTotal: 0, wantLen: 0, }, { name: "discussions disabled", limit: 10, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.StringResponse(disabledResp), ) }, wantErr: "discussions disabled", }, { name: "limit zero", limit: 0, wantErr: "limit argument must be positive", }, { name: "invalid orderBy", limit: 10, filters: ListFilters{OrderBy: "invalid"}, wantErr: "unknown order-by field", }, { name: "invalid direction", limit: 10, filters: ListFilters{Direction: "sideways"}, wantErr: "unknown order direction", }, { name: "invalid state", limit: 10, filters: ListFilters{State: new("merged")}, wantErr: "unknown state filter", }, { name: "with after cursor", limit: 10, after: "someCursor", httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { assert.Equal(t, "someCursor", vars["after"]) }), ) }, }, { name: "open state filter", limit: 10, filters: ListFilters{State: new(FilterStateOpen)}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { assert.Equal(t, []interface{}{"OPEN"}, vars["states"]) }), ) }, }, { name: "closed state filter", limit: 10, filters: ListFilters{State: new(FilterStateClosed)}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { assert.Equal(t, []interface{}{"CLOSED"}, vars["states"]) }), ) }, }, { name: "answered filter", limit: 10, filters: ListFilters{Answered: new(true)}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { assert.Equal(t, true, vars["answered"]) }), ) }, }, { name: "unanswered filter", limit: 10, filters: ListFilters{Answered: new(false)}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { assert.Equal(t, false, vars["answered"]) }), ) }, }, { name: "category ID filter", limit: 10, filters: ListFilters{CategoryID: "CAT123"}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { assert.Equal(t, "CAT123", vars["categoryId"]) }), ) }, }, { name: "order by created asc", limit: 10, filters: ListFilters{OrderBy: OrderByCreated, Direction: OrderDirectionAsc}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { orderBy, ok := vars["orderBy"].(map[string]interface{}) require.True(t, ok, "orderBy should be a map") assert.Equal(t, "CREATED_AT", orderBy["field"]) assert.Equal(t, "ASC", orderBy["direction"]) }), ) }, }, { name: "order by updated desc", limit: 10, filters: ListFilters{OrderBy: OrderByUpdated, Direction: OrderDirectionDesc}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { orderBy, ok := vars["orderBy"].(map[string]interface{}) require.True(t, ok, "orderBy should be a map") assert.Equal(t, "UPDATED_AT", orderBy["field"]) assert.Equal(t, "DESC", orderBy["direction"]) }), ) }, }, { // Bot actors have no name; ID comes from the Bot.ID field. name: "bot actor", limit: 10, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.StringResponse(listResp(false, "", 1, heredoc.Doc(` { "id": "D_bot", "number": 1, "title": "Bot post", "body": "", "url": "", "closed": false, "stateReason": "", "isAnswered": false, "answerChosenAt": "0001-01-01T00:00:00Z", "author": { "__typename": "Bot", "login": "gh-bot", "id": "bot-node-id" }, "category": { "id": "C1", "name": "General", "slug": "general", "emoji": "", "isAnswerable": false }, "answerChosenBy": null, "labels": { "nodes": [] }, "reactionGroups": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", "locked": false } `))), ) }, wantLen: 1, wantTotal: 1, wantSingleDisc: &Discussion{ ID: "D_bot", Number: 1, Title: "Bot post", Author: DiscussionActor{ID: "bot-node-id", Login: "gh-bot", Name: ""}, Category: DiscussionCategory{ID: "C1", Name: "General", Slug: "general"}, Labels: []DiscussionLabel{}, Comments: DiscussionCommentList{}, CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), }, }, { // When limit > 100, the first page requests 100 and the second page // requests the remainder, exercising the per-iteration first variable. name: "limit greater than 100", limit: 101, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.GraphQLQuery(listResp(true, "pg2cursor", 101, minimalNodes(100)), func(_ string, vars map[string]interface{}) { assert.Equal(t, float64(100), vars["first"]) }), ) reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.GraphQLQuery(listResp(false, "", 101, minimalNode("D101", "Discussion 101")), func(_ string, vars map[string]interface{}) { assert.Equal(t, float64(1), vars["first"]) }), ) }, wantLen: 101, wantTotal: 101, }, { // When the page has more items than requested, NextCursor is set. name: "pagination sets next cursor", limit: 1, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.StringResponse(listResp(true, "cursor42", 5, minimalNode("D1", "Discussion 1"))), ) }, wantLen: 1, wantTotal: 5, wantCursor: "cursor42", }, { // Two pages are fetched when limit exceeds the first page's results. name: "pagination fetches multiple pages", limit: 2, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.StringResponse(listResp(true, "cursor1", 2, minimalNode("D1", "First"))), ) reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.StringResponse(listResp(false, "", 2, minimalNode("D2", "Second"))), ) }, wantLen: 2, wantTotal: 2, wantTitles: []string{"First", "Second"}, }, { name: "exact fit does not overfetch", limit: 1, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionList\b`), httpmock.StringResponse(listResp(false, "", 1, minimalNode("D1", "Only one"))), ) }, wantLen: 1, wantTotal: 1, wantTitles: []string{"Only one"}, }, } 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) result, err := c.List(repo, tt.filters, tt.after, tt.limit) if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) return } require.NoError(t, err) require.NotNil(t, result) assert.Equal(t, tt.wantTotal, result.TotalCount) assert.Len(t, result.Discussions, tt.wantLen) assert.Equal(t, tt.wantCursor, result.NextCursor) for i, title := range tt.wantTitles { assert.Equal(t, title, result.Discussions[i].Title) } if tt.wantSingleDisc != nil { require.NotEmpty(t, result.Discussions) assert.Equal(t, *tt.wantSingleDisc, result.Discussions[0]) } }) } } func TestSearch(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") richNode := heredoc.Doc(` { "id": "D_rich1", "number": 42, "title": "Rich search result", "body": "body text here", "url": "https://github.com/OWNER/REPO/discussions/42", "closed": true, "stateReason": "RESOLVED", "isAnswered": true, "answerChosenAt": "2024-06-01T12:00:00Z", "author": { "__typename": "User", "login": "alice", "id": "U1", "name": "Alice" }, "category": { "id": "C1", "name": "Q&A", "slug": "q-a", "emoji": ":question:", "isAnswerable": true }, "answerChosenBy": { "__typename": "User", "login": "bob", "id": "U2", "name": "Bob" }, "labels": { "nodes": [ {"id": "L1", "name": "bug", "color": "d73a4a"} ] }, "reactionGroups": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-06-02T00:00:00Z", "closedAt": "2024-06-01T00:00:00Z", "locked": true } `) emptyResp := searchResp(false, "", 0, "") tests := []struct { name string filters SearchFilters after string limit int httpStubs func(*testing.T, *httpmock.Registry) wantErr string wantTotal int wantLen int wantCursor string wantTitles []string wantSingleDisc *Discussion }{ { name: "maps all fields", limit: 10, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.StringResponse(searchResp(false, "", 1, richNode)), ) }, wantTotal: 1, wantLen: 1, wantSingleDisc: &Discussion{ ID: "D_rich1", Number: 42, Title: "Rich search result", Body: "body text here", URL: "https://github.com/OWNER/REPO/discussions/42", Closed: true, StateReason: "RESOLVED", Author: DiscussionActor{ ID: "U1", Login: "alice", Name: "Alice", }, Category: DiscussionCategory{ ID: "C1", Name: "Q&A", Slug: "q-a", Emoji: ":question:", IsAnswerable: true, }, Labels: []DiscussionLabel{ {ID: "L1", Name: "bug", Color: "d73a4a"}, }, Answered: true, AnswerChosenAt: time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC), AnswerChosenBy: &DiscussionActor{ ID: "U2", Login: "bob", Name: "Bob", }, Comments: DiscussionCommentList{}, CreatedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2024, 6, 2, 0, 0, 0, 0, time.UTC), ClosedAt: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC), Locked: true, }, }, { name: "limit zero", limit: 0, wantErr: "limit argument must be positive", }, { name: "invalid orderBy", limit: 10, filters: SearchFilters{OrderBy: "bogus"}, wantErr: "unknown order-by field", }, { name: "invalid direction", limit: 10, filters: SearchFilters{Direction: "sideways"}, wantErr: "unknown order direction", }, { name: "invalid state", limit: 10, filters: SearchFilters{State: new("merged")}, wantErr: "unknown state filter", }, { name: "with after cursor", limit: 10, after: "someCursor", httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { assert.Equal(t, "someCursor", vars["after"]) }), ) }, }, { name: "open state filter", limit: 10, filters: SearchFilters{State: new(FilterStateOpen)}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { assert.Contains(t, vars["query"].(string), "is:open") }), ) }, }, { name: "closed state filter", limit: 10, filters: SearchFilters{State: new(FilterStateClosed)}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { assert.Contains(t, vars["query"].(string), "is:closed") }), ) }, }, { name: "answered filter", limit: 10, filters: SearchFilters{Answered: new(true)}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { assert.Contains(t, vars["query"].(string), "is:answered") }), ) }, }, { name: "unanswered filter", limit: 10, filters: SearchFilters{Answered: new(false)}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { assert.Contains(t, vars["query"].(string), "is:unanswered") }), ) }, }, { name: "author filter", limit: 10, filters: SearchFilters{Author: "alice"}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { assert.Contains(t, vars["query"].(string), `author:"alice"`) }), ) }, }, { name: "labels filter", limit: 10, filters: SearchFilters{Labels: []string{"bug", "enhancement"}}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { q := vars["query"].(string) assert.Contains(t, q, `label:"bug"`) assert.Contains(t, q, `label:"enhancement"`) }), ) }, }, { name: "category filter", limit: 10, filters: SearchFilters{Category: "Q&A"}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { assert.Contains(t, vars["query"].(string), `category:"Q&A"`) }), ) }, }, { name: "keywords filter", limit: 10, filters: SearchFilters{Keywords: "some keyword"}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { assert.Contains(t, vars["query"].(string), "some keyword") }), ) }, }, { name: "order by created asc", limit: 10, filters: SearchFilters{OrderBy: OrderByCreated, Direction: OrderDirectionAsc}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { assert.Contains(t, vars["query"].(string), "sort:created-asc") }), ) }, }, { name: "order by updated desc", limit: 10, filters: SearchFilters{OrderBy: OrderByUpdated, Direction: OrderDirectionDesc}, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.GraphQLQuery(emptyResp, func(_ string, vars map[string]interface{}) { assert.Contains(t, vars["query"].(string), "sort:updated-desc") }), ) }, }, { // When limit > 100, the first page requests 100 and the second page // requests the remainder, exercising the per-iteration first variable. name: "limit greater than 100", limit: 101, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.GraphQLQuery(searchResp(true, "pg2cursor", 101, minimalNodes(100)), func(_ string, vars map[string]interface{}) { assert.Equal(t, float64(100), vars["first"]) }), ) reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.GraphQLQuery(searchResp(false, "", 101, minimalNode("D101", "Discussion 101")), func(_ string, vars map[string]interface{}) { assert.Equal(t, float64(1), vars["first"]) }), ) }, wantLen: 101, wantTotal: 101, }, { // When the page has more items than requested, NextCursor is set. name: "pagination sets next cursor", limit: 1, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.StringResponse(searchResp(true, "searchCursor42", 5, minimalNode("D1", "Discussion 1"))), ) }, wantLen: 1, wantTotal: 5, wantCursor: "searchCursor42", }, { // Two pages are fetched when limit exceeds the first page's results. name: "pagination fetches multiple pages", limit: 2, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.StringResponse(searchResp(true, "searchCursor1", 2, minimalNode("D1", "First"))), ) reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.StringResponse(searchResp(false, "", 2, minimalNode("D2", "Second"))), ) }, wantLen: 2, wantTotal: 2, wantTitles: []string{"First", "Second"}, }, { name: "exact fit does not overfetch", limit: 1, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionListSearch\b`), httpmock.StringResponse(searchResp(false, "", 1, minimalNode("D1", "Only one"))), ) }, wantLen: 1, wantTotal: 1, wantTitles: []string{"Only one"}, }, } 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) result, err := c.Search(repo, tt.filters, tt.after, tt.limit) if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) return } require.NoError(t, err) require.NotNil(t, result) assert.Equal(t, tt.wantTotal, result.TotalCount) assert.Len(t, result.Discussions, tt.wantLen) assert.Equal(t, tt.wantCursor, result.NextCursor) for i, title := range tt.wantTitles { assert.Equal(t, title, result.Discussions[i].Title) } if tt.wantSingleDisc != nil { require.NotEmpty(t, result.Discussions) assert.Equal(t, *tt.wantSingleDisc, result.Discussions[0]) } }) } } func TestListCategories(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") tests := []struct { name string httpStubs func(*testing.T, *httpmock.Registry) wantErr string wantCats []DiscussionCategory }{ { name: "maps all fields", httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionCategoryList\b`), httpmock.StringResponse(`{"data":{"repository":{ "hasDiscussionsEnabled":true, "discussionCategories":{"nodes":[ {"id":"C1","name":"General","slug":"general","emoji":":speech_balloon:","isAnswerable":false}, {"id":"C2","name":"Q&A","slug":"q-a","emoji":":question:","isAnswerable":true} ]} }}}`), ) }, wantCats: []DiscussionCategory{ {ID: "C1", Name: "General", Slug: "general", Emoji: ":speech_balloon:", IsAnswerable: false}, {ID: "C2", Name: "Q&A", Slug: "q-a", Emoji: ":question:", IsAnswerable: true}, }, }, { name: "discussions disabled", httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionCategoryList\b`), httpmock.StringResponse(`{"data":{"repository":{ "hasDiscussionsEnabled":false, "discussionCategories":{"nodes":[]} }}}`), ) }, 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) categories, err := c.ListCategories(repo) if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) return } require.NoError(t, err) require.Len(t, categories, len(tt.wantCats)) for i, want := range tt.wantCats { assert.Equal(t, want, categories[i]) } }) } } func TestGetByNumber(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") tests := []struct { name string httpStubs func(*testing.T, *httpmock.Registry) wantErr string assertDisc *Discussion }{ { name: "maps all fields", httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionMinimal\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": true, "discussion": { "id": "D_1", "number": 42, "title": "Test Discussion", "body": "This is a test", "url": "https://github.com/OWNER/REPO/discussions/42", "closed": true, "stateReason": "RESOLVED", "isAnswered": true, "answerChosenAt": "2025-06-01T12:00:00Z", "author": {"__typename": "User", "login": "alice", "id": "U1", "name": "Alice"}, "category": {"id": "C1", "name": "Q&A", "slug": "q-a", "emoji": ":question:", "isAnswerable": true}, "answerChosenBy": {"__typename": "User", "login": "bob", "id": "U2", "name": "Bob"}, "labels": {"nodes": [{"id": "L1", "name": "bug", "color": "d73a4a"}]}, "reactionGroups": [{"content": "THUMBS_UP", "users": {"totalCount": 3}}], "createdAt": "2025-01-01T00:00:00Z", "updatedAt": "2025-01-02T00:00:00Z", "closedAt": "2025-06-01T00:00:00Z", "locked": true, "comments": {"totalCount": 5} } } } } `)), ) }, assertDisc: &Discussion{ ID: "D_1", Number: 42, Title: "Test Discussion", Body: "This is a test", URL: "https://github.com/OWNER/REPO/discussions/42", Closed: true, StateReason: "RESOLVED", Author: DiscussionActor{ID: "U1", Login: "alice", Name: "Alice"}, Category: DiscussionCategory{ ID: "C1", Name: "Q&A", Slug: "q-a", Emoji: ":question:", IsAnswerable: true, }, Labels: []DiscussionLabel{{ID: "L1", Name: "bug", Color: "d73a4a"}}, Answered: true, AnswerChosenAt: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC), AnswerChosenBy: &DiscussionActor{ID: "U2", Login: "bob", Name: "Bob"}, ReactionGroups: []ReactionGroup{ {Content: "THUMBS_UP", TotalCount: 3}, }, Comments: DiscussionCommentList{TotalCount: 5}, CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), ClosedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), Locked: true, }, }, { name: "discussions disabled", httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionMinimal\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": false, "discussion": null } }, "errors": [ { "type": "NOT_FOUND", "path": ["repository", "discussion"], "message": "Could not resolve to a Discussion with the number of 42." } ] } `)), ) }, wantErr: "Could not resolve to a Discussion with the number of 42.", }, { name: "repo not found", httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionMinimal\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": null }, "errors": [ { "type": "NOT_FOUND", "path": ["repository"], "message": "Could not resolve to a Repository with the name 'OWNER/REPO'." } ] } `)), ) }, wantErr: "Could not resolve to a Repository with the name 'OWNER/REPO'.", }, } 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) require.NotNil(t, tt.assertDisc, "assertDisc must be set for non-error cases") assert.Equal(t, tt.assertDisc, d) }) } } 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 assertDisc func(*testing.T, *Discussion) }{ { name: "maps comments with replies", limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionWithComments\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": true, "discussion": { "id": "D_1", "number": 42, "title": "Test Discussion", "body": "Discussion body", "url": "https://github.com/OWNER/REPO/discussions/42", "closed": true, "stateReason": "RESOLVED", "isAnswered": true, "answerChosenAt": "2025-06-01T12:00:00Z", "author": {"__typename": "User", "login": "alice", "id": "U_alice", "name": "Alice"}, "category": {"id": "CAT1", "name": "Q&A", "slug": "q-a", "emoji": ":question:", "isAnswerable": true}, "answerChosenBy": {"__typename": "User", "login": "bob", "id": "U_bob", "name": "Bob"}, "labels": {"nodes": [{"id": "L1", "name": "bug", "color": "d73a4a"}]}, "reactionGroups": [{"content": "THUMBS_UP", "users": {"totalCount": 3}}], "createdAt": "2025-01-01T00:00:00Z", "updatedAt": "2025-01-02T00:00:00Z", "closedAt": "2025-06-01T00:00:00Z", "locked": true, "comments": { "totalCount": 1, "pageInfo": {"endCursor": "COM_CUR", "hasNextPage": true, "startCursor": "COM_START", "hasPreviousPage": false}, "nodes": [ { "id": "C1", "url": "https://github.com/OWNER/REPO/discussions/42#comment-1", "author": {"__typename": "User", "login": "octocat", "id": "U_octocat", "name": "Octocat"}, "body": "Main comment", "createdAt": "2025-03-01T00:00:00Z", "isAnswer": true, "upvoteCount": 5, "reactionGroups": [{"content": "HEART", "users": {"totalCount": 2}}], "replies": { "totalCount": 1, "nodes": [ { "id": "R1", "url": "https://github.com/OWNER/REPO/discussions/42#reply-1", "author": {"__typename": "User", "login": "hubot", "id": "U_hubot", "name": "Hubot"}, "body": "Thanks!", "createdAt": "2025-04-01T00:00:00Z", "isAnswer": false, "upvoteCount": 1, "reactionGroups": [{"content": "THUMBS_UP", "users": {"totalCount": 1}}] } ] } } ] } } } } } `)), ) }, assertDisc: func(t *testing.T, d *Discussion) { assert.Equal(t, Discussion{ ID: "D_1", Number: 42, Title: "Test Discussion", Body: "Discussion body", URL: "https://github.com/OWNER/REPO/discussions/42", Closed: true, StateReason: "RESOLVED", Author: DiscussionActor{ID: "U_alice", Login: "alice", Name: "Alice"}, Category: DiscussionCategory{ ID: "CAT1", Name: "Q&A", Slug: "q-a", Emoji: ":question:", IsAnswerable: true, }, Labels: []DiscussionLabel{{ID: "L1", Name: "bug", Color: "d73a4a"}}, Answered: true, AnswerChosenAt: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC), AnswerChosenBy: &DiscussionActor{ID: "U_bob", Login: "bob", Name: "Bob"}, ReactionGroups: []ReactionGroup{{Content: "THUMBS_UP", TotalCount: 3}}, CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), ClosedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), Locked: true, Comments: DiscussionCommentList{ TotalCount: 1, NextCursor: "COM_CUR", Direction: DiscussionCommentListDirectionForward, Comments: []DiscussionComment{ { ID: "C1", URL: "https://github.com/OWNER/REPO/discussions/42#comment-1", Author: DiscussionActor{ID: "U_octocat", Login: "octocat", Name: "Octocat"}, Body: "Main comment", CreatedAt: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), IsAnswer: true, UpvoteCount: 5, ReactionGroups: []ReactionGroup{{Content: "HEART", TotalCount: 2}}, Replies: DiscussionCommentList{ TotalCount: 1, Direction: DiscussionCommentListDirectionBackward, Comments: []DiscussionComment{ { ID: "R1", URL: "https://github.com/OWNER/REPO/discussions/42#reply-1", Author: DiscussionActor{ID: "U_hubot", Login: "hubot", Name: "Hubot"}, Body: "Thanks!", CreatedAt: time.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC), UpvoteCount: 1, ReactionGroups: []ReactionGroup{{Content: "THUMBS_UP", TotalCount: 1}}, }, }, }, }, }, }, }, *d) }, }, { name: "pagination forward", limit: 5, after: "CUR_A", newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionWithComments\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": true, "discussion": { "id": "D_1", "number": 1, "title": "Test", "body": "", "url": "", "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": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", "locked": false, "comments": { "totalCount": 3, "pageInfo": {"endCursor": "CUR_B", "hasNextPage": true, "startCursor": "", "hasPreviousPage": false}, "nodes": [ { "id": "C1", "url": "", "author": {"__typename": "User", "login": "alice"}, "body": "Hello", "createdAt": "2025-01-01T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [], "replies": {"totalCount": 0, "nodes": []} } ] } } } } } `)), ) }, assertDisc: func(t *testing.T, d *Discussion) { comments := d.Comments assert.Len(t, comments.Comments, 1) assert.Equal(t, 3, comments.TotalCount) assert.Equal(t, "CUR_A", comments.Cursor) assert.Equal(t, "CUR_B", comments.NextCursor) assert.Equal(t, DiscussionCommentListDirectionForward, comments.Direction) }, }, { name: "pagination backward newest", limit: 5, after: "CUR_X", newest: true, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionWithComments\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": true, "discussion": { "id": "D_1", "number": 1, "title": "Test", "body": "", "url": "", "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": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", "locked": false, "comments": { "totalCount": 5, "pageInfo": {"endCursor": "", "hasNextPage": false, "startCursor": "CUR_Y", "hasPreviousPage": true}, "nodes": [ { "id": "C1", "url": "", "author": {"__typename": "User", "login": "alice"}, "body": "First", "createdAt": "2025-01-01T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [], "replies": {"totalCount": 0, "nodes": []} }, { "id": "C2", "url": "", "author": {"__typename": "User", "login": "bob"}, "body": "Second", "createdAt": "2025-01-02T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [], "replies": {"totalCount": 0, "nodes": []} } ] } } } } } `)), ) }, assertDisc: func(t *testing.T, d *Discussion) { comments := d.Comments assert.Len(t, comments.Comments, 2) assert.Equal(t, 5, comments.TotalCount) assert.Equal(t, "CUR_X", comments.Cursor) assert.Equal(t, "CUR_Y", comments.NextCursor) assert.Equal(t, DiscussionCommentListDirectionBackward, comments.Direction) assert.Equal(t, "C2", comments.Comments[0].ID, "newest mode should reverse comments") assert.Equal(t, "C1", comments.Comments[1].ID) }, }, { name: "no more pages", limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionWithComments\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": true, "discussion": { "id": "D_1", "number": 1, "title": "Test", "body": "", "url": "", "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": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", "locked": false, "comments": { "totalCount": 1, "pageInfo": {"endCursor": "", "hasNextPage": false, "startCursor": "", "hasPreviousPage": false}, "nodes": [ { "id": "C1", "url": "", "author": {"__typename": "User", "login": "alice"}, "body": "Only one", "createdAt": "2025-01-01T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [], "replies": {"totalCount": 0, "nodes": []} } ] } } } } } `)), ) }, assertDisc: func(t *testing.T, d *Discussion) { comments := d.Comments assert.Len(t, comments.Comments, 1) assert.Equal(t, 1, comments.TotalCount) assert.Equal(t, "", comments.NextCursor) assert.Equal(t, DiscussionCommentListDirectionForward, comments.Direction) }, }, { name: "discussions disabled", limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionWithComments\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": false, "discussion": null } }, "errors": [ { "type": "NOT_FOUND", "path": ["repository", "discussion"], "message": "Could not resolve to a Discussion with the number of 1." } ] } `)), ) }, wantErr: "Could not resolve to a Discussion", }, { name: "repo not found", limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionWithComments\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": null }, "errors": [ { "type": "NOT_FOUND", "path": ["repository"], "message": "Could not resolve to a Repository with the name 'OWNER/REPO'." } ] } `)), ) }, wantErr: "Could not resolve to a Repository with the name 'OWNER/REPO'.", }, { name: "empty comments", limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionWithComments\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": true, "discussion": { "id": "D_1", "number": 1, "title": "Test", "body": "", "url": "", "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": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", "locked": false, "comments": { "totalCount": 0, "pageInfo": {"endCursor": null, "hasNextPage": false, "startCursor": null, "hasPreviousPage": false}, "nodes": [] } } } } } `)), ) }, assertDisc: func(t *testing.T, d *Discussion) { comments := d.Comments assert.Len(t, comments.Comments, 0) assert.Equal(t, 0, comments.TotalCount) assert.Equal(t, DiscussionCommentListDirectionForward, comments.Direction) }, }, { name: "first page newest reverses comments", limit: 5, newest: true, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionWithComments\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": true, "discussion": { "id": "D_1", "number": 1, "title": "Test", "body": "", "url": "", "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": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", "locked": false, "comments": { "totalCount": 8, "pageInfo": {"endCursor": "", "hasNextPage": false, "startCursor": "CUR_START", "hasPreviousPage": true}, "nodes": [ { "id": "C4", "url": "", "author": {"__typename": "User", "login": "alice"}, "body": "Fourth", "createdAt": "2025-01-04T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [], "replies": {"totalCount": 0, "nodes": []} }, { "id": "C5", "url": "", "author": {"__typename": "User", "login": "bob"}, "body": "Fifth", "createdAt": "2025-01-05T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [], "replies": {"totalCount": 0, "nodes": []} } ] } } } } } `)), ) }, assertDisc: func(t *testing.T, d *Discussion) { comments := d.Comments assert.Len(t, comments.Comments, 2) assert.Equal(t, 8, comments.TotalCount) assert.Equal(t, "", comments.Cursor) assert.Equal(t, "CUR_START", comments.NextCursor) assert.Equal(t, DiscussionCommentListDirectionBackward, comments.Direction) assert.Equal(t, "C5", comments.Comments[0].ID, "newest mode should reverse comments") assert.Equal(t, "C4", comments.Comments[1].ID) }, }, { name: "multiple replies on comment", limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionWithComments\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": true, "discussion": { "id": "D_1", "number": 1, "title": "Test", "body": "", "url": "", "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": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", "locked": false, "comments": { "totalCount": 1, "pageInfo": {"endCursor": "", "hasNextPage": false, "startCursor": "", "hasPreviousPage": false}, "nodes": [ { "id": "C1", "url": "", "author": {"__typename": "User", "login": "alice"}, "body": "Parent", "createdAt": "2025-01-01T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [], "replies": { "totalCount": 3, "nodes": [ { "id": "R1", "url": "", "author": {"__typename": "User", "login": "bob"}, "body": "First reply", "createdAt": "2025-01-02T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [] }, { "id": "R2", "url": "", "author": {"__typename": "User", "login": "carol"}, "body": "Second reply", "createdAt": "2025-01-03T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [] }, { "id": "R3", "url": "", "author": {"__typename": "User", "login": "dave"}, "body": "Third reply", "createdAt": "2025-01-04T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [] } ] } } ] } } } } } `)), ) }, assertDisc: func(t *testing.T, d *Discussion) { comments := d.Comments assert.Len(t, comments.Comments, 1) assert.Equal(t, 1, comments.TotalCount) assert.Equal(t, DiscussionCommentListDirectionForward, comments.Direction) c := comments.Comments[0] require.Len(t, c.Replies.Comments, 3) assert.Equal(t, 3, c.Replies.TotalCount) assert.Equal(t, "R1", c.Replies.Comments[0].ID) assert.Equal(t, "R2", c.Replies.Comments[1].ID) assert.Equal(t, "R3", c.Replies.Comments[2].ID) }, }, } 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) require.NotNil(t, tt.assertDisc, "assertDisc must be set for non-error cases") tt.assertDisc(t, d) }) } } 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 assertDisc func(*testing.T, *Discussion) }{ { name: "maps all fields", commentID: "DC_abc", limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionCommentReplies\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": true, "discussion": { "id": "D_1", "number": 42, "title": "Test Discussion", "body": "Discussion body", "url": "https://github.com/OWNER/REPO/discussions/42", "closed": true, "stateReason": "RESOLVED", "isAnswered": true, "answerChosenAt": "2025-06-01T12:00:00Z", "author": {"__typename": "User", "login": "alice", "id": "U_alice", "name": "Alice"}, "category": {"id": "CAT1", "name": "Q&A", "slug": "q-a", "emoji": ":question:", "isAnswerable": true}, "answerChosenBy": {"__typename": "User", "login": "bob", "id": "U_bob", "name": "Bob"}, "labels": {"nodes": [{"id": "L1", "name": "bug", "color": "d73a4a"}]}, "reactionGroups": [{"content": "THUMBS_UP", "users": {"totalCount": 3}}], "createdAt": "2025-01-01T00:00:00Z", "updatedAt": "2025-01-02T00:00:00Z", "closedAt": "2025-06-01T00:00:00Z", "locked": true } }, "node": { "id": "DC_abc", "url": "https://github.com/OWNER/REPO/discussions/42#discussioncomment-1", "author": {"__typename": "User", "login": "octocat", "id": "U_octocat", "name": "Octocat"}, "body": "Top-level comment", "createdAt": "2025-03-01T00:00:00Z", "isAnswer": true, "upvoteCount": 5, "reactionGroups": [{"content": "HEART", "users": {"totalCount": 2}}], "replies": { "totalCount": 1, "pageInfo": {"endCursor": "REP_CUR", "hasNextPage": true, "startCursor": "REP_START", "hasPreviousPage": false}, "nodes": [ { "id": "R1", "url": "https://github.com/OWNER/REPO/discussions/42#discussioncomment-2", "author": {"__typename": "User", "login": "hubot", "id": "U_hubot", "name": "Hubot"}, "body": "A reply", "createdAt": "2025-04-01T00:00:00Z", "isAnswer": false, "upvoteCount": 1, "reactionGroups": [{"content": "THUMBS_UP", "users": {"totalCount": 1}}] } ] } } } } `)), ) }, assertDisc: func(t *testing.T, d *Discussion) { assert.Equal(t, Discussion{ ID: "D_1", Number: 42, Title: "Test Discussion", Body: "Discussion body", URL: "https://github.com/OWNER/REPO/discussions/42", Closed: true, StateReason: "RESOLVED", Author: DiscussionActor{ID: "U_alice", Login: "alice", Name: "Alice"}, Category: DiscussionCategory{ ID: "CAT1", Name: "Q&A", Slug: "q-a", Emoji: ":question:", IsAnswerable: true, }, Labels: []DiscussionLabel{{ID: "L1", Name: "bug", Color: "d73a4a"}}, Answered: true, AnswerChosenAt: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC), AnswerChosenBy: &DiscussionActor{ID: "U_bob", Login: "bob", Name: "Bob"}, ReactionGroups: []ReactionGroup{{Content: "THUMBS_UP", TotalCount: 3}}, CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), ClosedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), Locked: true, Comments: DiscussionCommentList{ TotalCount: 1, Comments: []DiscussionComment{ { ID: "DC_abc", URL: "https://github.com/OWNER/REPO/discussions/42#discussioncomment-1", Author: DiscussionActor{ID: "U_octocat", Login: "octocat", Name: "Octocat"}, Body: "Top-level comment", CreatedAt: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), IsAnswer: true, UpvoteCount: 5, ReactionGroups: []ReactionGroup{{Content: "HEART", TotalCount: 2}}, Replies: DiscussionCommentList{ TotalCount: 1, NextCursor: "REP_CUR", Direction: DiscussionCommentListDirectionForward, Comments: []DiscussionComment{ { ID: "R1", URL: "https://github.com/OWNER/REPO/discussions/42#discussioncomment-2", Author: DiscussionActor{ID: "U_hubot", Login: "hubot", Name: "Hubot"}, Body: "A reply", CreatedAt: time.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC), UpvoteCount: 1, ReactionGroups: []ReactionGroup{{Content: "THUMBS_UP", TotalCount: 1}}, }, }, }, }, }, }, }, *d) }, }, { name: "pagination forward oldest", commentID: "DC_abc", limit: 5, after: "CUR_A", newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionCommentReplies\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": true, "discussion": { "id": "D_1", "number": 1, "title": "Test", "body": "", "url": "", "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": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", "locked": false } }, "node": { "id": "DC_abc", "url": "", "author": {"__typename": "User", "login": "alice"}, "body": "Comment", "createdAt": "2025-01-01T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [], "replies": { "totalCount": 3, "pageInfo": {"endCursor": "CUR_B", "hasNextPage": true, "startCursor": "CUR_A", "hasPreviousPage": false}, "nodes": [ { "id": "R1", "url": "", "author": {"__typename": "User", "login": "bob"}, "body": "Reply 1", "createdAt": "2025-02-01T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [] }, { "id": "R2", "url": "", "author": {"__typename": "User", "login": "carol"}, "body": "Reply 2", "createdAt": "2025-03-01T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [] } ] } } } } `)), ) }, assertDisc: func(t *testing.T, d *Discussion) { replies := d.Comments.Comments[0].Replies assert.Len(t, replies.Comments, 2) assert.Equal(t, 3, replies.TotalCount) assert.Equal(t, "CUR_A", replies.Cursor) assert.Equal(t, "CUR_B", replies.NextCursor) assert.Equal(t, DiscussionCommentListDirectionForward, replies.Direction) assert.Equal(t, "R1", replies.Comments[0].ID, "forward mode should preserve chronological order") assert.Equal(t, "R2", replies.Comments[1].ID) }, }, { name: "pagination backward newest reverses replies", commentID: "DC_abc", limit: 5, after: "CUR_X", newest: true, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionCommentReplies\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": true, "discussion": { "id": "D_1", "number": 1, "title": "Test", "body": "", "url": "", "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": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", "locked": false } }, "node": { "id": "DC_abc", "url": "", "author": {"__typename": "User", "login": "alice"}, "body": "Comment", "createdAt": "2025-01-01T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [], "replies": { "totalCount": 5, "pageInfo": {"endCursor": "CUR_END", "hasNextPage": false, "startCursor": "CUR_Y", "hasPreviousPage": true}, "nodes": [ { "id": "R1", "url": "", "author": {"__typename": "User", "login": "bob"}, "body": "Older", "createdAt": "2025-02-01T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [] }, { "id": "R2", "url": "", "author": {"__typename": "User", "login": "carol"}, "body": "Newer", "createdAt": "2025-03-01T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [] } ] } } } } `)), ) }, assertDisc: func(t *testing.T, d *Discussion) { replies := d.Comments.Comments[0].Replies assert.Len(t, replies.Comments, 2) assert.Equal(t, 5, replies.TotalCount) assert.Equal(t, "CUR_X", replies.Cursor) assert.Equal(t, "CUR_Y", replies.NextCursor) assert.Equal(t, DiscussionCommentListDirectionBackward, replies.Direction) assert.Equal(t, "R2", replies.Comments[0].ID, "newest mode should reverse replies") assert.Equal(t, "R1", replies.Comments[1].ID) }, }, { name: "first page newest reverses replies", commentID: "DC_abc", limit: 5, newest: true, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionCommentReplies\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": true, "discussion": { "id": "D_1", "number": 1, "title": "Test", "body": "", "url": "", "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": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", "locked": false } }, "node": { "id": "DC_abc", "url": "", "author": {"__typename": "User", "login": "alice"}, "body": "Comment", "createdAt": "2025-01-01T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [], "replies": { "totalCount": 3, "pageInfo": {"endCursor": "", "hasNextPage": false, "startCursor": "CUR_START", "hasPreviousPage": true}, "nodes": [ { "id": "R1", "url": "", "author": {"__typename": "User", "login": "bob"}, "body": "Older", "createdAt": "2025-02-01T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [] }, { "id": "R2", "url": "", "author": {"__typename": "User", "login": "carol"}, "body": "Newer", "createdAt": "2025-03-01T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [] } ] } } } } `)), ) }, assertDisc: func(t *testing.T, d *Discussion) { replies := d.Comments.Comments[0].Replies assert.Len(t, replies.Comments, 2) assert.Equal(t, 3, replies.TotalCount) assert.Equal(t, "", replies.Cursor) assert.Equal(t, "CUR_START", replies.NextCursor) assert.Equal(t, DiscussionCommentListDirectionBackward, replies.Direction) assert.Equal(t, "R2", replies.Comments[0].ID, "newest mode should reverse replies") assert.Equal(t, "R1", replies.Comments[1].ID) }, }, { name: "no more pages", commentID: "DC_abc", limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionCommentReplies\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": true, "discussion": { "id": "D_1", "number": 1, "title": "Test", "body": "", "url": "", "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": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", "locked": false } }, "node": { "id": "DC_abc", "url": "", "author": {"__typename": "User", "login": "alice"}, "body": "Comment", "createdAt": "2025-01-01T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [], "replies": { "totalCount": 1, "pageInfo": {"endCursor": "CUR_ONLY", "hasNextPage": false, "startCursor": "CUR_ONLY", "hasPreviousPage": false}, "nodes": [ { "id": "R1", "url": "", "author": {"__typename": "User", "login": "bob"}, "body": "Only reply", "createdAt": "2025-02-01T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [] } ] } } } } `)), ) }, assertDisc: func(t *testing.T, d *Discussion) { replies := d.Comments.Comments[0].Replies assert.Len(t, replies.Comments, 1) assert.Equal(t, 1, replies.TotalCount) assert.Equal(t, "", replies.NextCursor) assert.Equal(t, DiscussionCommentListDirectionForward, replies.Direction) }, }, { name: "discussions disabled", commentID: "DC_abc", limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionCommentReplies\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": false, "discussion": null }, "node": { "id": "DC_abc", "url": "", "author": {"__typename": "User", "login": "alice"}, "body": "Comment", "createdAt": "2025-01-01T00:00:00Z", "isAnswer": false, "upvoteCount": 0, "reactionGroups": [], "replies": { "totalCount": 0, "pageInfo": {"endCursor": null, "hasNextPage": false, "startCursor": null, "hasPreviousPage": false}, "nodes": [] } } }, "errors": [ { "type": "NOT_FOUND", "path": ["repository", "discussion"], "message": "Could not resolve to a Discussion with the number of 1." } ] } `)), ) }, wantErr: "Could not resolve to a Discussion", }, { name: "repo not found", commentID: "DC_abc", limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionCommentReplies\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": null, "node": null }, "errors": [ { "type": "NOT_FOUND", "path": ["repository"], "message": "Could not resolve to a Repository with the name 'OWNER/REPO'." } ] } `)), ) }, wantErr: "Could not resolve to a Repository", }, { name: "reply node not found", commentID: "DC_invalid", limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionCommentReplies\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": true, "discussion": { "id": "D_1", "number": 1, "title": "Test", "body": "", "url": "", "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": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", "locked": false } }, "node": null }, "errors": [ { "type": "NOT_FOUND", "path": ["node"], "message": "Could not resolve to a node with the global id of 'DC_invalid'" } ] } `)), ) }, wantErr: "Could not resolve to a node", }, { name: "node is not a discussion comment", commentID: "I_notacomment", limit: 10, newest: false, httpStubs: func(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query DiscussionCommentReplies\b`), httpmock.StringResponse(heredoc.Doc(` { "data": { "repository": { "hasDiscussionsEnabled": true, "discussion": { "id": "D_1", "number": 1, "title": "Test", "body": "", "url": "", "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": [], "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z", "closedAt": "0001-01-01T00:00:00Z", "locked": false } }, "node": {} } } `)), ) }, 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") require.NotNil(t, tt.assertDisc, "assertDisc must be set for non-error cases") tt.assertDisc(t, d) }) } }