Repository: FormidableLabs/urql
Pull request #428 (2019-09-16 16:23:48)

Add smaller tests to up the coverage


Patch:
diff --git a/package.json b/package.json
index 6b871837..fd3876d0 100644
--- a/package.json
+++ b/package.json
@@ -86,7 +86,6 @@
     "@testing-library/react": "^9.1.3",
     "@testing-library/react-hooks": "^2.0.1",
     "@types/enzyme": "3.10.3",
-    "@types/fast-json-stable-stringify": "^2.0.0",
     "@types/jest": "^24.0.18",
     "@types/react": "^16.9.2",
     "@types/react-test-renderer": "^16.9.0",
@@ -134,7 +133,6 @@
     "react-dom": ">= 16.8.0"
   },
   "dependencies": {
-    "fast-json-stable-stringify": "^2.0.0",
     "wonka": "^3.2.1"
   }
 }
diff --git a/src/exchanges/cache.ts b/src/exchanges/cache.ts
index a5279c81..72337357 100755
--- a/src/exchanges/cache.ts
+++ b/src/exchanges/cache.ts
@@ -61,20 +61,11 @@ export const cacheExchange: Exchange = ({ forward, client }) => {
           reexecuteOperation(client, operation);
         }
 
-        if (cachedResult !== undefined) {
-          return {
-            ...cachedResult,
-            operation: addMetadata(cachedResult.operation, {
-              cacheOutcome: 'hit',
-            }),
-          };
-        }
-
         return {
-          data: undefined,
-          error: undefined,
-          extensions: undefined,
-          operation: addMetadata(operation, { cacheOutcome: 'miss' }),
+          ...cachedResult,
+          operation: addMetadata(operation, {
+            cacheOutcome: cachedResult ? 'hit' : 'miss',
+          }),
         };
       })
     );
diff --git a/src/index.ts b/src/index.ts
index fb48863b..d827ce4b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,6 +7,7 @@ export * from './types';
 
 export {
   CombinedError,
+  stringifyVariables,
   createRequest,
   makeResult,
   makeErrorResult,
diff --git a/src/utils/index.ts b/src/utils/index.ts
index e17b2d56..8c01fe95 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -3,6 +3,7 @@ export * from './request';
 export * from './result';
 export * from './typenames';
 export * from './toSuspenseSource';
+export * from './stringifyVariables';
 export * from './withPromise';
 
 export const noop = () => {
diff --git a/src/utils/request.ts b/src/utils/request.ts
index f5ab63c3..5d33de19 100644
--- a/src/utils/request.ts
+++ b/src/utils/request.ts
@@ -1,6 +1,6 @@
 import { DocumentNode, parse, print } from 'graphql';
 import { hash, phash } from './hash';
-import stringify from 'fast-json-stable-stringify';
+import { stringifyVariables } from './stringifyVariables';
 import { GraphQLRequest, Operation, OperationContext } from '../types';
 
 interface Documents {
@@ -33,7 +33,7 @@ export const createRequest = (
   (query as any)[keyProp] = key;
 
   return {
-    key: vars ? phash(key, stringify(vars)) >>> 0 : key,
+    key: vars ? phash(key, stringifyVariables(vars)) >>> 0 : key,
     query,
     variables: vars || {},
   };
diff --git a/src/utils/stringifyVariables.ts b/src/utils/stringifyVariables.ts
new file mode 100644
index 00000000..38c060b2
--- /dev/null
+++ b/src/utils/stringifyVariables.ts
@@ -0,0 +1,50 @@
+const seen = new Set();
+
+const stringify = (x: any): string => {
+  if (x === undefined) {
+    return '';
+  } else if (typeof x == 'number') {
+    return isFinite(x) ? '' + x : 'null';
+  } else if (typeof x !== 'object') {
+    return JSON.stringify(x);
+  } else if (x === null) {
+    return 'null';
+  }
+
+  let out = '';
+  if (Array.isArray(x)) {
+    out = '[';
+    for (let i = 0, l = x.length; i < l; i++) {
+      if (i > 0) out += ',';
+      const value = stringify(x[i]);
+      out += value.length > 0 ? value : 'null';
+    }
+
+    out += ']';
+    return out;
+  } else if (seen.has(x)) {
+    throw new TypeError('Converting circular structure to JSON');
+  }
+
+  const keys = Object.keys(x).sort();
+
+  seen.add(x);
+  out = '{';
+  for (let i = 0, l = keys.length; i < l; i++) {
+    const key = keys[i];
+    const value = stringify(x[key]);
+    if (value.length !== 0) {
+      if (out.length > 1) out += ',';
+      out += stringify(key) + ':' + value;
+    }
+  }
+
+  seen.delete(x);
+  out += '}';
+  return out;
+};
+
+export const stringifyVariables = (x: any): string => {
+  seen.clear();
+  return stringify(x);
+};
diff --git a/yarn.lock b/yarn.lock
index 807a433b..898e5ad2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -456,11 +456,6 @@
   resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
   integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==
 
-"@types/fast-json-stable-stringify@^2.0.0":
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/@types/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#40363bb847cb86b2c2e1599f1398d11e8329c921"
-  integrity sha512-mky/O83TXmGY39P1H9YbUpjV6l6voRYlufqfFCvel8l1phuy8HRjdWc1rrPuN53ITBJlbyMSV6z3niOySO5pgQ==
-
 "@types/glob@^7.1.1":
   version "7.1.1"
   resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575"


Test patch:
diff --git a/src/exchanges/cache.test.ts b/src/exchanges/cache.test.ts
index 09a44b07..de0c0b25 100755
--- a/src/exchanges/cache.test.ts
+++ b/src/exchanges/cache.test.ts
@@ -75,6 +75,31 @@ describe('on query', () => {
     expect(reexecuteOperation).not.toBeCalled();
   });
 
+  it('respects cache-and-network', () => {
+    const [ops$, next, complete] = input;
+    const exchange = cacheExchange(exchangeArgs)(ops$);
+
+    publish(exchange);
+    next(queryOperation);
+
+    next({
+      ...queryOperation,
+      context: {
+        ...queryOperation.context,
+        requestPolicy: 'cache-and-network',
+      },
+    });
+
+    complete();
+    expect(forwardedOperations.length).toBe(1);
+    expect(reexecuteOperation).toHaveBeenCalledTimes(1);
+
+    expect(reexecuteOperation.mock.calls[0][0]).toEqual({
+      ...queryOperation,
+      context: { ...queryOperation.context, requestPolicy: 'network-only' },
+    });
+  });
+
   describe('cache hit', () => {
     it('is miss when operation is forwarded', () => {
       const [ops$, next, complete] = input;
diff --git a/src/exchanges/debug.test.ts b/src/exchanges/debug.test.ts
index f4e24c48..2c0c6a0f 100644
--- a/src/exchanges/debug.test.ts
+++ b/src/exchanges/debug.test.ts
@@ -39,3 +39,23 @@ it('forwards query operations correctly', async () => {
   // eslint-disable-next-line no-console
   expect(console.log).toBeCalledTimes(2);
 });
+
+describe('production', () => {
+  beforeEach(() => {
+    process.env.NODE_ENV = 'production';
+  });
+
+  afterEach(() => {
+    process.env.NODE_ENV = 'test';
+  });
+
+  it('is a noop in production', () => {
+    const [ops$] = input;
+
+    debugExchange({
+      forward: ops => {
+        expect(ops).toBe(ops$);
+      },
+    } as any)(ops$);
+  });
+});
diff --git a/src/utils/result.test.ts b/src/utils/result.test.ts
new file mode 100644
index 00000000..eaa1e7d8
--- /dev/null
+++ b/src/utils/result.test.ts
@@ -0,0 +1,24 @@
+import { queryOperation } from '../test-utils';
+import { makeResult } from './result';
+
+describe('makeResult', () => {
+  it('adds extensions and errors correctly', () => {
+    const response = {};
+    const origResult = {
+      data: undefined,
+      errors: ['error message'],
+      extensions: {
+        extensionKey: 'extensionValue',
+      },
+    };
+
+    const result = makeResult(queryOperation, origResult, response);
+
+    expect(result.operation).toBe(queryOperation);
+    expect(result.data).toBe(undefined);
+    expect(result.extensions).toEqual(origResult.extensions);
+    expect(result.error).toMatchInlineSnapshot(
+      `[CombinedError: [GraphQL] error message]`
+    );
+  });
+});
diff --git a/src/utils/stringifyVariables.test.ts b/src/utils/stringifyVariables.test.ts
new file mode 100644
index 00000000..e08c21b6
--- /dev/null
+++ b/src/utils/stringifyVariables.test.ts
@@ -0,0 +1,30 @@
+import { stringifyVariables } from './stringifyVariables';
+
+it('stringifies objects stabily', () => {
+  expect(stringifyVariables({ b: 'b', a: 'a' })).toBe('{"a":"a","b":"b"}');
+  expect(stringifyVariables({ x: { b: 'b', a: 'a' } })).toBe(
+    '{"x":{"a":"a","b":"b"}}'
+  );
+});
+
+it('stringifies arrays', () => {
+  expect(stringifyVariables([1, 2])).toBe('[1,2]');
+  expect(stringifyVariables({ x: [1, 2] })).toBe('{"x":[1,2]}');
+});
+
+it('stringifies scalars', () => {
+  expect(stringifyVariables(1)).toBe('1');
+  expect(stringifyVariables('test')).toBe('"test"');
+  expect(stringifyVariables(null)).toBe('null');
+  expect(stringifyVariables(undefined)).toBe('');
+  expect(stringifyVariables(Infinity)).toBe('null');
+  expect(stringifyVariables(1 / 0)).toBe('null');
+});
+
+it('throws for circular structures', () => {
+  expect(() => {
+    const x = { x: null } as any;
+    x.x = x;
+    stringifyVariables(x);
+  }).toThrow();
+});
diff --git a/src/utils/toSuspenseSource.test.ts b/src/utils/toSuspenseSource.test.ts
index 0b624202..21185cc1 100644
--- a/src/utils/toSuspenseSource.test.ts
+++ b/src/utils/toSuspenseSource.test.ts
@@ -7,6 +7,7 @@ import {
   fromArray,
   makeSubject,
   never,
+  publish,
   subscribe,
 } from 'wonka';
 
@@ -100,3 +101,22 @@ it('behaves like a normal source when the first result was synchronous', async (
 
   expect(push).toHaveBeenCalledTimes(2);
 });
+
+it('still supports cancellation', async () => {
+  let unsubscribe;
+  const end = jest.fn();
+
+  try {
+    [unsubscribe] = pipe(
+      fromArray([1, 2]),
+      toSuspenseSource,
+      onEnd(end),
+      publish
+    );
+  } catch (promise) {
+    expect(promise).toBe(expect.any(Promise));
+  }
+
+  unsubscribe();
+  expect(end).toHaveBeenCalledTimes(1);
+});
diff --git a/src/utils/typenames.test.ts b/src/utils/typenames.test.ts
index 48a10591..98782c21 100755
--- a/src/utils/typenames.test.ts
+++ b/src/utils/typenames.test.ts
@@ -21,11 +21,11 @@ describe('formatTypeNames', () => {
 
   it('does not duplicate typenames', () => {
     expect(
-      formatTypeNames(`{ 
-      todos { 
+      formatTypeNames(`{
+      todos {
         id
+        __typename
       }
-      __typename
     }`)
     ).toMatchInlineSnapshot(`
                   "{
@@ -33,7 +33,6 @@ describe('formatTypeNames', () => {
                       id
                       __typename
                     }
-                    __typename
                   }
                   "
             `);