Home Reference Source Repository

src/traversal/__tests__/writeRelayUpdatePayload-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')
  .mock('warning');

const GraphQLMutatorConstants = require('GraphQLMutatorConstants');
const Relay = require('Relay');
const RelayChangeTracker = require('RelayChangeTracker');
const RelayConnectionInterface = require('RelayConnectionInterface');
const RelayMutationType = require('RelayMutationType');
const RelayQueryTracker = require('RelayQueryTracker');
const RelayQueryWriter = require('RelayQueryWriter');
const RelayRecordStore = require('RelayRecordStore');
const RelayRecordWriter = require('RelayRecordWriter');
const RelayTestUtils = require('RelayTestUtils');

const generateClientEdgeID = require('generateClientEdgeID');
const writeRelayUpdatePayload = require('writeRelayUpdatePayload');

describe('writeRelayUpdatePayload()', () => {
  const {getNode, writePayload} = RelayTestUtils;

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

    jasmine.addMatchers(RelayTestUtils.matchers);
  });

  describe('fields changed mutations', () => {
    let commentID;
    let connectionID;
    let query;
    let queueStore;
    let queueWriter;
    let store;
    let writer;

    beforeEach(() => {
      const records = {};
      const queuedRecords = {};
      const nodeConnectionMap = {};
      const rootCallMap = {};
      const rootCallMaps = {rootCallMap};

      commentID = 'comment123';

      store = new RelayRecordStore(
        {records},
        rootCallMaps,
        nodeConnectionMap
      );
      queueStore = new RelayRecordStore(
        {records, queuedRecords},
        rootCallMaps,
        nodeConnectionMap
      );
      writer = new RelayRecordWriter(
        records,
        rootCallMap,
        false,
        nodeConnectionMap
      );
      queueWriter = new RelayRecordWriter(
        queuedRecords,
        rootCallMap,
        true,
        nodeConnectionMap,
        null,
        'mutationID'
      );
      query = getNode(Relay.QL`
        query TestQuery {
          node(id:"feedback_id") {
            topLevelComments(first:"1") {
              count
              edges {
                node {
                  id
                }
              }
            }
          }
        }
      `);
      const payload = {
        node: {
          __typename: 'Feedback',
          id: 'feedback_id',
          topLevelComments: {
            count: 1,
            edges: [
              {
                cursor: commentID + ':cursor',
                node: {
                  id: commentID,
                },
              },
            ],
          },
        },
      };
      writePayload(store, writer, query, payload);
      connectionID = store.getLinkedRecordID(
        'feedback_id',
        'topLevelComments'
      );
    });

    it('unspecified optimistic fields does not overwrite existing store data', () => {
      // create the mutation and payload
      const input = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]: '0',
        deletedCommentId: commentID,
      };
      const mutation = getNode(Relay.QL`
        mutation {
          commentDelete(input:$input) {
            feedback {
              topLevelComments {
                count
              }
            }
          }
        }
      `, {
        input: JSON.stringify(input),
      });
      const configs = [{
        type: RelayMutationType.FIELDS_CHANGE,
        fieldIDs: {feedback: 'feedback_id'},
      }];

      const payload = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]:
          input[RelayConnectionInterface.CLIENT_MUTATION_ID],
        feedback: {
          id: 'feedback_id',
          topLevelComments: {},
        },
      };

      // write to the queued store
      const changeTracker = new RelayChangeTracker();
      const queryTracker = new RelayQueryTracker();
      const queryWriter = new RelayQueryWriter(
        queueStore,
        queueWriter,
        queryTracker,
        changeTracker,
        {isOptimisticUpdate: true}
      );

      writeRelayUpdatePayload(
        queryWriter,
        mutation,
        payload,
        {configs, isOptimisticUpdate: true}
      );

      expect(changeTracker.getChangeSet()).toEqual({
        created: {},
        updated: {},
      });

      expect(queueStore.getField(connectionID, 'count')).toBe(1);
    });

    it('reports useful debug info for unexpectedly missing records', () => {
      const changeTracker = new RelayChangeTracker();
      const queryTracker = new RelayQueryTracker();
      const queryWriter = new RelayQueryWriter(
        store,
        writer,
        queryTracker,
        changeTracker,
      );
      const configs = [{
        type: RelayMutationType.FIELDS_CHANGE,
        fieldIDs: {feedback: 'feedback_id'},
      }];
      const input = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]: '0',
        deletedCommentId: commentID,
      };
      const payload = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]:
          input[RelayConnectionInterface.CLIENT_MUTATION_ID],
        feedback: {
          id: null, // Malformed response.
          topLevelComments: {},
        },
      };
      expect(() => writeRelayUpdatePayload(queryWriter, query, payload, {configs}))
        .toFailInvariant(
          'writeRelayUpdatePayload(): Expected a record ID in the response ' +
          'payload supplied to update the store for field `feedback`, ' +
          'payload keys [id, topLevelComments], operation name `TestQuery`.'
        );
    });
  });

  describe('range delete mutations', () => {
    let store, queueStore, writer, queueWriter, commentID, connectionID, edgeID;

    beforeEach(() => {
      const records = {};
      const queuedRecords = {};
      const nodeConnectionMap = {};
      const rootCallMap = {};
      const rootCallMaps = {rootCallMap};

      commentID = 'comment123';

      store = new RelayRecordStore(
        {records},
        rootCallMaps,
        nodeConnectionMap
      );
      queueStore = new RelayRecordStore(
        {records, queuedRecords},
        rootCallMaps,
        nodeConnectionMap
      );
      writer = new RelayRecordWriter(
        records,
        rootCallMap,
        false,
        nodeConnectionMap
      );
      queueWriter = new RelayRecordWriter(
        queuedRecords,
        rootCallMap,
        true,
        nodeConnectionMap,
        null,
        'mutationID'
      );

      const query = getNode(Relay.QL`
        query {
          node(id:"feedback_id") {
            topLevelComments(first:"1") {
              count
              edges {
                node {
                  id
                }
              }
            }
          }
        }
      `);
      const payload = {
        node: {
          __typename: 'Feedback',
          id: 'feedback_id',
          topLevelComments: {
            count: 1,
            edges: [
              {
                cursor: commentID + ':cursor',
                node: {
                  id: commentID,
                },
              },
            ],
          },
        },
      };
      writePayload(store, writer, query, payload);
      connectionID = store.getLinkedRecordID(
        'feedback_id',
        'topLevelComments'
      );
      edgeID = generateClientEdgeID(connectionID, commentID);
    });

    it('optimistically removes range edges', () => {
      // create the mutation and payload
      const input = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]: '0',
        deletedCommentId: commentID,
      };
      const mutation = getNode(Relay.QL`
        mutation {
          commentDelete(input:$input) {
            deletedCommentId
            feedback {
              topLevelComments {
                count
              }
            }
          }
        }
      `, {
        input: JSON.stringify(input),
      });
      const configs = [{
        type: RelayMutationType.RANGE_DELETE,
        deletedIDFieldName: 'deletedCommentId',
        pathToConnection: ['feedback', 'topLevelComments'],
      }];

      const payload = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]:
          input[RelayConnectionInterface.CLIENT_MUTATION_ID],
        deletedCommentId: commentID,
        feedback: {
          id: 'feedback_id',
          topLevelComments: {
            count: 0,
          },
        },
      };

      // write to the queued store
      const changeTracker = new RelayChangeTracker();
      const queryTracker = new RelayQueryTracker();
      const queryWriter = new RelayQueryWriter(
        queueStore,
        queueWriter,
        queryTracker,
        changeTracker,
        {isOptimisticUpdate: true}
      );

      writeRelayUpdatePayload(
        queryWriter,
        mutation,
        payload,
        {configs, isOptimisticUpdate: true}
      );

      expect(changeTracker.getChangeSet()).toEqual({
        created: {},
        updated: {
          [connectionID]: true, // range edge deleted & count changed
          [edgeID]: true, // edge deleted
          // `commentID` is not modified
        },
      });

      expect(queueStore.getField(connectionID, 'count')).toBe(0);
      expect(queueStore.getRecordState(edgeID)).toBe('NONEXISTENT');
      expect(queueStore.getRecordState(commentID)).toBe('EXISTENT');
      // the range no longer returns this edge
      expect(queueStore.getRangeMetadata(
        connectionID,
        [{name: 'first', value: '1'}]
      ).filteredEdges.map(edge => edge.edgeID)).toEqual([]);

      expect(store.getField(connectionID, 'count')).toBe(1);
      expect(store.getRecordState(edgeID)).toBe('EXISTENT');
      // the range still contains this edge
      expect(store.getRangeMetadata(
        connectionID,
        [{name: 'first', value: '1'}]
      ).filteredEdges.map(edge => edge.edgeID)).toEqual([
        edgeID,
      ]);
    });

    it('non-optimistically removes range edges', () => {
      // create the mutation and payload
      const input = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]: '0',
        deletedCommentId: commentID,
      };
      const mutation = getNode(Relay.QL`
        mutation {
          commentDelete(input:$input) {
            deletedCommentId
            feedback {
              topLevelComments {
                count
              }
            }
          }
        }
      `, {
        input: JSON.stringify(input),
      });
      const configs = [{
        type: RelayMutationType.RANGE_DELETE,
        deletedIDFieldName: 'deletedCommentId',
        pathToConnection: ['feedback', 'topLevelComments'],
      }];

      const payload = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]:
          input[RelayConnectionInterface.CLIENT_MUTATION_ID],
        deletedCommentId: commentID,
        feedback: {
          id: 'feedback_id',
          topLevelComments: {
            count: 0,
          },
        },
      };

      // write to the queued store
      const changeTracker = new RelayChangeTracker();
      const queryTracker = new RelayQueryTracker();
      const queryWriter = new RelayQueryWriter(
        store,
        writer,
        queryTracker,
        changeTracker
      );

      writeRelayUpdatePayload(
        queryWriter,
        mutation,
        payload,
        {configs, isOptimisticUpdate: false}
      );

      expect(changeTracker.getChangeSet()).toEqual({
        created: {},
        updated: {
          [connectionID]: true, // range edge deleted & count changed
          [edgeID]: true, // edge deleted
          // `commentID` is not modified
        },
      });

      expect(store.getField(connectionID, 'count')).toBe(0);
      expect(store.getRecordState(edgeID)).toBe('NONEXISTENT');
      expect(store.getRecordState(commentID)).toBe('EXISTENT');
      // the range no longer returns this edge
      expect(store.getRangeMetadata(
        connectionID,
        [{name: 'first', value: '1'}]
      ).filteredEdges.map(edge => edge.edgeID)).toEqual([]);
    });

    it('removes range edge with a "deleted field ID path"', () => {
      writePayload(
        store,
        writer,
        getNode(Relay.QL`
          query {
            viewer {
              actor {
                friends(first: "1") {
                  edges {
                    node {
                      id
                    }
                  }
                }
              }
            }
          }
        `),
        {
          viewer: {
            actor: {
              __typename: 'User',
              id: '123',
              friends: {
                edges: [
                  {
                    node: {
                      id: '456',
                    },
                  },
                ],
              },
            },
          },
        }
      );
      const friendConnectionID = store.getLinkedRecordID('123', 'friends');
      const friendEdgeID = generateClientEdgeID(friendConnectionID, '456');

      const input = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]: '0',
        friendId: '456',
      };
      const mutation = getNode(Relay.QL`
        mutation {
          unfriend(input: $input) {
            actor {
              id
            }
            formerFriend {
              id
            }
          }
        }
      `, {
        input: JSON.stringify(input),
      });
      const configs = [{
        type: RelayMutationType.RANGE_DELETE,
        parentName: 'actor',
        parentID: '123',
        connectionName: 'friends',
        deletedIDFieldName: ['formerFriend'],
        pathToConnection: ['actor', 'friends'],
      }];

      const payload = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]:
          input[RelayConnectionInterface.CLIENT_MUTATION_ID],
        actor: {
          id: '123',
          __typename: 'User',
        },
        formerFriend: {
          id: '456',
        },
      };
      const changeTracker = new RelayChangeTracker();
      const queryTracker = new RelayQueryTracker();
      const queryWriter = new RelayQueryWriter(
        store,
        writer,
        queryTracker,
        changeTracker
      );

      writeRelayUpdatePayload(
        queryWriter,
        mutation,
        payload,
        {configs, isOptimisticUpdate: false}
      );

      expect(changeTracker.getChangeSet()).toEqual({
        created: {},
        updated: {
          [friendConnectionID]: true,
          [friendEdgeID]: true,
        },
      });

      expect(store.getRecordState(friendEdgeID)).toBe('NONEXISTENT');
      expect(store.getRecordState('456')).toBe('EXISTENT');
      // the range no longer returns this edge
      expect(store.getRangeMetadata(
        friendConnectionID,
        [{name: 'first', value: '1'}]
      ).filteredEdges.map(edge => edge.edgeID)).toEqual([]);
    });
  });

  describe('plural range delete mutation', () => {
    let store, queueStore, writer, queueWriter, commentIDs, connectionID, edgeIDs;

    beforeEach(() => {
      const records = {};
      const queuedRecords = {};
      const nodeConnectionMap = {};
      const rootCallMap = {};
      const rootCallMaps = {rootCallMap};

      commentIDs = ['comment123', 'comment456', 'comment789'];

      store = new RelayRecordStore(
        {records},
        rootCallMaps,
        nodeConnectionMap
      );
      queueStore = new RelayRecordStore(
        {records, queuedRecords},
        rootCallMaps,
        nodeConnectionMap
      );
      writer = new RelayRecordWriter(
        records,
        rootCallMap,
        false,
        nodeConnectionMap
      );
      queueWriter = new RelayRecordWriter(
        queuedRecords,
        rootCallMap,
        true,
        nodeConnectionMap,
        null,
        'mutationID'
      );

      const query = getNode(Relay.QL`
        query {
          node(id:"feedback_id") {
            topLevelComments(first:"3") {
              count
              edges {
                node {
                  id
                }
              }
            }
          }
        }
      `);
      const payload = {
        node: {
          __typename: 'Feedback',
          id: 'feedback_id',
          topLevelComments: {
            count: commentIDs.length,
            edges: commentIDs.map(id => {
              return {
                cursor: id + ':cursor',
                node: {
                  id,
                },
              };
            }),
          },
        },
      };
      writePayload(store, writer, query, payload);
      connectionID = store.getLinkedRecordID(
        'feedback_id',
        'topLevelComments'
      );
      edgeIDs = commentIDs.map(id => {
        return generateClientEdgeID(connectionID, id);
      });
    });

    it('optimistically deletes requests', () => {
      // create the mutation and payload
      const input = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]: '0',
        deletedCommentId: commentIDs,
      };
      const mutation = getNode(Relay.QL`
        mutation {
          commentDelete(input:$input) {
            deletedCommentId
            feedback {
              topLevelComments {
                count
              }
            }
          }
        }
      `, {
        input: JSON.stringify(input),
      });
      const configs = [{
        type: RelayMutationType.RANGE_DELETE,
        deletedIDFieldName: 'deletedCommentId',
        pathToConnection: ['feedback', 'topLevelComments'],
      }];

      const payload = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]:
          input[RelayConnectionInterface.CLIENT_MUTATION_ID],
        deletedCommentId: commentIDs,
        feedback: {
          id: 'feedback_id',
          topLevelComments: {
            count: 0,
          },
        },
      };

      // write to the queued store
      const changeTracker = new RelayChangeTracker();
      const queryTracker = new RelayQueryTracker();
      const queryWriter = new RelayQueryWriter(
        queueStore,
        queueWriter,
        queryTracker,
        changeTracker,
        {isOptimisticUpdate: true}
      );

      writeRelayUpdatePayload(
        queryWriter,
        mutation,
        payload,
        {configs, isOptimisticUpdate: true}
      );

      expect(changeTracker.getChangeSet()).toEqual({
        created: {},
        updated: {
          [connectionID]: true, // range edge deleted & count changed
          ...edgeIDs.reduce((edgeMap, id) => { // edges are deleted
            return {
              ...edgeMap,
              [id]: true,
            };
          }, {}),
          // `commentID` is not modified
        },
      });

      expect(queueStore.getField(connectionID, 'count')).toBe(0);
      edgeIDs.forEach(edgeID => {
        expect(queueStore.getRecordState(edgeID)).toBe('NONEXISTENT');
      });
      commentIDs.forEach(commentID => {
        expect(queueStore.getRecordState(commentID)).toBe('EXISTENT');
      });
      // the range no longer returns this edge
      expect(queueStore.getRangeMetadata(
        connectionID,
        [{name: 'first', value: '1'}]
      ).filteredEdges.map(edge => edge.edgeID)).toEqual([]);

      expect(store.getField(connectionID, 'count')).toBe(3);
      edgeIDs.forEach(edgeID => {
        // the range still contains this edge
        expect(store.getRecordState(edgeID)).toBe('EXISTENT');
      });
      expect(store.getRangeMetadata(
        connectionID,
        [{name: 'first', value: commentIDs.length}]
      ).filteredEdges.map(edge => edge.edgeID)).toEqual(edgeIDs);
    });

    it('non-optimistically deletes requests', () => {
      // create the mutation and payload
      const input = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]: '0',
        deletedCommentId: commentIDs,
      };
      const mutation = getNode(Relay.QL`
        mutation {
          commentDelete(input:$input) {
            deletedCommentId
            feedback {
              topLevelComments {
                count
              }
            }
          }
        }
      `, {
        input: JSON.stringify(input),
      });
      const configs = [{
        type: RelayMutationType.RANGE_DELETE,
        deletedIDFieldName: 'deletedCommentId',
        pathToConnection: ['feedback', 'topLevelComments'],
      }];

      const payload = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]:
          input[RelayConnectionInterface.CLIENT_MUTATION_ID],
        deletedCommentId: commentIDs,
        feedback: {
          id: 'feedback_id',
          topLevelComments: {
            count: 0,
          },
        },
      };

      // write to the queued store
      const changeTracker = new RelayChangeTracker();
      const queryTracker = new RelayQueryTracker();
      const queryWriter = new RelayQueryWriter(
        store,
        writer,
        queryTracker,
        changeTracker
      );

      writeRelayUpdatePayload(
        queryWriter,
        mutation,
        payload,
        {configs, isOptimisticUpdate: false}
      );

      expect(changeTracker.getChangeSet()).toEqual({
        created: {},
        updated: {
          [connectionID]: true, // range edge deleted & count changed
          ...edgeIDs.reduce((edgeMap, id) => { // edges are deleted
            return {
              ...edgeMap,
              [id]: true,
            };
          }, {}),
          // `commentID` is not modified
        },
      });

      expect(store.getField(connectionID, 'count')).toBe(0);
      edgeIDs.forEach(edgeID => {
        expect(store.getRecordState(edgeID)).toBe('NONEXISTENT');
      });
      commentIDs.forEach(commentID => {
        expect(store.getRecordState(commentID)).toBe('EXISTENT');
      });
      // the range no longer returns this edge
      expect(store.getRangeMetadata(
        connectionID,
        [{name: 'first', value: '1'}]
      ).filteredEdges.map(edge => edge.edgeID)).toEqual([]);
    });
  });

  describe('node/range delete mutations', () => {
    let store, queueStore, writer, queueWriter, feedbackID, connectionID, firstCommentID, secondCommentID, firstEdgeID, secondEdgeID; // eslint-disable-line max-len

    beforeEach(() => {
      const records = {};
      const queuedRecords = {};
      const nodeConnectionMap = {};
      const rootCallMap = {};
      const rootCallMaps = {rootCallMap};

      feedbackID = 'feedback123';
      firstCommentID = 'comment456';
      secondCommentID = 'comment789';
      store = new RelayRecordStore(
        {records},
        rootCallMaps,
        nodeConnectionMap
      );
      queueStore = new RelayRecordStore(
        {records, queuedRecords},
        rootCallMaps,
        nodeConnectionMap
      );
      writer = new RelayRecordWriter(
        records,
        rootCallMap,
        false,
        nodeConnectionMap
      );
      queueWriter = new RelayRecordWriter(
        queuedRecords,
        rootCallMap,
        true,
        nodeConnectionMap,
        null,
        'mutationID'
      );

      const query = getNode(Relay.QL`
        query {
          node(id:"feedback123") {
            topLevelComments(first:"1") {
              count
              edges {
                node {
                  id
                }
              }
            }
          }
        }
      `);
      const payload = {
        node: {
          __typename: 'Feedback',
          id: feedbackID,
          topLevelComments: {
            count: 1,
            edges: [
              {
                cursor: firstCommentID + ':cursor',
                node: {
                  id: firstCommentID,
                },
              },
              {
                cursor: secondCommentID + ':cursor',
                node: {
                  id: secondCommentID,
                },
              },
            ],
          },
        },
      };

      writePayload(store, writer, query, payload);
      connectionID = store.getLinkedRecordID(feedbackID, 'topLevelComments');
      firstEdgeID = generateClientEdgeID(connectionID, firstCommentID);
      secondEdgeID = generateClientEdgeID(connectionID, secondCommentID);
    });

    it('optimistically deletes comments', () => {
      // create the mutation and payload
      const input = {
        actor_id: 'actor:123',
        [RelayConnectionInterface.CLIENT_MUTATION_ID]: '0',
        deletedCommentId: firstCommentID,
      };
      const mutation = getNode(Relay.QL`
        mutation {
          commentDelete(input:$input) {
            deletedCommentId
            feedback {
              id
              topLevelComments {
                count
              }
            }
          }
        }
      `, {
        input: JSON.stringify(input),
      });
      const configs = [{
        type: RelayMutationType.NODE_DELETE,
        deletedIDFieldName: 'deletedCommentId',
      }];

      const payload = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]:
          input[RelayConnectionInterface.CLIENT_MUTATION_ID],
        deletedCommentId: firstCommentID,
        feedback: {
          id: feedbackID,
          topLevelComments: {
            count: 0,
          },
        },
      };

      // write to the queued store
      const changeTracker = new RelayChangeTracker();
      const queryTracker = new RelayQueryTracker();
      const queryWriter = new RelayQueryWriter(
        queueStore,
        queueWriter,
        queryTracker,
        changeTracker,
        {isOptimisticUpdate: true}
      );

      writeRelayUpdatePayload(
        queryWriter,
        mutation,
        payload,
        {configs, isOptimisticUpdate: true}
      );

      expect(changeTracker.getChangeSet()).toEqual({
        created: {},
        updated: {
          [connectionID]: true, // range item deleted & count changed
          [firstEdgeID]: true, // edge deleted
          [firstCommentID]: true, // node deleted
        },
      });

      // node is deleted
      expect(queueStore.getRecordState(firstCommentID)).toBe('NONEXISTENT');
      expect(queueStore.getRecordState(secondCommentID)).toBe('EXISTENT');
      // corresponding edge is deleted for every range this node appears in
      expect(queueStore.getRecordState(firstEdgeID)).toBe('NONEXISTENT');
      expect(queueStore.getRecordState(secondEdgeID)).toBe('EXISTENT');
      // the range no longer returns this edge
      expect(queueStore.getRangeMetadata(
        connectionID,
        [{name: 'first', value: '2'}]
      ).filteredEdges.map(edge => edge.edgeID)).toEqual([
        secondEdgeID,
      ]);
      // connection metadata is merged into the queued store
      expect(queueStore.getField(connectionID, 'count')).toBe(0);

      // base records are not modified: node & edge exist, the edge is still
      // in the range, and the connection metadata is unchanged
      expect(store.getRecordState(firstCommentID)).toBe('EXISTENT');
      expect(store.getRecordState(secondCommentID)).toBe('EXISTENT');
      expect(store.getRecordState(firstEdgeID)).toBe('EXISTENT');
      expect(store.getRecordState(secondEdgeID)).toBe('EXISTENT');
      expect(store.getField(connectionID, 'count')).toBe(1);
      expect(store.getRangeMetadata(
        connectionID,
        [{name: 'first', value: '2'}]
      ).filteredEdges.map(edge => edge.edgeID)).toEqual([
        firstEdgeID,
        secondEdgeID,
      ]);
    });

    it('non-optimistically deletes comments', () => {
      // create the mutation and payload
      const input = {
        actor_id: 'actor:123',
        [RelayConnectionInterface.CLIENT_MUTATION_ID]: '0',
        deletedCommentId: firstCommentID,
      };
      const mutation = getNode(Relay.QL`
        mutation {
          commentDelete(input:$input) {
            deletedCommentId
            feedback {
              id
              topLevelComments {
                count
              }
            }
          }
        }
      `, {
        input: JSON.stringify(input),
      });
      const configs = [{
        type: RelayMutationType.NODE_DELETE,
        deletedIDFieldName: 'deletedCommentId',
      }];

      const payload = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]:
          input[RelayConnectionInterface.CLIENT_MUTATION_ID],
        deletedCommentId: firstCommentID,
        feedback: {
          id: feedbackID,
          topLevelComments: {
            count: 0,
          },
        },
      };

      // write to the base store
      const changeTracker = new RelayChangeTracker();
      const queryTracker = new RelayQueryTracker();
      const queryWriter = new RelayQueryWriter(
        store,
        writer,
        queryTracker,
        changeTracker
      );

      writeRelayUpdatePayload(
        queryWriter,
        mutation,
        payload,
        {configs, isOptimisticUpdate: false}
      );

      expect(changeTracker.getChangeSet()).toEqual({
        created: {},
        updated: {
          [connectionID]: true, // range item deleted & count changed
          [firstEdgeID]: true, // edge deleted
          [firstCommentID]: true, // node deleted
        },
      });

      // node is deleted
      expect(store.getRecordState(firstCommentID)).toBe('NONEXISTENT');
      expect(store.getRecordState(secondCommentID)).toBe('EXISTENT');
      // corresponding edge is deleted for every range this node appears in
      expect(store.getRecordState(firstEdgeID)).toBe('NONEXISTENT');
      expect(store.getRecordState(secondEdgeID)).toBe('EXISTENT');
      // the range no longer returns this edge
      expect(store.getRangeMetadata(
        connectionID,
        [{name: 'first', value: '1'}]
      ).filteredEdges.map(edge => edge.edgeID)).toEqual([
        secondEdgeID,
      ]);
      // connection metadata is merged into the queued store
      expect(store.getField(connectionID, 'count')).toBe(0);
    });
  });

  describe('plural node delete mutation', () => {
    let store, queueStore, writer, queueWriter, firstRequestID, secondRequestID, thirdRequestID;

    beforeEach(() => {
      const records = {};
      const queuedRecords = {};
      const rootCallMap = {};
      const rootCallMaps = {rootCallMap};

      firstRequestID = 'request1';
      secondRequestID = 'request2';
      thirdRequestID = 'request3';

      store = new RelayRecordStore(
        {records},
        rootCallMaps,
        {}
      );
      queueStore = new RelayRecordStore(
        {records, queuedRecords},
        rootCallMaps,
        {}
      );
      writer = new RelayRecordWriter(
        records,
        rootCallMap,
        false
      );
      queueWriter = new RelayRecordWriter(
        queuedRecords,
        rootCallMap,
        true,
        {},
        null,
        'mutationID'
      );

      const query = getNode(Relay.QL`
        query {
          nodes(ids:["request1","request2","request3"]) {
            id
          }
        }
      `);
      const payload = {
        nodes: [
          {__typename: 'User', id: firstRequestID},
          {__typename: 'User', id: secondRequestID},
          {__typename: 'User', id: thirdRequestID},
        ],
      };

      writePayload(store, writer, query, payload);

    });
    it('optimistically deletes requests', () => {
      // create the mutation and payload
      const input = {
        actor_id: 'actor:123',
        [RelayConnectionInterface.CLIENT_MUTATION_ID]: '0',
        deletedRequestIds: [firstRequestID, secondRequestID],
      };
      const mutation = getNode(Relay.QL`
        mutation {
          applicationRequestDeleteAll(input:$input) {
            deletedRequestIds
          }
        }
      `, {
        input: JSON.stringify(input),
      });
      const configs = [{
        type: RelayMutationType.NODE_DELETE,
        deletedIDFieldName: 'deletedRequestIds',
      }];

      const payload = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]:
          input[RelayConnectionInterface.CLIENT_MUTATION_ID],
        deletedRequestIds: [firstRequestID, secondRequestID],
      };

      // write to the queued store
      const changeTracker = new RelayChangeTracker();
      const queryTracker = new RelayQueryTracker();
      const queryWriter = new RelayQueryWriter(
        queueStore,
        queueWriter,
        queryTracker,
        changeTracker,
        {isOptimisticUpdate: true}
      );

      writeRelayUpdatePayload(
        queryWriter,
        mutation,
        payload,
        {configs, isOptimisticUpdate: true}
      );

      expect(changeTracker.getChangeSet()).toEqual({
        created: {},
        updated: {
          [firstRequestID]: true, // node deleted
          [secondRequestID]: true, // node deleted
        },
      });

      // node is deleted
      expect(queueStore.getRecordState(firstRequestID)).toBe('NONEXISTENT');
      expect(queueStore.getRecordState(secondRequestID)).toBe('NONEXISTENT');
      // third node is not deleted
      expect(queueStore.getRecordState(thirdRequestID)).toBe('EXISTENT');

      // base records are not modified: node & edge exist, the edge is still
      // in the range, and the connection metadata is unchanged
      expect(store.getRecordState(firstRequestID)).toBe('EXISTENT');
      expect(store.getRecordState(secondRequestID)).toBe('EXISTENT');
      expect(store.getRecordState(thirdRequestID)).toBe('EXISTENT');
    });

    it('non-optimistically deletes requests', () => {
      // create the mutation and payload
      const input = {
        actor_id: 'actor:123',
        [RelayConnectionInterface.CLIENT_MUTATION_ID]: '0',
        deletedRequestIds: [firstRequestID, secondRequestID],
      };
      const mutation = getNode(Relay.QL`
        mutation {
          applicationRequestDeleteAll(input:$input) {
            deletedRequestIds
          }
        }
      `, {
        input: JSON.stringify(input),
      });
      const configs = [{
        type: RelayMutationType.NODE_DELETE,
        deletedIDFieldName: 'deletedRequestIds',
      }];

      const payload = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]:
          input[RelayConnectionInterface.CLIENT_MUTATION_ID],
        deletedRequestIds: [firstRequestID, secondRequestID],
      };

      // write to the base store
      const changeTracker = new RelayChangeTracker();
      const queryTracker = new RelayQueryTracker();
      const queryWriter = new RelayQueryWriter(
        store,
        writer,
        queryTracker,
        changeTracker
      );

      writeRelayUpdatePayload(
        queryWriter,
        mutation,
        payload,
        {configs, isOptimisticUpdate: false}
      );

      expect(changeTracker.getChangeSet()).toEqual({
        created: {},
        updated: {
          [firstRequestID]: true, // node deleted
          [secondRequestID]: true,
        },
      });

      // node is deleted
      expect(store.getRecordState(firstRequestID)).toBe('NONEXISTENT');
      expect(store.getRecordState(secondRequestID)).toBe('NONEXISTENT');
      // third node is not deleted
      expect(store.getRecordState(thirdRequestID)).toBe('EXISTENT');
    });
  });

  describe('range add mutations', () => {
    let connectionID;
    let edgeID;
    let feedbackID;
    let queueStore;
    let queueWriter;
    let store;
    let writer;

    beforeEach(() => {
      const records = {};
      const queuedRecords = {};
      const nodeConnectionMap = {};
      const rootCallMap = {};
      const rootCallMaps = {rootCallMap};

      feedbackID = 'feedback123';
      const commentID = 'comment456';
      store = new RelayRecordStore(
        {records},
        rootCallMaps,
        nodeConnectionMap
      );
      queueStore = new RelayRecordStore(
        {records, queuedRecords},
        rootCallMaps,
        nodeConnectionMap
      );
      writer = new RelayRecordWriter(
        records,
        rootCallMap,
        false,
        nodeConnectionMap
      );
      queueWriter = new RelayRecordWriter(
        queuedRecords,
        rootCallMap,
        true,
        nodeConnectionMap,
        null,
        'mutationID'
      );

      const query = getNode(Relay.QL`
        query {
          node(id:"feedback123") {
            topLevelComments(first:"1") {
              count
              edges {
                node {
                  id
                }
              }
            }
          }
        }
      `);
      const payload = {
        node: {
          id: feedbackID,
          __typename: 'Feedback',
          topLevelComments: {
            count: 1,
            edges: [
              {
                cursor: commentID + ':cursor',
                node: {
                  id: commentID,
                },
              },
            ],
          },
        },
      };

      writePayload(store, writer, query, payload);
      connectionID = store.getLinkedRecordID(feedbackID, 'topLevelComments');
      edgeID = generateClientEdgeID(connectionID, commentID);
    });

    it('handles case when created `edge` field is missing in payload', () => {
      const input = {
        actor_id: 'actor:123',
        [RelayConnectionInterface.CLIENT_MUTATION_ID]: '0',
        feedback_id: feedbackID,
      };
      const mutation = getNode(Relay.QL`
        mutation {
          commentCreate(input:$input) {
            feedback {
              id
              topLevelComments {
                count
              }
            }
          }
        }
      `, {input: JSON.stringify(input)}
      );
      const configs = [{
        type: RelayMutationType.RANGE_ADD,
        connectionName: 'topLevelComments',
        edgeName: 'feedbackCommentEdge',
        rangeBehaviors: {'': GraphQLMutatorConstants.PREPEND},
      }];
      const payload = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]:
          input[RelayConnectionInterface.CLIENT_MUTATION_ID],
        feedback: {
          id: feedbackID,
          topLevelComments: {
            count: 2,
          },
        },
      };

      // write to queued store
      const changeTracker = new RelayChangeTracker();
      const queryTracker = new RelayQueryTracker();
      const queryWriter = new RelayQueryWriter(
        queueStore,
        queueWriter,
        queryTracker,
        changeTracker,
        {isOptimisticUpdate: true}
      );

      writeRelayUpdatePayload(
        queryWriter,
        mutation,
        payload,
        {configs, isOptimisticUpdate: true}
      );

      // feedback is updated, but the edge is not added
      expect(queueStore.getField(connectionID, 'count')).toBe(2);
      expect(queueStore.getRangeMetadata(
        connectionID,
        [{name: 'first', value: '2'}]
      ).filteredEdges.map(edge => edge.edgeID)).toEqual([edgeID]);
    });

    it('warns when using null as a rangeBehavior value instead of IGNORE', () => {
      const input = {
        actor_id: 'actor:123',
        [RelayConnectionInterface.CLIENT_MUTATION_ID]: '0',
        feedback_id: feedbackID,
        message: {
          text: 'Hello!',
          ranges: [],
        },
      };

      const mutation = getNode(Relay.QL`
        mutation {
          commentCreate(input:$input) {
            feedback {
              id
              topLevelComments {
                count
              }
            }
            feedbackCommentEdge {
              cursor
              node {
                id
                body {
                  text
                }
              }
              source {
                id
              }
            }
          }
        }
      `, {
        input: JSON.stringify(input),
      });
      const configs = [{
        type: RelayMutationType.RANGE_ADD,
        connectionName: 'topLevelComments',
        edgeName: 'feedbackCommentEdge',
        rangeBehaviors: {'': null},
      }];

      const nextCursor = 'comment789:cursor';
      const nextNodeID = 'comment789';
      const payload = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]:
          input[RelayConnectionInterface.CLIENT_MUTATION_ID],
        feedback: {
          id: feedbackID,
          topLevelComments: {
            count: 2,
          },
        },
        feedbackCommentEdge: {
          cursor: nextCursor,
          node: {
            id: nextNodeID,
            body: {
              text: input.message.text,
            },
          },
          source: {
            id: feedbackID,
          },
        },
      };

      const changeTracker = new RelayChangeTracker();
      const queryTracker = new RelayQueryTracker();
      const queryWriter = new RelayQueryWriter(
        queueStore,
        queueWriter,
        queryTracker,
        changeTracker,
        {isOptimisticUpdate: true}
      );

      writeRelayUpdatePayload(
        queryWriter,
        mutation,
        payload,
        {configs, isOptimisticUpdate: true}
      );

      expect([
        'Using `null` as a rangeBehavior value is deprecated. Use `ignore` to avoid ' +
        'refetching a range.',
      ]).toBeWarnedNTimes(1);
    });

    it('ignores node when rangeBehavior value is IGNORE', () => {
      const input = {
        actor_id: 'actor:123',
        [RelayConnectionInterface.CLIENT_MUTATION_ID]: '0',
        feedback_id: feedbackID,
        message: {
          text: 'Hello!',
          ranges: [],
        },
      };

      const mutation = getNode(Relay.QL`
        mutation {
          commentCreate(input:$input) {
            feedback {
              id
              topLevelComments {
                count
              }
            }
            feedbackCommentEdge {
              cursor
              node {
                id
                body {
                  text
                }
              }
              source {
                id
              }
            }
          }
        }
      `, {
        input: JSON.stringify(input),
      });
      const configs = [{
        type: RelayMutationType.RANGE_ADD,
        connectionName: 'topLevelComments',
        edgeName: 'feedbackCommentEdge',
        rangeBehaviors: {'': GraphQLMutatorConstants.IGNORE},
      }];

      const nextCursor = 'comment789:cursor';
      const nextNodeID = 'comment789';
      const payload = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]:
          input[RelayConnectionInterface.CLIENT_MUTATION_ID],
        feedback: {
          id: feedbackID,
          topLevelComments: {
            count: 2,
          },
        },
        feedbackCommentEdge: {
          cursor: nextCursor,
          node: {
            id: nextNodeID,
            body: {
              text: input.message.text,
            },
          },
          source: {
            id: feedbackID,
          },
        },
      };

      const changeTracker = new RelayChangeTracker();
      const queryTracker = new RelayQueryTracker();
      const queryWriter = new RelayQueryWriter(
        queueStore,
        queueWriter,
        queryTracker,
        changeTracker,
        {isOptimisticUpdate: true}
      );

      writeRelayUpdatePayload(
        queryWriter,
        mutation,
        payload,
        {configs, isOptimisticUpdate: true}
      );

      expect(changeTracker.getChangeSet()).toEqual({
        created: {}, // No node added
        updated: {
          [connectionID]: true,
        },
      });
    });

    it('optimistically prepends comments', () => {
      // create the mutation and payload
      const input = {
        actor_id: 'actor:123',
        [RelayConnectionInterface.CLIENT_MUTATION_ID]: '0',
        feedback_id: feedbackID,
        message: {
          text: 'Hello!',
          ranges: [],
        },
      };

      const mutation = getNode(Relay.QL`
        mutation {
          commentCreate(input:$input) {
            feedback {
              id
              topLevelComments {
                count
              }
            }
            feedbackCommentEdge {
              cursor
              node {
                id
                body {
                  text
                }
              }
              source {
                id
              }
            }
          }
        }
      `, {
        input: JSON.stringify(input),
      });
      const configs = [{
        type: RelayMutationType.RANGE_ADD,
        connectionName: 'topLevelComments',
        edgeName: 'feedbackCommentEdge',
        rangeBehaviors: {'': GraphQLMutatorConstants.PREPEND},
      }];

      const nextCursor = 'comment789:cursor';
      const nextNodeID = 'comment789';
      const bodyID = 'client:2';
      const nextEdgeID = generateClientEdgeID(connectionID, nextNodeID);
      const payload = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]:
          input[RelayConnectionInterface.CLIENT_MUTATION_ID],
        feedback: {
          id: feedbackID,
          topLevelComments: {
            count: 2,
          },
        },
        feedbackCommentEdge: {
          __typename: 'CommentsEdge',
          cursor: nextCursor,
          node: {
            id: nextNodeID,
            body: {
              text: input.message.text,
            },
          },
          source: {
            id: feedbackID,
          },
        },
      };

      // write to queued store
      const changeTracker = new RelayChangeTracker();
      const queryTracker = new RelayQueryTracker();
      const queryWriter = new RelayQueryWriter(
        queueStore,
        queueWriter,
        queryTracker,
        changeTracker,
        {isOptimisticUpdate: true}
      );

      writeRelayUpdatePayload(
        queryWriter,
        mutation,
        payload,
        {configs, isOptimisticUpdate: true}
      );

      expect(changeTracker.getChangeSet()).toEqual({
        created: {
          [nextNodeID]: true, // node added
          [nextEdgeID]: true, // edge added
          [bodyID]: true, // `body` subfield
        },
        updated: {
          [connectionID]: true, // range item added & count changed
        },
      });

      // queued records are updated: edge/node added
      expect(queueStore.getField(connectionID, 'count')).toBe(2);
      expect(queueStore.getLinkedRecordID(nextEdgeID, 'source')).toBe(
        feedbackID
      );
      expect(queueStore.getField(nextEdgeID, 'cursor')).toBe(nextCursor);
      expect(queueStore.getLinkedRecordID(nextEdgeID, 'node')).toBe(nextNodeID);
      expect(queueStore.getField(nextNodeID, 'id')).toBe(nextNodeID);
      expect(queueStore.getLinkedRecordID(nextNodeID, 'body')).toBe(bodyID);
      expect(queueStore.getField(bodyID, 'text')).toBe(input.message.text);
      expect(queueStore.getRangeMetadata(
        connectionID,
        [{name: 'first', value: '2'}]
      ).filteredEdges.map(edge => edge.edgeID)).toEqual([
        nextEdgeID,
        edgeID,
      ]);

      // base records are not modified
      expect(store.getField(connectionID, 'count')).toBe(1);
      expect(store.getRecordState(nextEdgeID)).toBe('UNKNOWN');
      expect(store.getRecordState(nextNodeID)).toBe('UNKNOWN');
      expect(store.getRecordState(bodyID)).toBe('UNKNOWN');
      expect(store.getRangeMetadata(
        connectionID,
        [{name: 'first', value: '2'}]
      ).filteredEdges.map(edge => edge.edgeID)).toEqual([
        edgeID,
      ]);
    });

    it('non-optimistically prepends comments', () => {
      // create the mutation and payload
      const input = {
        actor_id: 'actor:123',
        [RelayConnectionInterface.CLIENT_MUTATION_ID]: '0',
        feedback_id: feedbackID,
        message: {
          text: 'Hello!',
          ranges: [],
        },
      };

      const mutation = getNode(Relay.QL`
        mutation {
          commentCreate(input:$input) {
            feedback {
              id
              topLevelComments {
                count
              }
            }
            feedbackCommentEdge {
              cursor
              node {
                id
                body {
                  text
                }
              }
              source {
                id
              }
            }
          }
        }
      `, {
        input: JSON.stringify(input),
      });
      const configs = [{
        type: RelayMutationType.RANGE_ADD,
        connectionName: 'topLevelComments',
        edgeName: 'feedbackCommentEdge',
        rangeBehaviors: {'': GraphQLMutatorConstants.PREPEND},
      }];

      const nextCursor = 'comment789:cursor';
      const nextNodeID = 'comment789';
      const bodyID = 'client:2';
      const nextEdgeID = generateClientEdgeID(connectionID, nextNodeID);
      const payload = {
        [RelayConnectionInterface.CLIENT_MUTATION_ID]:
          input[RelayConnectionInterface.CLIENT_MUTATION_ID],
        feedback: {
          id: feedbackID,
          topLevelComments: {
            count: 2,
          },
        },
        feedbackCommentEdge: {
          __typename: 'CommentsEdge',
          cursor: nextCursor,
          node: {
            id: nextNodeID,
            body: {
              text: input.message.text,
            },
          },
          source: {
            id: feedbackID,
          },
        },
      };

      // write to base store
      const changeTracker = new RelayChangeTracker();
      const queryTracker = new RelayQueryTracker();
      const queryWriter = new RelayQueryWriter(
        store,
        writer,
        queryTracker,
        changeTracker
      );

      writeRelayUpdatePayload(
        queryWriter,
        mutation,
        payload,
        {configs, isOptimisticUpdate: false}
      );

      expect(changeTracker.getChangeSet()).toEqual({
        created: {
          [nextNodeID]: true, // node added
          [nextEdgeID]: true, // edge added
          [bodyID]: true, // `body` subfield
        },
        updated: {
          [connectionID]: true, // range item added & count changed
        },
      });

      // base records are updated: edge/node added
      expect(store.getField(connectionID, 'count')).toBe(2);
      expect(store.getLinkedRecordID(nextEdgeID, 'source')).toBe(
        feedbackID
      );
      expect(store.getField(nextEdgeID, 'cursor')).toBe(nextCursor);
      expect(store.getLinkedRecordID(nextEdgeID, 'node')).toBe(nextNodeID);
      expect(store.getField(nextNodeID, 'id')).toBe(nextNodeID);
      expect(store.getType(nextNodeID)).toBe('Comment');
      expect(store.getLinkedRecordID(nextNodeID, 'body')).toBe(bodyID);
      expect(store.getField(bodyID, 'text')).toBe(input.message.text);
      expect(store.getRangeMetadata(
        connectionID,
        [{name: 'first', value: '2'}]
      ).filteredEdges.map(edge => edge.edgeID)).toEqual([
        nextEdgeID,
        edgeID,
      ]);
    });
  });
});