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});