Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
at main 14 kB view raw
1import { pipe, scan, subscribe, toPromise } from 'wonka'; 2import { 3 vi, 4 expect, 5 it, 6 beforeEach, 7 describe, 8 beforeAll, 9 Mock, 10 afterAll, 11} from 'vitest'; 12 13import { queryOperation, context } from '../test-utils'; 14import { makeFetchSource } from './fetchSource'; 15import { gql } from '../gql'; 16import { OperationResult, Operation } from '../types'; 17import { makeOperation } from '../utils'; 18 19const fetch = (globalThis as any).fetch as Mock; 20const abort = vi.fn(); 21 22beforeAll(() => { 23 (globalThis as any).AbortController = function AbortController() { 24 this.signal = undefined; 25 this.abort = abort; 26 }; 27}); 28 29beforeEach(() => { 30 fetch.mockClear(); 31 abort.mockClear(); 32}); 33 34afterAll(() => { 35 (globalThis as any).AbortController = undefined; 36}); 37 38const response = JSON.stringify({ 39 status: 200, 40 data: { 41 data: { 42 user: 1200, 43 }, 44 }, 45}); 46 47describe('on success', () => { 48 beforeEach(() => { 49 fetch.mockResolvedValue({ 50 status: 200, 51 headers: { get: () => 'application/json' }, 52 text: vi.fn().mockResolvedValue(response), 53 }); 54 }); 55 56 it('returns response data', async () => { 57 const fetchOptions = {}; 58 const data = await pipe( 59 makeFetchSource(queryOperation, 'https://test.com/graphql', fetchOptions), 60 toPromise 61 ); 62 63 expect(data).toMatchSnapshot(); 64 65 expect(fetch).toHaveBeenCalled(); 66 expect(fetch.mock.calls[0][0]).toBe('https://test.com/graphql'); 67 expect(fetch.mock.calls[0][1]).toBe(fetchOptions); 68 }); 69 70 it('uses the mock fetch if given', async () => { 71 const fetchOptions = {}; 72 const fetcher = vi.fn().mockResolvedValue({ 73 status: 200, 74 headers: { get: () => 'application/json' }, 75 text: vi.fn().mockResolvedValue(response), 76 }); 77 78 const data = await pipe( 79 makeFetchSource( 80 { 81 ...queryOperation, 82 context: { 83 ...queryOperation.context, 84 fetch: fetcher, 85 }, 86 }, 87 'https://test.com/graphql', 88 fetchOptions 89 ), 90 toPromise 91 ); 92 93 expect(data).toMatchSnapshot(); 94 expect(fetch).not.toHaveBeenCalled(); 95 expect(fetcher).toHaveBeenCalled(); 96 }); 97}); 98 99describe('on error', () => { 100 beforeEach(() => { 101 fetch.mockResolvedValue({ 102 status: 400, 103 statusText: 'Forbidden', 104 headers: { get: () => 'application/json' }, 105 text: vi.fn().mockResolvedValue('{}'), 106 }); 107 }); 108 109 it('handles network errors', async () => { 110 const error = new Error('test'); 111 fetch.mockRejectedValue(error); 112 113 const fetchOptions = {}; 114 const data = await pipe( 115 makeFetchSource(queryOperation, 'https://test.com/graphql', fetchOptions), 116 toPromise 117 ); 118 119 expect(data).toHaveProperty('error.networkError', error); 120 }); 121 122 it('returns error data', async () => { 123 const fetchOptions = {}; 124 const data = await pipe( 125 makeFetchSource(queryOperation, 'https://test.com/graphql', fetchOptions), 126 toPromise 127 ); 128 129 expect(data).toMatchSnapshot(); 130 }); 131 132 it('returns error data with status 400 and manual redirect mode', async () => { 133 const data = await pipe( 134 makeFetchSource(queryOperation, 'https://test.com/graphql', { 135 redirect: 'manual', 136 }), 137 toPromise 138 ); 139 140 expect(data).toMatchSnapshot(); 141 }); 142 143 it('ignores the error when a result is available', async () => { 144 const data = await pipe( 145 makeFetchSource(queryOperation, 'https://test.com/graphql', {}), 146 toPromise 147 ); 148 149 expect(data).toMatchSnapshot(); 150 }); 151}); 152 153describe('on unexpected plain text responses', () => { 154 beforeEach(() => { 155 fetch.mockResolvedValue({ 156 status: 200, 157 headers: new Map([['Content-Type', 'text/plain']]), 158 text: vi.fn().mockResolvedValue('Some Error Message'), 159 }); 160 }); 161 162 it('returns error data', async () => { 163 const fetchOptions = {}; 164 const result = await pipe( 165 makeFetchSource(queryOperation, 'https://test.com/graphql', fetchOptions), 166 toPromise 167 ); 168 169 expect(result.error).toMatchObject({ 170 message: '[Network] Some Error Message', 171 }); 172 }); 173}); 174 175describe('on error with non spec-compliant body', () => { 176 beforeEach(() => { 177 fetch.mockResolvedValue({ 178 status: 400, 179 statusText: 'Forbidden', 180 headers: { get: () => 'application/json' }, 181 text: vi.fn().mockResolvedValue('{"errors":{"detail":"Bad Request"}}'), 182 }); 183 }); 184 185 it('handles network errors', async () => { 186 const data = await pipe( 187 makeFetchSource(queryOperation, 'https://test.com/graphql', {}), 188 toPromise 189 ); 190 191 expect(data).toMatchSnapshot(); 192 expect(data).toHaveProperty('error.networkError.message', 'Forbidden'); 193 }); 194}); 195 196describe('on teardown', () => { 197 const fail = () => { 198 expect(true).toEqual(false); 199 }; 200 201 it('does not start the outgoing request on immediate teardowns', async () => { 202 fetch.mockImplementation(async () => { 203 await new Promise(() => { 204 /*noop*/ 205 }); 206 }); 207 208 const { unsubscribe } = pipe( 209 makeFetchSource(queryOperation, 'https://test.com/graphql', {}), 210 subscribe(fail) 211 ); 212 213 unsubscribe(); 214 215 // NOTE: We can only observe the async iterator's final run after a macro tick 216 217 await new Promise(resolve => setTimeout(resolve)); 218 expect(fetch).toHaveBeenCalledTimes(0); 219 expect(abort).toHaveBeenCalledTimes(1); 220 }); 221 222 it('aborts the outgoing request', async () => { 223 fetch.mockResolvedValue({ 224 status: 200, 225 headers: new Map([['Content-Type', 'application/json']]), 226 text: vi.fn().mockResolvedValue('{ "data": null }'), 227 }); 228 229 const { unsubscribe } = pipe( 230 makeFetchSource(queryOperation, 'https://test.com/graphql', {}), 231 subscribe(() => { 232 /*noop*/ 233 }) 234 ); 235 236 await new Promise(resolve => setTimeout(resolve)); 237 unsubscribe(); 238 239 // NOTE: We can only observe the async iterator's final run after a macro tick 240 await new Promise(resolve => setTimeout(resolve)); 241 expect(fetch).toHaveBeenCalledTimes(1); 242 expect(abort).toHaveBeenCalledTimes(1); 243 }); 244}); 245 246describe('on multipart/mixed', () => { 247 const wrap = (json: object) => 248 '\r\n' + 249 'Content-Type: application/json; charset=utf-8\r\n\r\n' + 250 JSON.stringify(json) + 251 '\r\n---'; 252 253 it('listens for more streamed responses', async () => { 254 fetch.mockResolvedValue({ 255 status: 200, 256 headers: { 257 get() { 258 return 'multipart/mixed'; 259 }, 260 }, 261 body: { 262 getReader: function () { 263 let cancelled = false; 264 const results = [ 265 { 266 done: false, 267 value: Buffer.from('\r\n---'), 268 }, 269 { 270 done: false, 271 value: Buffer.from( 272 wrap({ 273 hasNext: true, 274 data: { 275 author: { 276 id: '1', 277 __typename: 'Author', 278 }, 279 }, 280 }) 281 ), 282 }, 283 { 284 done: false, 285 value: Buffer.from( 286 wrap({ 287 incremental: [ 288 { 289 path: ['author'], 290 data: { name: 'Steve' }, 291 }, 292 ], 293 hasNext: true, 294 }) 295 ), 296 }, 297 { 298 done: false, 299 value: Buffer.from(wrap({ hasNext: false }) + '--'), 300 }, 301 { done: true }, 302 ]; 303 let count = 0; 304 return { 305 cancel: function () { 306 cancelled = true; 307 }, 308 read: function () { 309 if (cancelled) throw new Error('No'); 310 311 return Promise.resolve(results[count++]); 312 }, 313 }; 314 }, 315 }, 316 }); 317 318 const AuthorFragment = gql` 319 fragment authorFields on Author { 320 name 321 } 322 `; 323 324 const streamedQueryOperation: Operation = makeOperation( 325 'query', 326 { 327 query: gql` 328 query { 329 author { 330 id 331 ...authorFields @defer 332 } 333 } 334 335 ${AuthorFragment} 336 `, 337 variables: {}, 338 key: 1, 339 }, 340 context 341 ); 342 343 const chunks: OperationResult[] = await pipe( 344 makeFetchSource(streamedQueryOperation, 'https://test.com/graphql', {}), 345 scan((prev: OperationResult[], item) => [...prev, item], []), 346 toPromise 347 ); 348 349 expect(chunks.length).toEqual(3); 350 351 expect(chunks[0].data).toEqual({ 352 author: { 353 id: '1', 354 __typename: 'Author', 355 }, 356 }); 357 358 expect(chunks[1].data).toEqual({ 359 author: { 360 id: '1', 361 name: 'Steve', 362 __typename: 'Author', 363 }, 364 }); 365 366 expect(chunks[2].data).toEqual({ 367 author: { 368 id: '1', 369 name: 'Steve', 370 __typename: 'Author', 371 }, 372 }); 373 }); 374}); 375 376describe('on text/event-stream', () => { 377 const wrap = (json: object) => 'data: ' + JSON.stringify(json) + '\n\n'; 378 379 it('listens for streamed responses', async () => { 380 fetch.mockResolvedValue({ 381 status: 200, 382 headers: { 383 get() { 384 return 'text/event-stream'; 385 }, 386 }, 387 body: { 388 getReader: function () { 389 let cancelled = false; 390 const results = [ 391 { 392 done: false, 393 value: Buffer.from( 394 wrap({ 395 hasNext: true, 396 data: { 397 author: { 398 id: '1', 399 __typename: 'Author', 400 }, 401 }, 402 }) 403 ), 404 }, 405 { 406 done: false, 407 value: Buffer.from( 408 wrap({ 409 incremental: [ 410 { 411 path: ['author'], 412 data: { name: 'Steve' }, 413 }, 414 ], 415 hasNext: true, 416 }) 417 ), 418 }, 419 { 420 done: false, 421 value: Buffer.from(wrap({ hasNext: false })), 422 }, 423 { done: true }, 424 ]; 425 let count = 0; 426 return { 427 cancel: function () { 428 cancelled = true; 429 }, 430 read: function () { 431 if (cancelled) throw new Error('No'); 432 433 return Promise.resolve(results[count++]); 434 }, 435 }; 436 }, 437 }, 438 }); 439 440 const AuthorFragment = gql` 441 fragment authorFields on Author { 442 name 443 } 444 `; 445 446 const streamedQueryOperation: Operation = makeOperation( 447 'query', 448 { 449 query: gql` 450 query { 451 author { 452 id 453 ...authorFields @defer 454 } 455 } 456 457 ${AuthorFragment} 458 `, 459 variables: {}, 460 key: 1, 461 }, 462 context 463 ); 464 465 const chunks: OperationResult[] = await pipe( 466 makeFetchSource(streamedQueryOperation, 'https://test.com/graphql', {}), 467 scan((prev: OperationResult[], item) => [...prev, item], []), 468 toPromise 469 ); 470 471 expect(chunks.length).toEqual(3); 472 473 expect(chunks[0].data).toEqual({ 474 author: { 475 id: '1', 476 __typename: 'Author', 477 }, 478 }); 479 480 expect(chunks[1].data).toEqual({ 481 author: { 482 id: '1', 483 name: 'Steve', 484 __typename: 'Author', 485 }, 486 }); 487 488 expect(chunks[2].data).toEqual({ 489 author: { 490 id: '1', 491 name: 'Steve', 492 __typename: 'Author', 493 }, 494 }); 495 }); 496 497 it('merges deferred results on the root-type', async () => { 498 fetch.mockResolvedValue({ 499 status: 200, 500 headers: { 501 get() { 502 return 'text/event-stream'; 503 }, 504 }, 505 body: { 506 getReader: function () { 507 let cancelled = false; 508 const results = [ 509 { 510 done: false, 511 value: Buffer.from( 512 wrap({ 513 hasNext: true, 514 data: { 515 author: { 516 id: '1', 517 __typename: 'Author', 518 }, 519 }, 520 }) 521 ), 522 }, 523 { 524 done: false, 525 value: Buffer.from( 526 wrap({ 527 incremental: [ 528 { 529 path: [], 530 data: { author: { name: 'Steve' } }, 531 }, 532 ], 533 hasNext: true, 534 }) 535 ), 536 }, 537 { 538 done: false, 539 value: Buffer.from(wrap({ hasNext: false })), 540 }, 541 { done: true }, 542 ]; 543 let count = 0; 544 return { 545 cancel: function () { 546 cancelled = true; 547 }, 548 read: function () { 549 if (cancelled) throw new Error('No'); 550 551 return Promise.resolve(results[count++]); 552 }, 553 }; 554 }, 555 }, 556 }); 557 558 const AuthorFragment = gql` 559 fragment authorFields on Query { 560 author { 561 name 562 } 563 } 564 `; 565 566 const streamedQueryOperation: Operation = makeOperation( 567 'query', 568 { 569 query: gql` 570 query { 571 author { 572 id 573 ...authorFields @defer 574 } 575 } 576 577 ${AuthorFragment} 578 `, 579 variables: {}, 580 key: 1, 581 }, 582 context 583 ); 584 585 const chunks: OperationResult[] = await pipe( 586 makeFetchSource(streamedQueryOperation, 'https://test.com/graphql', {}), 587 scan((prev: OperationResult[], item) => [...prev, item], []), 588 toPromise 589 ); 590 591 expect(chunks.length).toEqual(3); 592 593 expect(chunks[0].data).toEqual({ 594 author: { 595 id: '1', 596 __typename: 'Author', 597 }, 598 }); 599 600 expect(chunks[1].data).toEqual({ 601 author: { 602 id: '1', 603 name: 'Steve', 604 __typename: 'Author', 605 }, 606 }); 607 608 expect(chunks[2].data).toEqual({ 609 author: { 610 id: '1', 611 name: 'Steve', 612 __typename: 'Author', 613 }, 614 }); 615 }); 616});