Home Reference Source Repository

src/store/__tests__/RelayStoreData_cacheManager-test.js

/**
 * Copyright (c) 2013-present, Facebook, Inc.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 *
 * @emails oncall+relay
 */

'use strict';

require('configureForRelayOSS');

jest
  .unmock('GraphQLRange')
  .unmock('GraphQLSegment');

const GraphQLMutatorConstants = require('GraphQLMutatorConstants');
const Relay = require('Relay');
const RelayConnectionInterface = require('RelayConnectionInterface');
const RelayMockCacheManager = require('RelayMockCacheManager');
const RelayMutationType = require('RelayMutationType');
const RelayStoreData = require('RelayStoreData');
const RelayTestUtils = require('RelayTestUtils');

const transformRelayQueryPayload = require('transformRelayQueryPayload');

describe('RelayStoreData', function() {
  let cacheManager;
  let storeData;

  const {getNode} = RelayTestUtils;
  let CLIENT_MUTATION_ID, HAS_NEXT_PAGE, HAS_PREV_PAGE, PAGE_INFO;

  function getPathToRecord(dataID) {
    return storeData.getRecordStore().getPathToRecord(dataID);
  }

  function getRangeForRecord(dataID) {
    const nodeData = storeData.getNodeData();
    expect(Object.keys(nodeData)).toContain(dataID);
    return nodeData[dataID].__range__;
  }

  beforeEach(() => {
    jest.resetModuleRegistry();

    ({
      CLIENT_MUTATION_ID,
      HAS_NEXT_PAGE,
      HAS_PREV_PAGE,
      PAGE_INFO,
    } = RelayConnectionInterface);

    cacheManager = RelayMockCacheManager.genCacheManager();
    storeData = new RelayStoreData();
    storeData.injectCacheManager(cacheManager);

    jasmine.addMatchers({
      toContainCalledMethods: () => ({
        compare: (actual, calls) => {
          let message;
          const pass = Object.keys(calls).every(methodName => {
            const expected = calls[methodName];
            const value = actual[methodName].mock.calls.length;
            const eachPass = expected === value;

            const expTimes = expected + ' time' + (expected === 1 ? '' : 's');
            const actTimes = value + ' time' + (value === 1 ? '' : 's');
            const not = eachPass ? 'not ' : '';
            message = 'Expected `' + methodName + '` ' + not + 'to be called ' +
              expTimes + ', was called ' + actTimes + '.';
            return eachPass;
          });
          return {pass, message};
        },
      }),
      toBeCalledWithNodeFields: (util, customEqualityTesters) => ({
        compare: (actual, nodeFields) => {
          let message;
          const pass = Object.keys(nodeFields).every(
            expectedID => Object.keys(nodeFields[expectedID]).every(
              expectedFieldName => {
                message =
                  'Expected function to be called with (' +
                  expectedID + ', ' +
                  expectedFieldName + ', ' +
                  nodeFields[expectedID][expectedFieldName] + ').';
                return actual.mock.calls.some(
                  ([actualID, actualFieldName, actualFieldValue]) => (
                    actualID === expectedID &&
                    actualFieldName === expectedFieldName &&
                    util.equals(
                      actualFieldValue,
                      nodeFields[expectedID][actualFieldName],
                      customEqualityTesters
                    )
                  )
                );
              }
            )
          );
          return {pass, message};
        },
      }),
    });
  });

  it('caches node metadata', () => {
    const query = getNode(Relay.QL`query{node(id:"123"){id}}`);
    const response = {
      node: {
        __typename: 'User',
        id: '123',
      },
    };
    storeData.handleQueryPayload(query, response);
    const {queryWriter} = cacheManager.mocks;

    expect(queryWriter).toContainCalledMethods({
      writeNode: 0,
      writeField: 3,
      writeRootCall: 0,
    });
    expect(queryWriter.writeField).toBeCalledWithNodeFields({
      '123': {
        __dataID__: '123',
        __typename: 'User',
        id: '123',
      },
    });
  });

  it('caches custom root calls', () => {
    const query = getNode(Relay.QL`query{username(name:"yuzhi"){id}}`);
    const response = {
      username: {
        __typename: 'User',
        id: '123',
      },
    };
    storeData.handleQueryPayload(query, response);
    const {queryWriter} = cacheManager.mocks;

    expect(queryWriter).toContainCalledMethods({
      writeNode: 0,
      writeField: 3,
      writeRootCall: 1,
    });
    expect(queryWriter.writeRootCall).toBeCalledWith(
      'username',
      'yuzhi',
      '123'
    );
    expect(queryWriter.writeField).toBeCalledWithNodeFields({
      '123': {
        __dataID__: '123',
        __typename: 'User',
        id: '123',
      },
    });
  });

  it('caches nodes with client IDs', () => {
    const query = getNode(Relay.QL`query{viewer{isFbEmployee}}`);
    const response = {
      viewer: {
        __typename: 'User',
        isFbEmployee: true,
      },
    };
    storeData.handleQueryPayload(query, response);
    const {queryWriter} = cacheManager.mocks;

    expect(queryWriter).toContainCalledMethods({
      writeNode: 0,
      writeField: 2,
      writeRootCall: 1,
    });
    expect(queryWriter.writeField).toBeCalledWithNodeFields({
      'client:1': {
        __dataID__: 'client:1',
        // __typename: 'User',
        isFbEmployee: true,
      },
    });
  });

  it('caches linked records', () => {
    const query = getNode(Relay.QL`
      query {
        node(id:"123") {
          id
          hometown {
            id
            url
          }
        }
      }
    `);
    const response = {
      node: {
        __typename: 'User',
        id: '123',
        hometown: {
          __typename: 'Page',
          id: '456',
          url: 'http://...',
        },
      },
    };
    storeData.handleQueryPayload(query, response);
    const {queryWriter} = cacheManager.mocks;

    expect(queryWriter).toContainCalledMethods({
      writeNode: 0,
      writeField: 7,
      writeRootCall: 0,
    });
    expect(queryWriter.writeField).toBeCalledWithNodeFields({
      '123': {
        __dataID__: '123',
        __typename: 'User',
        id: '123',
        hometown: {__dataID__: '456'},
      },
      '456': {
        __dataID__: '456',
        // __typename: 'Page',
        id: '456',
        url: 'http://...',
      },
    });
  });

  it('caches plural fields', () => {
    const query = getNode(Relay.QL`
      query {
        node(id:"123") {
          id
          screennames {
            service
          }
        }
      }
    `);
    const response = {
      node: {
        __typename: 'User',
        id: '123',
        screennames: [
          {service: 'GTALK'},
          {service: 'TWITTER'},
        ],
      },
    };
    storeData.handleQueryPayload(query, response);
    const {queryWriter} = cacheManager.mocks;

    expect(getPathToRecord('client:1')).toEqual(getPathToRecord('client:2'));
    expect(queryWriter).toContainCalledMethods({
      writeNode: 0,
      writeField: 8,
      writeRootCall: 0,
    });
    expect(queryWriter.writeField).toBeCalledWithNodeFields({
      '123': {
        __dataID__: '123',
        __typename: 'User',
        id: '123',
        screennames: [
          {__dataID__: 'client:1'},
          {__dataID__: 'client:2'},
        ],
      },
      'client:1': {
        __dataID__: 'client:1',
        // __typename: 'Screenname',
        service: 'GTALK',
      },
      'client:2': {
        __dataID__: 'client:2',
        // __typename: 'Screenname',
        service: 'TWITTER',
      },
    });
  });

  it('caches connection fields', () => {
    const query = getNode(Relay.QL`
      query {
        node(id:"123") {
          id
          friends(first:"2") {
            edges {
              node {
                id
              }
              cursor
            }
            pageInfo {
              hasPreviousPage
              hasNextPage
            }
          }
        }
      }
    `);
    const response = transformRelayQueryPayload(query, {
      node: {
        __typename: 'User',
        id: '123',
        friends: {
          edges: [
            {
              node: {
                __typename: 'User',
                id: '1',
              },
              cursor: '1',
            },
            {
              node: {
                __typename: 'User',
                id: '2',
              },
              cursor: '2',
            },
          ],
          [PAGE_INFO]: {
            [HAS_PREV_PAGE]: false,
            [HAS_NEXT_PAGE]: true,
          },
        },
      },
    });
    storeData.handleQueryPayload(query, response);
    const {queryWriter} = cacheManager.mocks;

    expect(queryWriter).toContainCalledMethods({
      writeNode: 0,
      writeField: 19,
      writeRootCall: 0,
    });
    expect(queryWriter.writeField).toBeCalledWithNodeFields({
      '123': {
        __dataID__: '123',
        __typename: 'User',
        id: '123',
        friends: {__dataID__: 'client:1'},
      },
      'client:1': {
        __dataID__: 'client:1',
        __filterCalls__: [],
        __forceIndex__: 0,
        __range__: getRangeForRecord('client:1'),
        // __typename: 'FriendsConnection',
      },
      'client:client:1:1': {
        __dataID__: 'client:client:1:1',
        // __typename: 'FriendsEdge',
        node: {__dataID__: '1'},
        cursor: '1',
      },
      '1': {
        __dataID__: '1',
        // __typename: 'User',
        id: '1',
      },
      'client:client:1:2': {
        __dataID__: 'client:client:1:2',
        // __typename: 'FriendsEdge',
        node: {__dataID__: '2'},
        cursor: '2',
      },
      '2': {
        __dataID__: '2',
        // __typename: 'User',
        id: '2',
      },
    });
  });

  it('caches connection fields with no edges', () => {
    const query = getNode(Relay.QL`
      query {
        node(id:"123") {
          id
          friends(first:"2") {
            edges {
              node {
                id
              }
              cursor
            }
            pageInfo {
              hasPreviousPage
              hasNextPage
            }
          }
        }
      }
    `);
    const response = transformRelayQueryPayload(query, {
      node: {
        __typename: 'User',
        id: '123',
        friends: {
          edges: [],
          [PAGE_INFO]: {
            [HAS_PREV_PAGE]: false,
            [HAS_NEXT_PAGE]: true,
          },
        },
      },
    });
    storeData.handleQueryPayload(query, response);
    const {queryWriter} = cacheManager.mocks;

    expect(queryWriter).toContainCalledMethods({
      writeNode: 0,
      writeField: 9,
      writeRootCall: 0,
    });
    expect(queryWriter.writeField).toBeCalledWithNodeFields({
      '123': {
        __dataID__: '123',
        id: '123',
        friends: {__dataID__: 'client:1'},
      },
      'client:1': {
        __dataID__: 'client:1',
        __filterCalls__: [],
        __forceIndex__: 0,
        __range__: getRangeForRecord('client:1'),
        // __typename: 'FriendsConnection',
      },
    });
  });

  it('caches simple mutations', () => {
    const query = getNode(Relay.QL`query{node(id:"123"){id,doesViewerLike}}`);
    const response = {
      node: {
        __typename: 'User',
        id: '123',
        doesViewerLike: false,
      },
    };
    storeData.handleQueryPayload(query, response);
    const {mutationWriter} = cacheManager.mocks;

    const mutationQuery = getNode(Relay.QL`
      mutation {
        feedbackLike(input:$input) {
          clientMutationId
          feedback {
            id
            doesViewerLike
          }
        }
      }
    `);
    const payload = {
      [CLIENT_MUTATION_ID]: 'abc',
      feedback: {
        id: '123',
        doesViewerLike: true,
      },
    };
    storeData.handleUpdatePayload(
      mutationQuery,
      payload,
      {configs: [], isOptimisticUpdate: false}
    );

    expect(mutationWriter).toContainCalledMethods({
      writeNode: 0,
      writeField: 2, // both scalar fields are updated
      writeRootCall: 0,
    });
    expect(mutationWriter.writeField).toBeCalledWithNodeFields({
      '123': {
        doesViewerLike: true,
      },
    });
  });

  it('caches mutation that inserts an edge', () => {
    const query = getNode(Relay.QL`
      query {
        node(id:"123") {
          id
          comments(first:"1") {
            count
            edges {
              node {
                id
              }
              cursor
            }
            pageInfo {
              hasPreviousPage
              hasNextPage
            }
          }
        }
      }
    `);
    const response = transformRelayQueryPayload(query, {
      node: {
        __typename: 'Story',
        id: '123',
        comments: {
          count: 2,
          edges: [
            {
              node: {
                id: '1',
              },
              cursor: '1',
            },
          ],
          [PAGE_INFO]: {
            [HAS_PREV_PAGE]: false,
            [HAS_NEXT_PAGE]: true,
          },
        },
      },
    });
    storeData.handleQueryPayload(query, response);
    const {mutationWriter} = cacheManager.mocks;

    const configs = [{
      type: RelayMutationType.RANGE_ADD,
      connectionName: 'comments',
      edgeName: 'feedbackCommentEdge',
      rangeBehaviors: {'': GraphQLMutatorConstants.PREPEND},
    }];

    const mutationQuery = getNode(Relay.QL`
      mutation {
        commentCreate(input:$input) {
          clientMutationId
          feedback {
            id
            comments {
              count
            }
          }
          feedbackCommentEdge {
            node {
              id
            }
            cursor
            source {
              id
            }
          }
        }
      }
    `);
    const payload = {
      [CLIENT_MUTATION_ID]: 'abc',
      feedback: {
        comments: {
          count: 3,
        },
        id: '123',
      },
      feedbackCommentEdge: {
        __typename: 'User',
        node: {
          id: '2',
        },
        cursor: '2',
        source: {
          id: '123',
        },
      },
    };
    storeData.handleUpdatePayload(
      mutationQuery,
      payload,
      {configs, isOptimisticUpdate: false}
    );

    expect(mutationWriter).toContainCalledMethods({
      writeNode: 0,
      writeField: 11,
      writeRootCall: 0,
    });
    expect(mutationWriter.writeField).toBeCalledWithNodeFields({
      'client:1': {
        __range__: getRangeForRecord('client:1'),
        count: 3,
      },
      'client:client:1:2': {
        __dataID__: 'client:client:1:2',
        node: {__dataID__: '2'},
        cursor: '2',
        source: {__dataID__: '123'},
      },
      '2': {
        __dataID__: '2',
        id: '2',
      },
    });
  });

  it('caches mutation that deletes an edge', () => {
    const query = getNode(Relay.QL`
      query {
        node(id:"123") {
          id
          comments(first:"1") {
            count
            edges {
              node {
                id
              }
              cursor
            }
            pageInfo {
              hasPreviousPage
              hasNextPage
            }
          }
        }
      }
    `);
    const response = transformRelayQueryPayload(query, {
      node: {
        __typename: 'Story',
        id: '123',
        comments: {
          count: 2,
          edges: [
            {
              node: {
                id: '1',
              },
              cursor: '1',
            },
          ],
          [PAGE_INFO]: {
            [HAS_PREV_PAGE]: false,
            [HAS_NEXT_PAGE]: true,
          },
        },
      },
    });
    storeData.handleQueryPayload(query, response);
    const {mutationWriter} = cacheManager.mocks;

    const configs = [{
      type: RelayMutationType.RANGE_DELETE,
      pathToConnection: ['feedback', 'comments'],
      deletedIDFieldName: 'deletedCommentId',
    }];

    const mutationQuery = getNode(Relay.QL`
      mutation {
        commentDelete(input:$input) {
          clientMutationId
          deletedCommentId
          feedback {
            id
            comments {
              count
            }
          }
        }
      }
    `);
    const payload = {
      [CLIENT_MUTATION_ID]: 'abc',
      deletedCommentId: '1',
      feedback: {
        id: '123',
        comments: {
          count: 1,
        },
      },
    };
    storeData.handleUpdatePayload(
      mutationQuery,
      payload,
      {configs, isOptimisticUpdate: false}
    );

    expect(mutationWriter).toContainCalledMethods({
      writeNode: 1,
      writeField: 4,
      writeRootCall: 0,
    });
    expect(mutationWriter.writeField).toBeCalledWithNodeFields({
      'client:1': {
        __range__: getRangeForRecord('client:1'),
        count: 1,
      },
    });
  });

  it('clears cache manager', () => {
    storeData.clearCacheManager();
    expect(storeData.hasCacheManager()).toBe(false);
  });
});