|
4 | 4 | "context" |
5 | 5 | "encoding/json" |
6 | 6 | "fmt" |
| 7 | + "io" |
7 | 8 | "math" |
8 | 9 | "net/http" |
9 | 10 | "strings" |
@@ -57,6 +58,55 @@ func projectIDMatcher(owner string, projectNumber int, projectNodeID string) git |
57 | 58 | ) |
58 | 59 | } |
59 | 60 |
|
| 61 | +// mutationAwareTransport routes GraphQL requests to a fixed query-matcher |
| 62 | +// transport (e.g. githubv4mock.NewMockedHTTPClient's Transport) for ordinary |
| 63 | +// queries/lookups, and to a sequenced, call-counted responder for mutation |
| 64 | +// requests, so end-to-end tests can assert on aliased-mutation call counts and |
| 65 | +// per-call variables without needing to hand-construct the exact minified |
| 66 | +// mutation query text that reflect.StructOf produces. |
| 67 | +type mutationAwareTransport struct { |
| 68 | + t *testing.T |
| 69 | + queries http.RoundTripper |
| 70 | + mutationRespond func(callIndex int, req capturedGraphQLRequest) (status int, body string) |
| 71 | + queryCalls []capturedGraphQLRequest |
| 72 | + mutationCalls []capturedGraphQLRequest |
| 73 | +} |
| 74 | + |
| 75 | +func (m *mutationAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) { |
| 76 | + raw, err := io.ReadAll(req.Body) |
| 77 | + if err != nil { |
| 78 | + return nil, err |
| 79 | + } |
| 80 | + _ = req.Body.Close() |
| 81 | + |
| 82 | + var parsed struct { |
| 83 | + Query string `json:"query"` |
| 84 | + Variables map[string]any `json:"variables"` |
| 85 | + } |
| 86 | + if err := json.Unmarshal(raw, &parsed); err != nil { |
| 87 | + return nil, err |
| 88 | + } |
| 89 | + |
| 90 | + if !strings.HasPrefix(strings.TrimSpace(parsed.Query), "mutation") { |
| 91 | + m.queryCalls = append(m.queryCalls, capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables}) |
| 92 | + req.Body = io.NopCloser(strings.NewReader(string(raw))) |
| 93 | + return m.queries.RoundTrip(req) |
| 94 | + } |
| 95 | + |
| 96 | + captured := capturedGraphQLRequest{Query: parsed.Query, Variables: parsed.Variables} |
| 97 | + idx := len(m.mutationCalls) |
| 98 | + m.mutationCalls = append(m.mutationCalls, captured) |
| 99 | + if m.mutationRespond == nil { |
| 100 | + m.t.Fatalf("unexpected mutation call #%d (query: %s)", idx, parsed.Query) |
| 101 | + } |
| 102 | + status, body := m.mutationRespond(idx, captured) |
| 103 | + return &http.Response{ |
| 104 | + StatusCode: status, |
| 105 | + Body: io.NopCloser(strings.NewReader(body)), |
| 106 | + Header: make(http.Header), |
| 107 | + }, nil |
| 108 | +} |
| 109 | + |
60 | 110 | func Test_UpdateProjectItemsBatch_TopLevelGuards(t *testing.T) { |
61 | 111 | tooMany := make([]any, maxProjectItemsPerBatch+1) |
62 | 112 | validItem := map[string]any{"node_id": "PVTI_item1"} |
@@ -1076,3 +1126,160 @@ func Test_ResolveItemNodeIDsByNumericID_DeduplicatesOrgAndUserLookups(t *testing |
1076 | 1126 | }) |
1077 | 1127 | } |
1078 | 1128 | } |
| 1129 | + |
| 1130 | +func Test_ExecuteBatchWrites_AllAliasGraphQLErrorContinues(t *testing.T) { |
| 1131 | + transport := &sequencedGraphQLTransport{ |
| 1132 | + t: t, |
| 1133 | + responses: []func(capturedGraphQLRequest) (int, string){ |
| 1134 | + func(_ capturedGraphQLRequest) (int, string) { |
| 1135 | + return http.StatusOK, mutationErrorResponse(t, map[string]any{}, "all aliases failed") |
| 1136 | + }, |
| 1137 | + func(_ capturedGraphQLRequest) (int, string) { |
| 1138 | + return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ |
| 1139 | + 0: {NodeID: "PVTI_item20", FullDatabaseID: "1020"}, |
| 1140 | + }) |
| 1141 | + }, |
| 1142 | + }, |
| 1143 | + } |
| 1144 | + items, results := batchItemsOfSize(21) |
| 1145 | + |
| 1146 | + executeTestBatchWrites(t.Context(), newTestGQLClient(transport), items, results) |
| 1147 | + |
| 1148 | + assert.Len(t, transport.calls, 2) |
| 1149 | + for i := range 20 { |
| 1150 | + assert.Equal(t, batchItemUnknown, results[i].Status) |
| 1151 | + } |
| 1152 | + assert.Equal(t, batchItemSucceeded, results[20].Status) |
| 1153 | +} |
| 1154 | + |
| 1155 | +func Test_ExecuteBatchWrites_PartialGraphQLErrorPreservesSuccess(t *testing.T) { |
| 1156 | + transport := &sequencedGraphQLTransport{ |
| 1157 | + t: t, |
| 1158 | + responses: []func(capturedGraphQLRequest) (int, string){ |
| 1159 | + func(_ capturedGraphQLRequest) (int, string) { |
| 1160 | + return http.StatusOK, mutationErrorResponse(t, map[string]any{ |
| 1161 | + "item0": map[string]any{ |
| 1162 | + "projectV2Item": map[string]any{"id": "PVTI_item0", "fullDatabaseId": "1000"}, |
| 1163 | + }, |
| 1164 | + "item1": nil, |
| 1165 | + }, "item1 failed") |
| 1166 | + }, |
| 1167 | + }, |
| 1168 | + } |
| 1169 | + items, results := batchItemsOfSize(2) |
| 1170 | + |
| 1171 | + executeTestBatchWrites(t.Context(), newTestGQLClient(transport), items, results) |
| 1172 | + |
| 1173 | + assert.Equal(t, batchItemSucceeded, results[0].Status) |
| 1174 | + assert.Equal(t, items[0].ref, results[0].Ref) |
| 1175 | + assert.Equal(t, batchItemUnknown, results[1].Status) |
| 1176 | + assert.Equal(t, items[1].ref, results[1].Ref) |
| 1177 | +} |
| 1178 | + |
| 1179 | +func Test_ExecuteBatchWrites_AmbiguousSuccessResponseAborts(t *testing.T) { |
| 1180 | + tests := []struct { |
| 1181 | + name string |
| 1182 | + body string |
| 1183 | + confirmedSuccesses int |
| 1184 | + }{ |
| 1185 | + { |
| 1186 | + name: "null data", |
| 1187 | + body: `{"data":null}`, |
| 1188 | + }, |
| 1189 | + { |
| 1190 | + name: "missing data", |
| 1191 | + body: `{}`, |
| 1192 | + }, |
| 1193 | + { |
| 1194 | + name: "partial data without errors", |
| 1195 | + body: mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ |
| 1196 | + 0: {NodeID: "PVTI_item0", FullDatabaseID: "1000"}, |
| 1197 | + }), |
| 1198 | + confirmedSuccesses: 1, |
| 1199 | + }, |
| 1200 | + } |
| 1201 | + |
| 1202 | + for _, tt := range tests { |
| 1203 | + t.Run(tt.name, func(t *testing.T) { |
| 1204 | + transport := &sequencedGraphQLTransport{ |
| 1205 | + t: t, |
| 1206 | + responses: []func(capturedGraphQLRequest) (int, string){ |
| 1207 | + func(_ capturedGraphQLRequest) (int, string) { |
| 1208 | + return http.StatusOK, tt.body |
| 1209 | + }, |
| 1210 | + func(_ capturedGraphQLRequest) (int, string) { |
| 1211 | + return http.StatusOK, mutationDataResponse(t, map[int]struct{ NodeID, FullDatabaseID string }{ |
| 1212 | + 0: {NodeID: "PVTI_item20", FullDatabaseID: "1020"}, |
| 1213 | + }) |
| 1214 | + }, |
| 1215 | + }, |
| 1216 | + } |
| 1217 | + items, results := batchItemsOfSize(21) |
| 1218 | + |
| 1219 | + executeTestBatchWrites(t.Context(), newTestGQLClient(transport), items, results) |
| 1220 | + |
| 1221 | + assert.Len(t, transport.calls, 1) |
| 1222 | + for i, result := range results { |
| 1223 | + if i < tt.confirmedSuccesses { |
| 1224 | + assert.Equal(t, batchItemSucceeded, result.Status) |
| 1225 | + continue |
| 1226 | + } |
| 1227 | + assert.Equal(t, batchItemUnknown, result.Status) |
| 1228 | + } |
| 1229 | + }) |
| 1230 | + } |
| 1231 | +} |
| 1232 | + |
| 1233 | +func Test_ExecuteBatchWrites_TransportTimeoutAborts(t *testing.T) { |
| 1234 | + transport := &errorGraphQLTransport{err: context.DeadlineExceeded} |
| 1235 | + items, results := batchItemsOfSize(21) |
| 1236 | + |
| 1237 | + executeTestBatchWrites(t.Context(), newTestGQLClient(transport), items, results) |
| 1238 | + |
| 1239 | + assert.Equal(t, 1, transport.calls) |
| 1240 | + for _, result := range results { |
| 1241 | + assert.Equal(t, batchItemUnknown, result.Status) |
| 1242 | + } |
| 1243 | +} |
| 1244 | + |
| 1245 | +func Test_ExecuteBatchWrites_CanceledContextSkipsWrites(t *testing.T) { |
| 1246 | + ctx, cancel := context.WithCancel(t.Context()) |
| 1247 | + cancel() |
| 1248 | + transport := &sequencedGraphQLTransport{t: t} |
| 1249 | + items, results := batchItemsOfSize(21) |
| 1250 | + |
| 1251 | + executeTestBatchWrites(ctx, newTestGQLClient(transport), items, results) |
| 1252 | + |
| 1253 | + assert.Empty(t, transport.calls) |
| 1254 | + for _, result := range results { |
| 1255 | + assert.Equal(t, batchItemUnknown, result.Status) |
| 1256 | + } |
| 1257 | +} |
| 1258 | + |
| 1259 | +func executeTestBatchWrites(ctx context.Context, gqlClient *githubv4.Client, items []resolvedBatchItem, results []batchItemResult) { |
| 1260 | + executeBatchWrites( |
| 1261 | + ctx, |
| 1262 | + batchWriteOperation{ |
| 1263 | + gqlClient: gqlClient, |
| 1264 | + kind: batchMutationUpdate, |
| 1265 | + projectID: githubv4.ID("PVT_project"), |
| 1266 | + fieldID: githubv4.ID("PVTF_field"), |
| 1267 | + value: githubv4.ProjectV2FieldValue{Text: githubv4.NewString("value")}, |
| 1268 | + }, |
| 1269 | + items, |
| 1270 | + results, |
| 1271 | + ) |
| 1272 | +} |
| 1273 | + |
| 1274 | +func batchItemsOfSize(n int) ([]resolvedBatchItem, []batchItemResult) { |
| 1275 | + items := make([]resolvedBatchItem, n) |
| 1276 | + for i := range n { |
| 1277 | + nodeID := fmt.Sprintf("PVTI_item%d", i) |
| 1278 | + items[i] = resolvedBatchItem{ |
| 1279 | + index: i, |
| 1280 | + ref: map[string]any{"node_id": nodeID}, |
| 1281 | + nodeID: nodeID, |
| 1282 | + } |
| 1283 | + } |
| 1284 | + return items, make([]batchItemResult, n) |
| 1285 | +} |
0 commit comments