4ed4f816cc
* Extract aliased project mutation primitive Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d7dc302d-e6f2-41e9-a2c8-ed598de47067 * Test partial GraphQL mutation data Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4d3df48a-5aa0-4cf0-a067-4aa5618c2887 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
319 lines
11 KiB
Go
319 lines
11 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/shurcooL/githubv4"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// capturedGraphQLRequest is one HTTP request observed by sequencedGraphQLTransport.
|
|
type capturedGraphQLRequest struct {
|
|
Query string
|
|
Variables map[string]any
|
|
}
|
|
|
|
// sequencedGraphQLTransport is a minimal fake http.RoundTripper for exercising
|
|
// executeAliasedMutation without needing to hand-construct
|
|
// the exact minified GraphQL query text that reflect.StructOf produces: each call
|
|
// is served by the next entry in responses, in order, and the parsed query +
|
|
// variables are recorded for assertions.
|
|
type sequencedGraphQLTransport struct {
|
|
t *testing.T
|
|
responses []func(req capturedGraphQLRequest) (status int, body string)
|
|
calls []capturedGraphQLRequest
|
|
}
|
|
|
|
func (s *sequencedGraphQLTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
raw, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var parsed struct {
|
|
Query string `json:"query"`
|
|
Variables map[string]any `json:"variables"`
|
|
}
|
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
|
return nil, err
|
|
}
|
|
captured := capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables}
|
|
s.calls = append(s.calls, captured)
|
|
|
|
idx := len(s.calls) - 1
|
|
if idx >= len(s.responses) {
|
|
s.t.Fatalf("unexpected GraphQL call #%d (query: %s)", idx, parsed.Query)
|
|
}
|
|
status, body := s.responses[idx](captured)
|
|
return &http.Response{
|
|
StatusCode: status,
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
}
|
|
|
|
type errorGraphQLTransport struct {
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (t *errorGraphQLTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
|
t.calls++
|
|
return nil, t.err
|
|
}
|
|
|
|
// mutationDataResponse builds a `{"data": {...}}` JSON body with one
|
|
// "itemN"."projectV2Item" entry per populated index in ids.
|
|
func mutationDataResponse(t *testing.T, ids map[int]struct{ NodeID, FullDatabaseID string }) string {
|
|
t.Helper()
|
|
data := make(map[string]any, len(ids))
|
|
for i, v := range ids {
|
|
data[fmt.Sprintf("item%d", i)] = map[string]any{
|
|
"projectV2Item": map[string]any{
|
|
"id": v.NodeID,
|
|
"fullDatabaseId": v.FullDatabaseID,
|
|
},
|
|
}
|
|
}
|
|
body, err := json.Marshal(map[string]any{"data": data})
|
|
require.NoError(t, err)
|
|
return string(body)
|
|
}
|
|
|
|
func mutationErrorResponse(t *testing.T, data map[string]any, message string) string {
|
|
t.Helper()
|
|
payload := map[string]any{
|
|
"errors": []map[string]any{{"message": message}},
|
|
}
|
|
if data != nil {
|
|
payload["data"] = data
|
|
}
|
|
body, err := json.Marshal(payload)
|
|
require.NoError(t, err)
|
|
return string(body)
|
|
}
|
|
|
|
func newTestGQLClient(transport http.RoundTripper) *githubv4.Client {
|
|
return githubv4.NewClient(&http.Client{Transport: transport})
|
|
}
|
|
|
|
func inputsOfSize(n int) []githubv4.Input {
|
|
inputs := make([]githubv4.Input, n)
|
|
for i := range n {
|
|
inputs[i] = githubv4.UpdateProjectV2ItemFieldValueInput{
|
|
ProjectID: githubv4.ID("PVT_project"),
|
|
ItemID: githubv4.ID(fmt.Sprintf("PVTI_item%d", i)),
|
|
FieldID: githubv4.ID("PVTF_field"),
|
|
Value: githubv4.ProjectV2FieldValue{Text: githubv4.NewString("v")},
|
|
}
|
|
}
|
|
return inputs
|
|
}
|
|
|
|
func Test_BuildAliasedMutationType_FieldNamesAndTags(t *testing.T) {
|
|
for _, size := range []int{1, 2, 20} {
|
|
t.Run(fmt.Sprintf("size=%d", size), func(t *testing.T) {
|
|
typ := buildAliasedMutationType(batchMutationUpdate, size)
|
|
require.Equal(t, size, typ.NumField())
|
|
for i := range size {
|
|
field := typ.Field(i)
|
|
assert.Equal(t, fmt.Sprintf("Item%d", i), field.Name)
|
|
|
|
tag, ok := field.Tag.Lookup("graphql")
|
|
require.True(t, ok)
|
|
|
|
wantVar := "input"
|
|
if i > 0 {
|
|
wantVar = fmt.Sprintf("input%d", i)
|
|
}
|
|
wantTag := fmt.Sprintf("item%d: updateProjectV2ItemFieldValue(input: $%s)", i, wantVar)
|
|
assert.Equal(t, wantTag, tag)
|
|
|
|
// No owner/id/name/value data may ever appear in the tag: only
|
|
// positional aliases and variable references.
|
|
assert.NotContains(t, tag, "PVT_")
|
|
assert.NotContains(t, tag, "octo")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_BuildAliasedMutationType_ClearKindUsesClearMutation(t *testing.T) {
|
|
typ := buildAliasedMutationType(batchMutationClear, 2)
|
|
tag0 := typ.Field(0).Tag.Get("graphql")
|
|
tag1 := typ.Field(1).Tag.Get("graphql")
|
|
assert.Equal(t, "item0: clearProjectV2ItemFieldValue(input: $input)", tag0)
|
|
assert.Equal(t, "item1: clearProjectV2ItemFieldValue(input: $input1)", tag1)
|
|
}
|
|
|
|
func Test_BuildAliasedMutationType_CachedByKindAndSize(t *testing.T) {
|
|
a := buildAliasedMutationType(batchMutationUpdate, 3)
|
|
b := buildAliasedMutationType(batchMutationUpdate, 3)
|
|
assert.True(t, a == b, "expected the same cached reflect.Type for identical (kind, size)")
|
|
|
|
c := buildAliasedMutationType(batchMutationClear, 3)
|
|
assert.False(t, a == c, "update and clear must not share a cached type")
|
|
|
|
d := buildAliasedMutationType(batchMutationUpdate, 4)
|
|
assert.False(t, a == d, "different sizes must not share a cached type")
|
|
}
|
|
|
|
func Test_ExecuteAliasedMutation_OneAlias(t *testing.T) {
|
|
transport := &sequencedGraphQLTransport{
|
|
t: t,
|
|
responses: []func(capturedGraphQLRequest) (int, string){
|
|
func(req capturedGraphQLRequest) (int, string) {
|
|
// Single alias: the only input is bound positionally via
|
|
// Client.Mutate's third argument, so no extra variables map entries.
|
|
assert.Len(t, req.Variables, 1)
|
|
assert.Contains(t, req.Variables, "input")
|
|
return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{
|
|
0: {NodeID: "PVTI_item0", FullDatabaseID: "1001"},
|
|
})
|
|
},
|
|
},
|
|
}
|
|
gqlClient := newTestGQLClient(transport)
|
|
|
|
outcomes, err := executeAliasedMutation(context.Background(), gqlClient, batchMutationUpdate, inputsOfSize(1))
|
|
require.NoError(t, err)
|
|
require.Len(t, outcomes, 1)
|
|
assert.True(t, outcomes[0].Populated)
|
|
assert.Equal(t, "PVTI_item0", outcomes[0].NodeID)
|
|
assert.Equal(t, "1001", outcomes[0].FullDatabaseID)
|
|
}
|
|
|
|
func Test_ExecuteAliasedMutation_TwoAliases_FirstInputWorkaround(t *testing.T) {
|
|
transport := &sequencedGraphQLTransport{
|
|
t: t,
|
|
responses: []func(capturedGraphQLRequest) (int, string){
|
|
func(req capturedGraphQLRequest) (int, string) {
|
|
// Alias 0's input is always bound to the reserved "input" wire
|
|
// variable by Client.Mutate; alias 1's input must be supplied
|
|
// separately (as "input1") since a GraphQL variable can only be
|
|
// referenced with one value per request.
|
|
require.Contains(t, req.Variables, "input1")
|
|
require.Contains(t, req.Variables, "input")
|
|
return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{
|
|
0: {NodeID: "PVTI_item0", FullDatabaseID: "1001"},
|
|
1: {NodeID: "PVTI_item1", FullDatabaseID: "1002"},
|
|
})
|
|
},
|
|
},
|
|
}
|
|
gqlClient := newTestGQLClient(transport)
|
|
|
|
outcomes, err := executeAliasedMutation(context.Background(), gqlClient, batchMutationUpdate, inputsOfSize(2))
|
|
require.NoError(t, err)
|
|
require.Len(t, outcomes, 2)
|
|
assert.True(t, outcomes[0].Populated)
|
|
assert.True(t, outcomes[1].Populated)
|
|
}
|
|
|
|
func Test_ExecuteAliasedMutation_PreservesPartialDataWithGraphQLErrors(t *testing.T) {
|
|
transport := &sequencedGraphQLTransport{
|
|
t: t,
|
|
responses: []func(capturedGraphQLRequest) (int, string){
|
|
func(capturedGraphQLRequest) (int, string) {
|
|
data := map[string]any{
|
|
"item0": map[string]any{
|
|
"projectV2Item": map[string]any{
|
|
"id": "PVTI_item0",
|
|
"fullDatabaseId": "1001",
|
|
},
|
|
},
|
|
}
|
|
return http.StatusOK, mutationErrorResponse(t, data, "item1 failed")
|
|
},
|
|
},
|
|
}
|
|
|
|
outcomes, err := executeAliasedMutation(t.Context(), newTestGQLClient(transport), batchMutationUpdate, inputsOfSize(2))
|
|
require.Error(t, err)
|
|
assert.True(t, isGraphQLResponseError(err))
|
|
require.Len(t, outcomes, 2)
|
|
assert.Equal(t, mutationAliasOutcome{
|
|
Populated: true,
|
|
NodeID: "PVTI_item0",
|
|
FullDatabaseID: "1001",
|
|
}, outcomes[0])
|
|
assert.Equal(t, mutationAliasOutcome{}, outcomes[1])
|
|
}
|
|
|
|
func Test_ExecuteAliasedMutation_TwentyAliases(t *testing.T) {
|
|
ids := make(map[int]struct{ NodeID, FullDatabaseID string }, 20)
|
|
for i := range 20 {
|
|
ids[i] = struct{ NodeID, FullDatabaseID string }{
|
|
NodeID: fmt.Sprintf("PVTI_item%d", i),
|
|
FullDatabaseID: fmt.Sprintf("%d", 1000+i),
|
|
}
|
|
}
|
|
transport := &sequencedGraphQLTransport{
|
|
t: t,
|
|
responses: []func(capturedGraphQLRequest) (int, string){
|
|
func(req capturedGraphQLRequest) (int, string) {
|
|
assert.Len(t, req.Variables, 20) // "input" (positional) plus input1..input19
|
|
return http.StatusOK, mutationDataResponse(t, ids)
|
|
},
|
|
},
|
|
}
|
|
gqlClient := newTestGQLClient(transport)
|
|
|
|
outcomes, err := executeAliasedMutation(context.Background(), gqlClient, batchMutationUpdate, inputsOfSize(20))
|
|
require.NoError(t, err)
|
|
require.Len(t, outcomes, 20)
|
|
for i, oc := range outcomes {
|
|
assert.Truef(t, oc.Populated, "outcome %d should be populated", i)
|
|
}
|
|
}
|
|
|
|
func Test_ExecuteAliasedMutation_ChunkSizeExceeded(t *testing.T) {
|
|
gqlClient := newTestGQLClient(&sequencedGraphQLTransport{t: t})
|
|
_, err := executeAliasedMutation(context.Background(), gqlClient, batchMutationUpdate, inputsOfSize(21))
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "exceeds wire chunk size")
|
|
}
|
|
|
|
func Test_ExecuteAliasedMutation_EmptyInputsIsNoop(t *testing.T) {
|
|
gqlClient := newTestGQLClient(&sequencedGraphQLTransport{t: t})
|
|
outcomes, err := executeAliasedMutation(context.Background(), gqlClient, batchMutationUpdate, nil)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, outcomes)
|
|
}
|
|
|
|
func Test_ProjectV2ItemMutationResult_ReflectFieldTypeIsConcrete(t *testing.T) {
|
|
// executeAliasedMutation type-asserts each reflected field back to
|
|
// projectV2ItemMutationResult directly; guard that assumption here.
|
|
typ := buildAliasedMutationType(batchMutationUpdate, 1)
|
|
assert.Equal(t, reflect.TypeFor[projectV2ItemMutationResult](), typ.Field(0).Type)
|
|
}
|
|
|
|
func Test_IsGraphQLResponseError(t *testing.T) {
|
|
graphqlTransport := &sequencedGraphQLTransport{
|
|
t: t,
|
|
responses: []func(capturedGraphQLRequest) (int, string){
|
|
func(_ capturedGraphQLRequest) (int, string) {
|
|
return http.StatusOK, mutationErrorResponse(t, nil, "mutation failed")
|
|
},
|
|
},
|
|
}
|
|
_, graphqlErr := executeAliasedMutation(t.Context(), newTestGQLClient(graphqlTransport), batchMutationUpdate, inputsOfSize(1))
|
|
require.Error(t, graphqlErr)
|
|
assert.True(t, isGraphQLResponseError(graphqlErr))
|
|
|
|
transport := &errorGraphQLTransport{err: context.DeadlineExceeded}
|
|
_, transportErr := executeAliasedMutation(t.Context(), newTestGQLClient(transport), batchMutationUpdate, inputsOfSize(1))
|
|
require.Error(t, transportErr)
|
|
assert.False(t, isGraphQLResponseError(transportErr))
|
|
assert.False(t, isGraphQLResponseError(errors.New("plain error")))
|
|
assert.False(t, isGraphQLResponseError(nil))
|
|
}
|