github.com/dgraph-io/dgraph@v1.2.8/graphql/resolve/resolver_error_test.go (about) 1 /* 2 * Copyright 2019 Dgraph Labs, Inc. and Contributors 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package resolve 18 19 import ( 20 "context" 21 "io/ioutil" 22 "testing" 23 24 dgoapi "github.com/dgraph-io/dgo/v2/protos/api" 25 "github.com/dgraph-io/dgraph/gql" 26 "github.com/dgraph-io/dgraph/graphql/schema" 27 "github.com/dgraph-io/dgraph/graphql/test" 28 "github.com/dgraph-io/dgraph/x" 29 "github.com/google/go-cmp/cmp" 30 "github.com/pkg/errors" 31 "github.com/stretchr/testify/require" 32 "gopkg.in/yaml.v2" 33 ) 34 35 // Tests that result completion and GraphQL error propagation are working properly. 36 37 // All the tests work on a mocked json response, rather than a running Dgraph. 38 // It's better to mock the Dgraph client interface in these tests and have cases 39 // where one can directly see the json response and how it gets modified, than 40 // to try and orchestrate conditions for all these complicated tests in a live 41 // Dgraph instance. Done on a real Dgraph, you also can't see the responses 42 // to see what the test is actually doing. 43 44 type executor struct { 45 resp string 46 assigned map[string]string 47 result map[string]interface{} 48 49 // start reporting Dgraph fails at this point (0 = never fail, 1 = fail on 50 // first request, 2 = succeed once and then fail on 2nd request, etc.) 51 failQuery int 52 failMutation int 53 } 54 55 type QueryCase struct { 56 Name string 57 GQLQuery string 58 Explanation string 59 Response string // Dgraph json response 60 Expected string // Expected data from Resolve() 61 Errors x.GqlErrorList 62 } 63 64 var testGQLSchema = ` 65 type Author { 66 id: ID! 67 name: String! 68 dob: DateTime 69 postsRequired: [Post!]! 70 postsElmntRequired: [Post!] 71 postsNullable: [Post] 72 postsNullableListRequired: [Post]! 73 } 74 75 type Post { 76 id: ID! 77 title: String! 78 text: String 79 author: Author! 80 }` 81 82 func (ex *executor) Query(ctx context.Context, query *gql.GraphQuery) ([]byte, error) { 83 ex.failQuery-- 84 if ex.failQuery == 0 { 85 return nil, schema.GQLWrapf(errors.New("_bad stuff happend_"), "Dgraph query failed") 86 } 87 return []byte(ex.resp), nil 88 } 89 90 func (ex *executor) Mutate(ctx context.Context, 91 query *gql.GraphQuery, 92 mutations []*dgoapi.Mutation) (map[string]string, map[string]interface{}, error) { 93 ex.failMutation-- 94 if ex.failMutation == 0 { 95 return nil, nil, schema.GQLWrapf(errors.New("_bad stuff happend_"), "Dgraph mutation failed") 96 } 97 return ex.assigned, ex.result, nil 98 } 99 100 // Tests in resolver_test.yaml are about what gets into a completed result (addition 101 // of "null", errors and error propagation). Exact JSON result (e.g. order) doesn't 102 // matter here - that makes for easier to format and read tests for these many cases. 103 // 104 // The []bytes built by Resolve() have some other properties, such as ordering of 105 // fields, which are tested by TestResponseOrder(). 106 func TestResolver(t *testing.T) { 107 b, err := ioutil.ReadFile("resolver_error_test.yaml") 108 require.NoError(t, err, "Unable to read test file") 109 110 var tests []QueryCase 111 err = yaml.Unmarshal(b, &tests) 112 require.NoError(t, err, "Unable to unmarshal tests to yaml.") 113 114 gqlSchema := test.LoadSchemaFromString(t, testGQLSchema) 115 116 for _, tcase := range tests { 117 t.Run(tcase.Name, func(t *testing.T) { 118 resp := resolve(gqlSchema, tcase.GQLQuery, tcase.Response) 119 120 if diff := cmp.Diff(tcase.Errors, resp.Errors); diff != "" { 121 t.Errorf("errors mismatch (-want +got):\n%s", diff) 122 } 123 124 require.JSONEq(t, tcase.Expected, resp.Data.String(), tcase.Explanation) 125 }) 126 } 127 } 128 129 // Ordering of results and inserted null values matters in GraphQL: 130 // https://graphql.github.io/graphql-spec/June2018/#sec-Serialized-Map-Ordering 131 func TestResponseOrder(t *testing.T) { 132 query := `query { 133 getAuthor(id: "0x1") { 134 name 135 dob 136 postsNullable { 137 title 138 text 139 } 140 } 141 }` 142 143 tests := []QueryCase{ 144 {Name: "Response is in same order as GQL query", 145 GQLQuery: query, 146 Response: `{ "getAuthor": [ { "name": "A.N. Author", "dob": "2000-01-01", ` + 147 `"postsNullable": [ ` + 148 `{ "title": "A Title", "text": "Some Text" }, ` + 149 `{ "title": "Another Title", "text": "More Text" } ] } ] }`, 150 Expected: `{"getAuthor": {"name": "A.N. Author", "dob": "2000-01-01", ` + 151 `"postsNullable": [` + 152 `{"title": "A Title", "text": "Some Text"}, ` + 153 `{"title": "Another Title", "text": "More Text"}]}}`}, 154 {Name: "Response is in same order as GQL query no matter Dgraph order", 155 GQLQuery: query, 156 Response: `{ "getAuthor": [ { "dob": "2000-01-01", "name": "A.N. Author", ` + 157 `"postsNullable": [ ` + 158 `{ "text": "Some Text", "title": "A Title" }, ` + 159 `{ "title": "Another Title", "text": "More Text" } ] } ] }`, 160 Expected: `{"getAuthor": {"name": "A.N. Author", "dob": "2000-01-01", ` + 161 `"postsNullable": [` + 162 `{"title": "A Title", "text": "Some Text"}, ` + 163 `{"title": "Another Title", "text": "More Text"}]}}`}, 164 {Name: "Inserted null is in GQL query order", 165 GQLQuery: query, 166 Response: `{ "getAuthor": [ { "name": "A.N. Author", ` + 167 `"postsNullable": [ ` + 168 `{ "title": "A Title" }, ` + 169 `{ "title": "Another Title", "text": "More Text" } ] } ] }`, 170 Expected: `{"getAuthor": {"name": "A.N. Author", "dob": null, ` + 171 `"postsNullable": [` + 172 `{"title": "A Title", "text": null}, ` + 173 `{"title": "Another Title", "text": "More Text"}]}}`}, 174 } 175 176 gqlSchema := test.LoadSchemaFromString(t, testGQLSchema) 177 178 for _, test := range tests { 179 t.Run(test.Name, func(t *testing.T) { 180 resp := resolve(gqlSchema, test.GQLQuery, test.Response) 181 182 require.Nil(t, resp.Errors) 183 require.Equal(t, test.Expected, resp.Data.String()) 184 }) 185 } 186 } 187 188 // For add and update mutations, we don't need to re-test all the cases from the 189 // query tests. So just test enough to demonstrate that we'll catch it if we were 190 // to delete the call to completeDgraphResult before adding to the response. 191 func TestAddMutationUsesErrorPropagation(t *testing.T) { 192 mutation := `mutation { 193 addPost(input: [{title: "A Post", text: "Some text", author: {id: "0x1"}}]) { 194 post { 195 title 196 text 197 author { 198 name 199 dob 200 } 201 } 202 } 203 }` 204 205 tests := map[string]struct { 206 explanation string 207 mutResponse map[string]string 208 mutQryResp map[string]interface{} 209 queryResponse string 210 expected string 211 errors x.GqlErrorList 212 }{ 213 "Add mutation adds missing nullable fields": { 214 explanation: "Field 'dob' is nullable, so null should be inserted " + 215 "if the mutation's query doesn't return a value.", 216 mutResponse: map[string]string{"Post1": "0x2"}, 217 mutQryResp: map[string]interface{}{ 218 "Author2": []interface{}{map[string]string{"uid": "0x1"}}}, 219 queryResponse: `{ "post" : [ 220 { "title": "A Post", 221 "text": "Some text", 222 "author": { "name": "A.N. Author" } } ] }`, 223 expected: `{ "addPost": { "post" : 224 [{ "title": "A Post", 225 "text": "Some text", 226 "author": { "name": "A.N. Author", "dob": null } }] } }`, 227 }, 228 "Add mutation triggers GraphQL error propagation": { 229 explanation: "An Author's name is non-nullable, so if that's missing, " + 230 "the author is squashed to null, but that's also non-nullable, so the " + 231 "propagates to the query root.", 232 mutResponse: map[string]string{"Post1": "0x2"}, 233 mutQryResp: map[string]interface{}{ 234 "Author2": []interface{}{map[string]string{"uid": "0x1"}}}, 235 queryResponse: `{ "post" : [ 236 { "title": "A Post", 237 "text": "Some text", 238 "author": { "dob": "2000-01-01" } } ] }`, 239 expected: `{ "addPost": { "post" : [null] } }`, 240 errors: x.GqlErrorList{&x.GqlError{ 241 Message: `Non-nullable field 'name' (type String!) ` + 242 `was not present in result from Dgraph. GraphQL error propagation triggered.`, 243 Locations: []x.Location{{Column: 6, Line: 7}}, 244 Path: []interface{}{"addPost", "post", 0, "author", "name"}}}, 245 }, 246 } 247 248 gqlSchema := test.LoadSchemaFromString(t, testGQLSchema) 249 250 for name, tcase := range tests { 251 t.Run(name, func(t *testing.T) { 252 resp := resolveWithClient(gqlSchema, mutation, nil, 253 &executor{ 254 resp: tcase.queryResponse, 255 assigned: tcase.mutResponse, 256 result: tcase.mutQryResp, 257 }) 258 259 test.RequireJSONEq(t, tcase.errors, resp.Errors) 260 require.JSONEq(t, tcase.expected, resp.Data.String(), tcase.explanation) 261 }) 262 } 263 } 264 265 func TestUpdateMutationUsesErrorPropagation(t *testing.T) { 266 mutation := `mutation { 267 updatePost(input: { filter: { id: ["0x1"] }, set: { text: "Some more text" } }) { 268 post { 269 title 270 text 271 author { 272 name 273 dob 274 } 275 } 276 } 277 }` 278 279 // There's no need to have mocks for the mutation part here because with nil results all the 280 // rewriting and rewriting from results will silently succeed. All we care about the is the 281 // result from the query that follows the mutation. In that add case we have to satisfy 282 // the type checking, but that's not required here. 283 284 tests := map[string]struct { 285 explanation string 286 mutResponse map[string]string 287 queryResponse string 288 expected string 289 errors x.GqlErrorList 290 }{ 291 "Update Mutation adds missing nullable fields": { 292 explanation: "Field 'dob' is nullable, so null should be inserted " + 293 "if the mutation's query doesn't return a value.", 294 queryResponse: `{ "post" : [ 295 { "title": "A Post", 296 "text": "Some text", 297 "author": { "name": "A.N. Author" } } ] }`, 298 expected: `{ "updatePost": { "post" : 299 [{ "title": "A Post", 300 "text": "Some text", 301 "author": { "name": "A.N. Author", "dob": null } }] } }`, 302 }, 303 "Update Mutation triggers GraphQL error propagation": { 304 explanation: "An Author's name is non-nullable, so if that's missing, " + 305 "the author is squashed to null, but that's also non-nullable, so the error " + 306 "propagates to the query root.", 307 queryResponse: `{ "post" : [ { 308 "title": "A Post", 309 "text": "Some text", 310 "author": { "dob": "2000-01-01" } } ] }`, 311 expected: `{ "updatePost": { "post" : [null] } }`, 312 errors: x.GqlErrorList{&x.GqlError{ 313 Message: `Non-nullable field 'name' (type String!) ` + 314 `was not present in result from Dgraph. GraphQL error propagation triggered.`, 315 Locations: []x.Location{{Column: 6, Line: 7}}, 316 Path: []interface{}{"updatePost", "post", 0, "author", "name"}}}, 317 }, 318 } 319 320 gqlSchema := test.LoadSchemaFromString(t, testGQLSchema) 321 322 for name, tcase := range tests { 323 t.Run(name, func(t *testing.T) { 324 resp := resolveWithClient(gqlSchema, mutation, nil, 325 &executor{resp: tcase.queryResponse, assigned: tcase.mutResponse}) 326 327 test.RequireJSONEq(t, tcase.errors, resp.Errors) 328 require.JSONEq(t, tcase.expected, resp.Data.String(), tcase.explanation) 329 }) 330 } 331 } 332 333 // TestManyMutationsWithError : Multiple mutations run serially (queries would 334 // run in parallel) and, in GraphQL, if an error is encountered in a request with 335 // multiple mutations, the mutations following the error are not run. The mutations 336 // that have succeeded are permanent - i.e. not rolled back. 337 // 338 // There's no real way to test this E2E against a live instance because the only 339 // real fails during a mutation are either failure to communicate with Dgraph, or 340 // a bug that causes a query rewriting that Dgraph rejects. There are some other 341 // cases: e.g. a delete that doesn't end up deleting anything (but we interpret 342 // that as not an error, it just deleted 0 things), and a mutation with some error 343 // in the input data/query (but that gets caught by validation before any mutations 344 // are executed). 345 // 346 // So this mocks a failing mutation and tests that we behave correctly in the case 347 // of multiple mutations. 348 func TestManyMutationsWithError(t *testing.T) { 349 350 // add1 - should succeed 351 // add2 - should fail 352 // add3 - is never executed 353 multiMutation := `mutation multipleMutations($id: ID!) { 354 add1: addPost(input: [{title: "A Post", text: "Some text", author: {id: "0x1"}}]) { 355 post { title } 356 } 357 358 add2: addPost(input: [{title: "A Post", text: "Some text", author: {id: $id}}]) { 359 post { title } 360 } 361 362 add3: addPost(input: [{title: "A Post", text: "Some text", author: {id: "0x1"}}]) { 363 post { title } 364 } 365 }` 366 367 tests := map[string]struct { 368 explanation string 369 idValue string 370 mutResponse map[string]string 371 mutQryResp map[string]interface{} 372 queryResponse string 373 expected string 374 errors x.GqlErrorList 375 }{ 376 "Dgraph fail": { 377 explanation: "a Dgraph, network or error in rewritten query failed the mutation", 378 idValue: "0x1", 379 mutResponse: map[string]string{"Post1": "0x2"}, 380 mutQryResp: map[string]interface{}{ 381 "Author2": []interface{}{map[string]string{"uid": "0x1"}}}, 382 queryResponse: `{ "post" : [{ "title": "A Post" } ] }`, 383 expected: `{ 384 "add1": { "post": [{ "title": "A Post" }] }, 385 "add2" : null 386 }`, 387 errors: x.GqlErrorList{ 388 &x.GqlError{Message: `mutation addPost failed because ` + 389 `Dgraph mutation failed because _bad stuff happend_`, 390 Locations: []x.Location{{Line: 6, Column: 4}}}, 391 &x.GqlError{Message: `Mutation add3 was not executed because of ` + 392 `a previous error.`, 393 Locations: []x.Location{{Line: 10, Column: 4}}}}, 394 }, 395 "Rewriting error": { 396 explanation: "The reference ID is not a uint64, so can't be converted to a uid", 397 idValue: "hi", 398 mutResponse: map[string]string{"Post1": "0x2"}, 399 mutQryResp: map[string]interface{}{ 400 "Author2": []interface{}{map[string]string{"uid": "0x1"}}}, 401 queryResponse: `{ "post" : [{ "title": "A Post" } ] }`, 402 expected: `{ 403 "add1": { "post": [{ "title": "A Post" }] }, 404 "add2" : null 405 }`, 406 errors: x.GqlErrorList{ 407 &x.GqlError{Message: `couldn't rewrite mutation addPost because ` + 408 `failed to rewrite mutation payload because ` + 409 `ID argument (hi) was not able to be parsed`}, 410 &x.GqlError{Message: `Mutation add3 was not executed because of ` + 411 `a previous error.`, 412 Locations: []x.Location{{Line: 10, Column: 4}}}}, 413 }, 414 } 415 416 gqlSchema := test.LoadSchemaFromString(t, testGQLSchema) 417 418 for name, tcase := range tests { 419 t.Run(name, func(t *testing.T) { 420 421 resp := resolveWithClient( 422 gqlSchema, 423 multiMutation, 424 map[string]interface{}{"id": tcase.idValue}, 425 &executor{ 426 resp: tcase.queryResponse, 427 assigned: tcase.mutResponse, 428 failMutation: 2}) 429 430 if diff := cmp.Diff(tcase.errors, resp.Errors); diff != "" { 431 t.Errorf("errors mismatch (-want +got):\n%s", diff) 432 } 433 require.JSONEq(t, tcase.expected, resp.Data.String()) 434 }) 435 } 436 } 437 438 func resolve(gqlSchema schema.Schema, gqlQuery string, dgResponse string) *schema.Response { 439 return resolveWithClient(gqlSchema, gqlQuery, nil, &executor{resp: dgResponse}) 440 } 441 442 func resolveWithClient( 443 gqlSchema schema.Schema, 444 gqlQuery string, 445 vars map[string]interface{}, 446 ex *executor) *schema.Response { 447 resolver := New( 448 gqlSchema, 449 NewResolverFactory(nil, nil).WithConventionResolvers(gqlSchema, &ResolverFns{ 450 Qrw: NewQueryRewriter(), 451 Arw: NewAddRewriter, 452 Urw: NewUpdateRewriter, 453 Qe: ex, 454 Me: ex, 455 })) 456 457 return resolver.Resolve(context.Background(), &schema.Request{Query: gqlQuery, Variables: vars}) 458 }