1import { describe, it, expect } from 'vitest';
2import { OperationResult } from '../types';
3import { queryOperation, subscriptionOperation } from '../test-utils';
4import { makeResult, mergeResultPatch } from './result';
5import { GraphQLError } from '@0no-co/graphql.web';
6import { CombinedError } from './error';
7
8describe('makeResult', () => {
9 it('adds extensions and errors correctly', () => {
10 const origResult = {
11 data: undefined,
12 errors: ['error message'],
13 extensions: {
14 extensionKey: 'extensionValue',
15 },
16 };
17
18 const result = makeResult(queryOperation, origResult);
19
20 expect(result.hasNext).toBe(false);
21 expect(result.operation).toBe(queryOperation);
22 expect(result.data).toBe(undefined);
23 expect(result.extensions).toEqual(origResult.extensions);
24 expect(result.error).toMatchInlineSnapshot(
25 `[CombinedError: [GraphQL] error message]`
26 );
27 });
28
29 it('default hasNext to true for subscriptions', () => {
30 const origResult = {
31 data: undefined,
32 errors: ['error message'],
33 extensions: {
34 extensionKey: 'extensionValue',
35 },
36 };
37
38 const result = makeResult(subscriptionOperation, origResult);
39 expect(result.hasNext).toBe(true);
40 });
41});
42
43describe('mergeResultPatch (defer/stream latest', () => {
44 it('should read pending and append the result', () => {
45 const pending = [{ id: '0', path: [] }];
46 const prevResult: OperationResult = {
47 operation: queryOperation,
48 stale: false,
49 hasNext: true,
50 data: {
51 f2: {
52 a: 'a',
53 b: 'b',
54 c: {
55 d: 'd',
56 e: 'e',
57 f: { h: 'h', i: 'i' },
58 },
59 },
60 },
61 };
62
63 const merged = mergeResultPatch(
64 prevResult,
65 {
66 incremental: [
67 { id: '0', data: { MyFragment: 'Query' } },
68 { id: '0', subPath: ['f2', 'c', 'f'], data: { j: 'j' } },
69 ],
70 // TODO: not sure if we need this but it's part of the spec
71 // completed: [{ id: '0' }],
72 hasNext: false,
73 },
74 undefined,
75 pending
76 );
77
78 expect(merged.data).toEqual({
79 MyFragment: 'Query',
80 f2: {
81 a: 'a',
82 b: 'b',
83 c: {
84 d: 'd',
85 e: 'e',
86 f: { h: 'h', i: 'i', j: 'j' },
87 },
88 },
89 });
90 });
91
92 it('should read pending and append the result w/ overlapping fields', () => {
93 const pending = [
94 { id: '0', path: [], label: 'D1' },
95 { id: '1', path: ['f2', 'c', 'f'], label: 'D2' },
96 ];
97 const prevResult: OperationResult = {
98 operation: queryOperation,
99 stale: false,
100 hasNext: true,
101 data: {
102 f2: {
103 a: 'A',
104 b: 'B',
105 c: {
106 d: 'D',
107 e: 'E',
108 f: {
109 h: 'H',
110 i: 'I',
111 },
112 },
113 },
114 },
115 };
116
117 const merged = mergeResultPatch(
118 prevResult,
119 {
120 incremental: [
121 { id: '0', subPath: ['f2', 'c', 'f'], data: { j: 'J', k: 'K' } },
122 ],
123 pending: [{ id: '1', path: ['f2', 'c', 'f'], label: 'D2' }],
124 hasNext: true,
125 },
126 undefined,
127 pending
128 );
129
130 const merged2 = mergeResultPatch(
131 merged,
132 {
133 incremental: [{ id: '1', data: { l: 'L', m: 'M' } }],
134 hasNext: false,
135 },
136 undefined,
137 pending
138 );
139
140 expect(merged2.data).toEqual({
141 f2: {
142 a: 'A',
143 b: 'B',
144 c: {
145 d: 'D',
146 e: 'E',
147 f: {
148 h: 'H',
149 i: 'I',
150 j: 'J',
151 k: 'K',
152 l: 'L',
153 m: 'M',
154 },
155 },
156 },
157 });
158 });
159});
160
161describe('mergeResultPatch (defer/stream pre June-2023)', () => {
162 it('should default hasNext to true if the last result was set to true', () => {
163 const prevResult: OperationResult = {
164 operation: subscriptionOperation,
165 data: {
166 __typename: 'Subscription',
167 event: 1,
168 },
169 stale: false,
170 hasNext: true,
171 };
172
173 const merged = mergeResultPatch(prevResult, {
174 data: {
175 __typename: 'Subscription',
176 event: 2,
177 },
178 });
179
180 expect(merged.data).not.toBe(prevResult.data);
181 expect(merged.data.event).toBe(2);
182 expect(merged.hasNext).toBe(true);
183 });
184
185 it('should work with the payload property', () => {
186 const prevResult: OperationResult = {
187 operation: subscriptionOperation,
188 data: {
189 __typename: 'Subscription',
190 event: 1,
191 },
192 stale: false,
193 hasNext: true,
194 };
195
196 const merged = mergeResultPatch(prevResult, {
197 payload: {
198 data: {
199 __typename: 'Subscription',
200 event: 2,
201 },
202 },
203 });
204
205 expect(merged.data).not.toBe(prevResult.data);
206 expect(merged.data.event).toBe(2);
207 expect(merged.hasNext).toBe(true);
208 });
209
210 it('should work with the payload property and errors', () => {
211 const prevResult: OperationResult = {
212 operation: subscriptionOperation,
213 data: {
214 __typename: 'Subscription',
215 event: 1,
216 },
217 stale: false,
218 hasNext: true,
219 };
220
221 const merged = mergeResultPatch(prevResult, {
222 payload: {
223 data: {
224 __typename: 'Subscription',
225 event: 2,
226 },
227 },
228 errors: [new GraphQLError('Something went horribly wrong')],
229 });
230
231 expect(merged.data).not.toBe(prevResult.data);
232 expect(merged.data.event).toBe(2);
233 expect(merged.error).toEqual(
234 new CombinedError({
235 graphQLErrors: [new GraphQLError('Something went horribly wrong')],
236 })
237 );
238 expect(merged.hasNext).toBe(true);
239 });
240
241 it('should ignore invalid patches', () => {
242 const prevResult: OperationResult = {
243 operation: queryOperation,
244 data: {
245 __typename: 'Query',
246 items: [
247 {
248 __typename: 'Item',
249 id: 'id',
250 },
251 ],
252 },
253 stale: false,
254 hasNext: true,
255 };
256
257 const merged = mergeResultPatch(prevResult, {
258 incremental: [
259 {
260 data: undefined,
261 path: ['a'],
262 },
263 {
264 items: null,
265 path: ['b'],
266 },
267 ],
268 });
269
270 expect(merged.data).toStrictEqual({
271 __typename: 'Query',
272 items: [
273 {
274 __typename: 'Item',
275 id: 'id',
276 },
277 ],
278 });
279 });
280
281 it('should apply incremental defer patches', () => {
282 const prevResult: OperationResult = {
283 operation: queryOperation,
284 data: {
285 __typename: 'Query',
286 items: [
287 {
288 __typename: 'Item',
289 id: 'id',
290 child: undefined,
291 },
292 ],
293 },
294 stale: false,
295 hasNext: true,
296 };
297
298 const patch = { __typename: 'Child' };
299
300 const merged = mergeResultPatch(prevResult, {
301 incremental: [
302 {
303 data: patch,
304 path: ['items', 0, 'child'],
305 },
306 ],
307 });
308
309 expect(merged.data.items[0]).not.toBe(prevResult.data.items[0]);
310 expect(merged.data.items[0].child).toBe(patch);
311 expect(merged.data).toStrictEqual({
312 __typename: 'Query',
313 items: [
314 {
315 __typename: 'Item',
316 id: 'id',
317 child: patch,
318 },
319 ],
320 });
321 });
322
323 it('should handle null incremental defer patches', () => {
324 const prevResult: OperationResult = {
325 operation: queryOperation,
326 data: {
327 __typename: 'Query',
328 item: undefined,
329 },
330 stale: false,
331 hasNext: true,
332 };
333
334 const merged = mergeResultPatch(prevResult, {
335 incremental: [
336 {
337 data: null,
338 path: ['item'],
339 },
340 ],
341 });
342
343 expect(merged.data).not.toBe(prevResult.data);
344 expect(merged.data.item).toBe(null);
345 });
346
347 it('should apply incremental stream patches', () => {
348 const prevResult: OperationResult = {
349 operation: queryOperation,
350 data: {
351 __typename: 'Query',
352 items: [{ __typename: 'Item' }],
353 },
354 stale: false,
355 hasNext: true,
356 };
357
358 const patch = { __typename: 'Item' };
359
360 const merged = mergeResultPatch(prevResult, {
361 incremental: [
362 {
363 items: [patch],
364 path: ['items', 1],
365 },
366 ],
367 });
368
369 expect(merged.data.items).not.toBe(prevResult.data.items);
370 expect(merged.data.items[0]).toBe(prevResult.data.items[0]);
371 expect(merged.data.items[1]).toBe(patch);
372 expect(merged.data).toStrictEqual({
373 __typename: 'Query',
374 items: [{ __typename: 'Item' }, { __typename: 'Item' }],
375 });
376 });
377
378 it('should apply incremental stream patches deeply', () => {
379 const prevResult: OperationResult = {
380 operation: queryOperation,
381 data: {
382 __typename: 'Query',
383 test: [
384 {
385 __typename: 'Test',
386 },
387 ],
388 },
389 stale: false,
390 hasNext: true,
391 };
392
393 const patch = { name: 'Test' };
394
395 const merged = mergeResultPatch(prevResult, {
396 incremental: [
397 {
398 items: [patch],
399 path: ['test', 0],
400 },
401 ],
402 });
403
404 expect(merged.data).toStrictEqual({
405 __typename: 'Query',
406 test: [
407 {
408 __typename: 'Test',
409 name: 'Test',
410 },
411 ],
412 });
413 });
414
415 it('should handle null incremental stream patches', () => {
416 const prevResult: OperationResult = {
417 operation: queryOperation,
418 data: {
419 __typename: 'Query',
420 items: [{ __typename: 'Item' }],
421 },
422 stale: false,
423 hasNext: true,
424 };
425
426 const merged = mergeResultPatch(prevResult, {
427 incremental: [
428 {
429 items: null,
430 path: ['items', 1],
431 },
432 ],
433 });
434
435 expect(merged.data.items).not.toBe(prevResult.data.items);
436 expect(merged.data.items[0]).toBe(prevResult.data.items[0]);
437 expect(merged.data).toStrictEqual({
438 __typename: 'Query',
439 items: [{ __typename: 'Item' }],
440 });
441 });
442
443 it('should handle root incremental stream patches', () => {
444 const prevResult: OperationResult = {
445 operation: queryOperation,
446 data: {
447 __typename: 'Query',
448 item: {
449 test: true,
450 },
451 },
452 stale: false,
453 hasNext: true,
454 };
455
456 const merged = mergeResultPatch(prevResult, {
457 incremental: [
458 {
459 data: { item: { test2: false } },
460 path: [],
461 },
462 ],
463 });
464
465 expect(merged.data).toStrictEqual({
466 __typename: 'Query',
467 item: {
468 test: true,
469 test2: false,
470 },
471 });
472 });
473
474 it('should merge extensions from each patch', () => {
475 const prevResult: OperationResult = {
476 operation: queryOperation,
477 data: {
478 __typename: 'Query',
479 },
480 extensions: {
481 base: true,
482 },
483 stale: false,
484 hasNext: true,
485 };
486
487 const merged = mergeResultPatch(prevResult, {
488 incremental: [
489 {
490 data: null,
491 path: ['item'],
492 extensions: {
493 patch: true,
494 },
495 },
496 ],
497 });
498
499 expect(merged.extensions).toStrictEqual({
500 base: true,
501 patch: true,
502 });
503 });
504
505 it('should combine errors from each patch', () => {
506 const prevResult: OperationResult = makeResult(queryOperation, {
507 errors: ['base'],
508 });
509
510 const merged = mergeResultPatch(prevResult, {
511 incremental: [
512 {
513 data: null,
514 path: ['item'],
515 errors: ['patch'],
516 },
517 ],
518 });
519
520 expect(merged.error).toMatchInlineSnapshot(`
521 [CombinedError: [GraphQL] base
522 [GraphQL] patch]
523 `);
524 });
525
526 it('should preserve all data for noop patches', () => {
527 const prevResult: OperationResult = {
528 operation: queryOperation,
529 data: {
530 __typename: 'Query',
531 },
532 extensions: {
533 base: true,
534 },
535 stale: false,
536 hasNext: true,
537 };
538
539 const merged = mergeResultPatch(prevResult, {
540 hasNext: false,
541 });
542
543 expect(merged.data).toStrictEqual({
544 __typename: 'Query',
545 });
546 });
547
548 it('handles the old version of the incremental payload spec (DEPRECATED)', () => {
549 const prevResult: OperationResult = {
550 operation: queryOperation,
551 data: {
552 __typename: 'Query',
553 items: [
554 {
555 __typename: 'Item',
556 id: 'id',
557 child: undefined,
558 },
559 ],
560 },
561 stale: false,
562 hasNext: true,
563 };
564
565 const patch = { __typename: 'Child' };
566
567 const merged = mergeResultPatch(prevResult, {
568 data: patch,
569 path: ['items', 0, 'child'],
570 } as any);
571
572 expect(merged.data.items[0]).not.toBe(prevResult.data.items[0]);
573 expect(merged.data.items[0].child).toBe(patch);
574 expect(merged.data).toStrictEqual({
575 __typename: 'Query',
576 items: [
577 {
578 __typename: 'Item',
579 id: 'id',
580 child: patch,
581 },
582 ],
583 });
584 });
585});