import { ApolloLink , execute , Observable , Operation , FetchResult , createOperation , GraphQLRequest , } from 'apollo-link' ; import gql from 'graphql-tag' ; import { print } from 'graphql/language/printer' ; import { BatchLink , OperationBatcher , BatchHandler , BatchableRequest , } from '../batchLink' ; interface MockedResponse { request : GraphQLRequest ; result ? : FetchResult ; error ? : Error ; delay ? : number ; } const terminatingCheck = ( done , body ) => { return ( ... args ) => { try { body ( ... args ) ; done ( ) ; } catch ( error ) { done . fail ( error ) ; } } ; } ; function requestToKey ( request ) { const queryString = typeof request . query === 'string' ? request . query : print ( request . query ) ; return JSON . stringify ( { variables : request . variables || { } , query : queryString , } ) ; } function createMockBatchHandler ( ... mockedResponses ) { const mockedResponsesByKey = { } ; const mockBatchHandler = ( operations ) => { return new Observable ( observer => { const results = operations . map ( operation => { const key = requestToKey ( operation ) ; const responses = mockedResponsesByKey [ key ] ; if ( ! responses || responses . length === 0 ) { throw new Error ( `No more mocked responses for the query: ${ print ( operation . query ) }, variables: ${ JSON . stringify ( operation . variables ) }` ) ; } const { result , error } = responses . shift ( ) ! ; if ( ! result && ! error ) { throw new Error ( `Mocked response should contain either result or error: ${ key }` ) ; } if ( error ) { observer . error ( error ) ; } return result ; } ) ; observer . next ( results ) ; } ) ; } ; ( mockBatchHandler as any ) . addMockedResponse = ( mockedResponse ) => { const key = requestToKey ( mockedResponse . request ) ; let _mockedResponses = mockedResponsesByKey [ key ] ; if ( ! _mockedResponses ) { _mockedResponses = [ ] ; mockedResponsesByKey [ key ] = _mockedResponses ; } _mockedResponses . push ( mockedResponse ) ; } ; mockedResponses . map ( ( mockBatchHandler as any ) . addMockedResponse ) ; return mockBatchHandler ; } describe ( 'OperationBatcher' , ( ) => { it ( 'should construct' , ( ) => { expect ( ( ) => { const querySched = new OperationBatcher ( { batchInterval : 10 , batchHandler : ( ) => null , } ) ; querySched . consumeQueue ( '' ) ; } ) . not . toThrow ( ) ; } ) ; it ( 'should not do anything when faced with an empty queue' , ( ) => { const batcher = new OperationBatcher ( { batchInterval : 10 , batchHandler : ( ) => { return null ; } , batchKey : ( ) => 'yo' , } ) ; expect ( batcher . queuedRequests . get ( '' ) ) . toBeUndefined ( ) ; expect ( batcher . queuedRequests . get ( 'yo' ) ) . toBeUndefined ( ) ; batcher . consumeQueue ( ) ; expect ( batcher . queuedRequests . get ( '' ) ) . toBeUndefined ( ) ; expect ( batcher . queuedRequests . get ( 'yo' ) ) . toBeUndefined ( ) ; } ) ; it ( 'should be able to add to the queue' , ( ) => { const batcher = new OperationBatcher ( { batchInterval : 10 , batchHandler : ( ) => { return null ; } , } ) ; const query = gql `
      query {
        author {
          firstName
          lastName
        }
      }
    ` ; const request = { operation : createOperation ( { } , { query } ) , } ; expect ( batcher . queuedRequests . get ( '' ) ) . toBeUndefined ( ) ; batcher . enqueueRequest ( request ) . subscribe ( { } ) ; expect ( batcher . queuedRequests . get ( '' ) . length ) . toBe ( 1 ) ; batcher . enqueueRequest ( request ) . subscribe ( { } ) ; expect ( batcher . queuedRequests . get ( '' ) . length ) . toBe ( 2 ) ; } ) ; describe ( 'request queue' , ( ) => { const query = gql `
      query {
        author {
          firstName
          lastName
        }
      }
    ` ; const data = { author : { firstName : 'John' , lastName : 'Smith' , } , } ; const batchHandler = createMockBatchHandler ( { request : { query } , result : { data } , } , { request : { query } , result : { data } , } ) ; const operation = createOperation ( { } , { query , } ) ; it ( 'should be able to consume from a queue containing a single query' , done => { const myBatcher = new OperationBatcher ( { batchInterval : 10 , batchHandler , } ) ; myBatcher . enqueueRequest ( { operation } ) . subscribe ( terminatingCheck ( done , resultObj => { expect ( myBatcher . queuedRequests . get ( '' ) ) . toBeUndefined ( ) ; expect ( resultObj ) . toEqual ( { data } ) ; } ) ) ; const observables = myBatcher . consumeQueue ( ) ! ; try { expect ( observables . length ) . toBe ( 1 ) ; } catch ( e ) { done . fail ( e ) ; } } ) ; it ( 'should be able to consume from a queue containing multiple queries' , done => { const request2 = createOperation ( { } , { query , } ) ; const BH = createMockBatchHandler ( { request : { query } , result : { data } , } , { request : { query } , result : { data } , } ) ; const myBatcher = new OperationBatcher ( { batchInterval : 10 , batchMax : 10 , batchHandler : BH , } ) ; const observable1 = myBatcher . enqueueRequest ( { operation } ) ; const observable2 = myBatcher . enqueueRequest ( { operation : request2 } ) ; let notify = false ; observable1 . subscribe ( resultObj1 => { try { expect ( resultObj1 ) . toEqual ( { data } ) ; } catch ( e ) { done . fail ( e ) ; } if ( notify ) { done ( ) ; } else { notify = true ; } } ) ; observable2 . subscribe ( resultObj2 => { try { expect ( resultObj2 ) . toEqual ( { data } ) ; } catch ( e ) { done . fail ( e ) ; } if ( notify ) { done ( ) ; } else { notify = true ; } } ) ; try { expect ( myBatcher . queuedRequests . get ( '' ) . length ) . toBe ( 2 ) ; const observables = myBatcher . consumeQueue ( ) ! ; expect ( myBatcher . queuedRequests . get ( '' ) ) . toBeUndefined ( ) ; expect ( observables . length ) . toBe ( 2 ) ; } catch ( e ) { done . fail ( e ) ; } } ) ; it ( 'should return a promise when we enqueue a request and resolve it with a result' , done => { const BH = createMockBatchHandler ( { request : { query } , result : { data } , } ) ; const myBatcher = new OperationBatcher ( { batchInterval : 10 , batchHandler : BH , } ) ; const observable = myBatcher . enqueueRequest ( { operation } ) ; observable . subscribe ( terminatingCheck ( done , result => { expect ( result ) . toEqual ( { data } ) ; } ) ) ; myBatcher . consumeQueue ( ) ; } ) ; } ) ; it ( 'should work when single query' , done => { const data = { lastName : 'Ever' , firstName : 'Greatest' , } ; const batcher = new OperationBatcher ( { batchInterval : 10 , batchHandler : ( ) => new Observable ( observer => { observer . next ( [ { data } ] ) ; setTimeout ( observer . complete . bind ( observer ) ) ; } ) , } ) ; const query = gql `
      query {
        author {
          firstName
          lastName
        }
      }
    ` ; const operation = createOperation ( { } , { query } ) ; batcher . enqueueRequest ( { operation } ) . subscribe ( { } ) ; try { expect ( batcher . queuedRequests . get ( '' ) . length ) . toBe ( 1 ) ; } catch ( e ) { done . fail ( e ) ; } setTimeout ( terminatingCheck ( done , ( ) => { expect ( batcher . queuedRequests . get ( '' ) ) . toBeUndefined ( ) ; } ) , 20 ) ; } ) ; it ( 'should correctly batch multiple queries' , done => { const data = { lastName : 'Ever' , firstName : 'Greatest' , } ; const data2 = { lastName : 'Hauser' , firstName : 'Evans' , } ; const batcher = new OperationBatcher ( { batchInterval : 10 , batchHandler : ( ) => new Observable ( observer => { observer . next ( [ { data } , { data : data2 } , { data } ] ) ; setTimeout ( observer . complete . bind ( observer ) ) ; } ) , } ) ; const query = gql `
      query {
        author {
          firstName
          lastName
        }
      }
    ` ; const operation = createOperation ( { } , { query } ) ; const operation2 = createOperation ( { } , { query } ) ; const operation3 = createOperation ( { } , { query } ) ; batcher . enqueueRequest ( { operation } ) . subscribe ( { } ) ; batcher . enqueueRequest ( { operation : operation2 } ) . subscribe ( { } ) ; try { expect ( batcher . queuedRequests . get ( '' ) . length ) . toBe ( 2 ) ; } catch ( e ) { done . fail ( e ) ; } setTimeout ( ( ) => { batcher . enqueueRequest ( { operation : operation3 } ) . subscribe ( { } ) ; try { expect ( batcher . queuedRequests . get ( '' ) . length ) . toBe ( 3 ) ; } catch ( e ) { done . fail ( e ) ; } } , 5 ) ; setTimeout ( terminatingCheck ( done , ( ) => { expect ( batcher . queuedRequests . get ( '' ) ) . toBeUndefined ( ) ; } ) , 20 ) ; } ) ; it ( 'should reject the promise if there is a network error' , done => { const query = gql `
      query {
        author {
          firstName
          lastName
        }
      }
    ` ; const operation = createOperation ( { } , { query } ) ; const error = new Error ( 'Network error' ) ; const BH = createMockBatchHandler ( { request : { query } , error , } ) ; const batcher = new OperationBatcher ( { batchInterval : 10 , batchHandler : BH , } ) ; const observable = batcher . enqueueRequest ( { operation } ) ; observable . subscribe ( { error : terminatingCheck ( done , ( resError ) => { expect ( resError . message ) . toBe ( 'Network error' ) ; } ) , } ) ; batcher . consumeQueue ( ) ; } ) ; } ) ; describe ( 'BatchLink' , ( ) => { const query = gql `
    {
      id
    }
  ` ; it ( 'does not need any constructor arguments' , ( ) => { expect ( ( ) => new BatchLink ( { batchHandler : ( ) => Observable . of ( ) } ) ) . not . toThrow ( ) ; } ) ; it ( 'passes forward on' , done => { const link = ApolloLink . from ( [ new BatchLink ( { batchInterval : 0 , batchMax : 1 , batchHandler : ( operation , forward ) => { try { expect ( forward . length ) . toBe ( 1 ) ; expect ( operation . length ) . toBe ( 1 ) ; } catch ( e ) { done . fail ( e ) ; } return forward [ 0 ] ( operation [ 0 ] ) . map ( result => [ result ] ) ; } , } ) , new ApolloLink ( operation => { terminatingCheck ( done , ( ) => { expect ( operation . query ) . toEqual ( query ) ; } ) ( ) ; return null ; } ) , ] ) ; execute ( link , createOperation ( { } , { query , } ) ) . subscribe ( result => done . fail ( ) ) ; } ) ; it ( 'raises warning if terminating' , ( ) => { let calls = 0 ; const link_full = new BatchLink ( { batchHandler : ( operation , forward ) => forward [ 0 ] ( operation [ 0 ] ) . map ( r => [ r ] ) , } ) ; const link_one_op = new BatchLink ( { batchHandler : operation => Observable . of ( ) , } ) ; const link_no_op = new BatchLink ( { batchHandler : ( ) => Observable . of ( ) } ) ; const _warn = console . warn ; console . warn = warning => { calls ++ ; expect ( warning . message ) . toBeDefined ( ) ; } ; expect ( link_one_op . concat ( ( operation , forward ) => forward ( operation ) ) ) . toEqual ( link_one_op ) ; expect ( link_no_op . concat ( ( operation , forward ) => forward ( operation ) ) ) . toEqual ( link_no_op ) ; console . warn = warning => { throw Error ( 'non-terminating link should not throw' ) ; } ; expect ( link_full . concat ( ( operation , forward ) => forward ( operation ) ) ) . not . toEqual ( link_full ) ; console . warn = _warn ; expect ( calls ) . toBe ( 2 ) ; } ) ; it ( 'correctly uses batch size' , done => { const sizes = [ 1 , 2 , 3 ] ; const terminating = new ApolloLink ( operation => { try { expect ( operation . query ) . toEqual ( query ) ; } catch ( e ) { done . fail ( e ) ; } return Observable . of ( operation . variables . count ) ; } ) ; let runBatchSize = ( ) => { const size = sizes . pop ( ) ; if ( ! size ) done ( ) ; const batchHandler = jest . fn ( ( operation , forward ) => { try { expect ( operation . length ) . toBe ( size ) ; expect ( forward . length ) . toBe ( size ) ; } catch ( e ) { done . fail ( e ) ; } const observables = forward . map ( ( f , i ) => f ( operation [ i ] ) ) ; return new Observable ( observer => { const data = [ ] ; observables . forEach ( obs => obs . subscribe ( d => { data . push ( d ) ; if ( data . length === observables . length ) { observer . next ( data ) ; observer . complete ( ) ; } } ) ) ; } ) ; } ) ; const link = ApolloLink . from ( [ new BatchLink ( { batchInterval : 1000 , batchMax : size , batchHandler , } ) , terminating , ] ) ; Array . from ( new Array ( size ) ) . forEach ( ( _ , i ) => { execute ( link , { query , variables : { count : i } , } ) . subscribe ( { next : data => { expect ( data ) . toBe ( i ) ; } , complete : ( ) => { try { expect ( batchHandler . mock . calls . length ) . toBe ( 1 ) ; } catch ( e ) { done . fail ( e ) ; } runBatchSize ( ) ; } , } ) ; } ) ; } ; runBatchSize ( ) ; } ) ; it ( 'correctly follows batch interval' , done => { const intervals = [ 10 , 20 , 30 ] ; const runBatchInterval = ( ) => { const mock = jest . fn ( ) ; const batchInterval = intervals . pop ( ) ; if ( ! batchInterval ) return done ( ) ; const batchHandler = jest . fn ( ( operation , forward ) => { try { expect ( operation . length ) . toBe ( 1 ) ; expect ( forward . length ) . toBe ( 1 ) ; } catch ( e ) { done . fail ( e ) ; } return forward [ 0 ] ( operation [ 0 ] ) . map ( d => [ d ] ) ; } ) ; const link = ApolloLink . from ( [ new BatchLink ( { batchInterval , batchMax : 0 , batchHandler , } ) , ( ) => Observable . of ( 42 ) , ] ) ; execute ( link , createOperation ( { } , { query , } ) ) . subscribe ( { next : data => { try { expect ( data ) . toBe ( 42 ) ; } catch ( e ) { done . fail ( e ) ; } } , complete : ( ) => { mock ( batchHandler . mock . calls . length ) ; } , } ) ; setTimeout ( ( ) => { const checkCalls = mock . mock . calls . slice ( 0 , - 1 ) ; try { expect ( checkCalls . length ) . toBe ( 2 ) ; checkCalls . forEach ( args => expect ( args [ 0 ] ) . toBe ( 0 ) ) ; expect ( mock ) . lastCalledWith ( 1 ) ; expect ( batchHandler . mock . calls . length ) . toBe ( 1 ) ; } catch ( e ) { done . fail ( e ) ; } runBatchInterval ( ) ; } , batchInterval + 5 ) ; setTimeout ( ( ) => mock ( batchHandler . mock . calls . length ) , batchInterval - 5 ) ; setTimeout ( ( ) => mock ( batchHandler . mock . calls . length ) , batchInterval / 2 ) ; } ; runBatchInterval ( ) ; } ) ; it ( 'throws an error when more requests than results' , done => { const result = [ { data : { } } ] ; const batchHandler = jest . fn ( op => Observable . of ( result ) ) ; const link = ApolloLink . from ( [ new BatchLink ( { batchInterval : 10 , batchMax : 2 , batchHandler , } ) , ] ) ; [ 1 , 2 ] . forEach ( x => { execute ( link , { query , } ) . subscribe ( { next : data => { done . fail ( 'next should not be called' ) ; } , error : terminatingCheck ( done , error => { expect ( error ) . toBeDefined ( ) ; expect ( error . result ) . toEqual ( result ) ; } ) , complete : ( ) => { done . fail ( 'complete should not be called' ) ; } , } ) ; } ) ; } ) ; describe ( 'batchKey' , ( ) => { it ( 'should allow different batches to be created separately' , done => { const data = { data : { } } ; const result = [ data , data ] ; const batchHandler = jest . fn ( op => { try { expect ( op . length ) . toBe ( 2 ) ; } catch ( e ) { done . fail ( e ) ; } return Observable . of ( result ) ; } ) ; let key = true ; const batchKey = ( ) => { key = ! key ; return '' + ! key ; } ; const link = ApolloLink . from ( [ new BatchLink ( { batchInterval : 1 , batchMax : 3 , batchHandler , batchKey , } ) , ] ) ; let count = 0 ; [ 1 , 2 , 3 , 4 ] . forEach ( x => { execute ( link , { query , } ) . subscribe ( { next : d => { try { expect ( d ) . toEqual ( data ) ; } catch ( e ) { done . fail ( e ) ; } } , error : done . fail , complete : ( ) => { count ++ ; if ( count === 4 ) { try { expect ( batchHandler . mock . calls . length ) . toBe ( 2 ) ; done ( ) ; } catch ( e ) { done . fail ( e ) ; } } } , } ) ; } ) ; } ) ; } ) ; } ) ;