import { expect } from 'chai';
import { stub } from 'sinon';
import 'mocha';

import {
  GraphQLSchema,
  GraphQLObjectType,
  GraphQLString,
  GraphQLInt,
  GraphQLError,
  GraphQLNonNull,
  introspectionQuery,
  BREAK,
} from 'graphql';

// tslint:disable-next-line
const request = require('supertest');

import { GraphQLOptions } from 'apollo-server-core';
import * as GraphiQL from 'apollo-server-module-graphiql';
import { OperationStore } from 'apollo-server-module-operation-store';

const personType = new GraphQLObjectType({
  name: 'PersonType',
  fields: {
    firstName: {
      type: GraphQLString,
    },
    lastName: {
      type: GraphQLString,
    },
  },
});

const queryType = new GraphQLObjectType({
  name: 'QueryType',
  fields: {
    testString: {
      type: GraphQLString,
      resolve() {
        return 'it works';
      },
    },
    testPerson: {
      type: personType,
      resolve() {
        return { firstName: 'Jane', lastName: 'Doe' };
      },
    },
    testStringWithDelay: {
      type: GraphQLString,
      args: {
        delay: { type: new GraphQLNonNull(GraphQLInt) },
      },
      resolve(root, args) {
        return new Promise((resolve, reject) => {
          setTimeout(() => resolve('it works'), args['delay']);
        });
      },
    },
    testContext: {
      type: GraphQLString,
      resolve(_, args, context) {
        if (context.otherField) {
          return 'unexpected';
        }
        context.otherField = true;
        return context.testField;
      },
    },
    testRootValue: {
      type: GraphQLString,
      resolve(rootValue) {
        return rootValue;
      },
    },
    testArgument: {
      type: GraphQLString,
      args: { echo: { type: GraphQLString } },
      resolve(root, { echo }) {
        return `hello ${echo}`;
      },
    },
    testError: {
      type: GraphQLString,
      resolve() {
        throw new Error('Secret error message');
      },
    },
  },
});

const mutationType = new GraphQLObjectType({
  name: 'MutationType',
  fields: {
    testMutation: {
      type: GraphQLString,
      args: { echo: { type: GraphQLString } },
      resolve(root, { echo }) {
        return `not really a mutation, but who cares: ${echo}`;
      },
    },
    testPerson: {
      type: personType,
      args: {
        firstName: {
          type: new GraphQLNonNull(GraphQLString),
        },
        lastName: {
          type: new GraphQLNonNull(GraphQLString),
        },
      },
      resolve(root, args) {
        return args;
      },
    },
  },
});

export const schema = new GraphQLSchema({
  query: queryType,
  mutation: mutationType,
});

export interface CreateAppOptions {
  excludeParser?: boolean;
  graphqlOptions?:
    | GraphQLOptions
    | { (): GraphQLOptions | Promise<GraphQLOptions> };
  graphiqlOptions?:
    | GraphiQL.GraphiQLData
    | { (): GraphiQL.GraphiQLData | Promise<GraphiQL.GraphiQLData> };
}

export interface CreateAppFunc {
  (options?: CreateAppOptions): any | Promise<any>;
}

export interface DestroyAppFunc {
  (app: any): void | Promise<void>;
}

export default (createApp: CreateAppFunc, destroyApp?: DestroyAppFunc) => {
  describe('apolloServer', () => {
    let app;

    afterEach(async () => {
      if (app) {
        if (destroyApp) {
          await destroyApp(app);
          app = null;
        } else {
          app = null;
        }
      }
    });

    describe('graphqlHTTP', () => {
      it('can be called with an options function', async () => {
        app = await createApp({
          graphqlOptions: (): GraphQLOptions => ({ schema }),
        });
        const expected = {
          testString: 'it works',
        };
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test{ testString }',
          });
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body.data).to.deep.equal(expected);
        });
      });

      it('can be called with an options function that returns a promise', async () => {
        app = await createApp({
          graphqlOptions: () => {
            return new Promise(resolve => {
              resolve({ schema });
            });
          },
        });
        const expected = {
          testString: 'it works',
        };
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test{ testString }',
          });
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body.data).to.deep.equal(expected);
        });
      });

      it('throws an error if options promise is rejected', async () => {
        app = await createApp({
          graphqlOptions: () => {
            return (Promise.reject({}) as any) as GraphQLOptions;
          },
        });
        const expected = 'Invalid options';
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test{ testString }',
          });
        return req.then(res => {
          expect(res.status).to.equal(500);
          return expect(res.error.text).to.contain(expected);
        });
      });

      it('rejects the request if the method is not POST or GET', async () => {
        app = await createApp({ excludeParser: true });
        const req = request(app)
          .head('/graphql')
          .send();
        return req.then(res => {
          expect(res.status).to.equal(405);
          expect(res.headers['allow']).to.equal('GET, POST');
        });
      });

      it('throws an error if POST body is missing', async () => {
        app = await createApp({ excludeParser: true });
        const req = request(app)
          .post('/graphql')
          .send();
        return req.then(res => {
          expect(res.status).to.equal(500);
          return expect(res.error.text).to.contain('POST body missing.');
        });
      });

      it('throws an error if GET query is missing', async () => {
        app = await createApp();
        const req = request(app).get(`/graphql`);
        return req.then(res => {
          expect(res.status).to.equal(400);
          return expect(res.error.text).to.contain('GET query missing.');
        });
      });

      it('can handle a basic GET request', async () => {
        app = await createApp();
        const expected = {
          testString: 'it works',
        };
        const query = {
          query: 'query test{ testString }',
        };
        const req = request(app)
          .get('/graphql')
          .query(query);
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body.data).to.deep.equal(expected);
        });
      });

      it('can handle a basic implicit GET request', async () => {
        app = await createApp();
        const expected = {
          testString: 'it works',
        };
        const query = {
          query: '{ testString }',
        };
        const req = request(app)
          .get('/graphql')
          .query(query);
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body.data).to.deep.equal(expected);
        });
      });

      it('throws error if trying to use mutation using GET request', async () => {
        app = await createApp();
        const query = {
          query: 'mutation test{ testMutation(echo: "ping") }',
        };
        const req = request(app)
          .get('/graphql')
          .query(query);
        return req.then(res => {
          expect(res.status).to.equal(405);
          expect(res.headers['allow']).to.equal('POST');
          return expect(res.error.text).to.contain(
            'GET supports only query operation',
          );
        });
      });

      it('throws error if trying to use mutation with fragment using GET request', async () => {
        app = await createApp();
        const query = {
          query: `fragment PersonDetails on PersonType {
              firstName
            }

            mutation test {
              testPerson(firstName: "Test", lastName: "Me") {
                ...PersonDetails
              }
            }`,
        };
        const req = request(app)
          .get('/graphql')
          .query(query);
        return req.then(res => {
          expect(res.status).to.equal(405);
          expect(res.headers['allow']).to.equal('POST');
          return expect(res.error.text).to.contain(
            'GET supports only query operation',
          );
        });
      });

      it('can handle a GET request with variables', async () => {
        app = await createApp();
        const query = {
          query: 'query test($echo: String){ testArgument(echo: $echo) }',
          variables: JSON.stringify({ echo: 'world' }),
        };
        const expected = {
          testArgument: 'hello world',
        };
        const req = request(app)
          .get('/graphql')
          .query(query);
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body.data).to.deep.equal(expected);
        });
      });

      it('can handle a basic request', async () => {
        app = await createApp();
        const expected = {
          testString: 'it works',
        };
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test{ testString }',
          });
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body.data).to.deep.equal(expected);
        });
      });

      it('can handle a basic request with cacheControl', async () => {
        app = await createApp({
          graphqlOptions: { schema, cacheControl: true },
        });
        const expected = {
          testPerson: { firstName: 'Jane' },
        };
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test{ testPerson { firstName } }',
          });
        return req.then(res => {
          expect(res.status).to.equal(200);
          expect(res.body.data).to.deep.equal(expected);
          return expect(res.body.extensions).to.deep.equal({
            cacheControl: {
              version: 1,
              hints: [{ maxAge: 0, path: ['testPerson'] }],
            },
          });
        });
      });

      it('can handle a basic request with cacheControl and defaultMaxAge', async () => {
        app = await createApp({
          graphqlOptions: { schema, cacheControl: { defaultMaxAge: 5 } },
        });
        const expected = {
          testPerson: { firstName: 'Jane' },
        };
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test{ testPerson { firstName } }',
          });
        return req.then(res => {
          expect(res.status).to.equal(200);
          expect(res.body.data).to.deep.equal(expected);
          return expect(res.body.extensions).to.deep.equal({
            cacheControl: {
              version: 1,
              hints: [{ maxAge: 5, path: ['testPerson'] }],
            },
          });
        });
      });

      it('can handle a request with variables', async () => {
        app = await createApp();
        const expected = {
          testArgument: 'hello world',
        };
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test($echo: String){ testArgument(echo: $echo) }',
            variables: { echo: 'world' },
          });
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body.data).to.deep.equal(expected);
        });
      });

      it('can handle a request with variables as string', async () => {
        app = await createApp();
        const expected = {
          testArgument: 'hello world',
        };
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test($echo: String!){ testArgument(echo: $echo) }',
            variables: '{ "echo": "world" }',
          });
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body.data).to.deep.equal(expected);
        });
      });

      it('can handle a request with variables as an invalid string', async () => {
        app = await createApp();
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test($echo: String!){ testArgument(echo: $echo) }',
            variables: '{ echo: "world" }',
          });
        return req.then(res => {
          expect(res.status).to.equal(400);
          return expect(res.error.text).to.contain(
            'Variables are invalid JSON.',
          );
        });
      });

      it('can handle a request with operationName', async () => {
        app = await createApp();
        const expected = {
          testString: 'it works',
        };
        const req = request(app)
          .post('/graphql')
          .send({
            query: `
                      query test($echo: String){ testArgument(echo: $echo) }
                      query test2{ testString }`,
            variables: { echo: 'world' },
            operationName: 'test2',
          });
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body.data).to.deep.equal(expected);
        });
      });

      it('can handle introspection request', async () => {
        app = await createApp();
        const req = request(app)
          .post('/graphql')
          .send({ query: introspectionQuery });
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(
            res.body.data.__schema.types[0].fields[0].name,
          ).to.equal('testString');
        });
      });

      it('can handle batch requests', async () => {
        app = await createApp();
        const expected = [
          {
            data: {
              testString: 'it works',
            },
          },
          {
            data: {
              testArgument: 'hello yellow',
            },
          },
        ];
        const req = request(app)
          .post('/graphql')
          .send([
            {
              query: `
                      query test($echo: String){ testArgument(echo: $echo) }
                      query test2{ testString }`,
              variables: { echo: 'world' },
              operationName: 'test2',
            },
            {
              query: `
                      query testX($echo: String){ testArgument(echo: $echo) }`,
              variables: { echo: 'yellow' },
              operationName: 'testX',
            },
          ]);
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body).to.deep.equal(expected);
        });
      });

      it('can handle batch requests', async () => {
        app = await createApp();
        const expected = [
          {
            data: {
              testString: 'it works',
            },
          },
        ];
        const req = request(app)
          .post('/graphql')
          .send([
            {
              query: `
                      query test($echo: String){ testArgument(echo: $echo) }
                      query test2{ testString }`,
              variables: { echo: 'world' },
              operationName: 'test2',
            },
          ]);
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body).to.deep.equal(expected);
        });
      });

      it('can handle batch requests in parallel', async function() {
        // this test will fail due to timeout if running serially.
        const parallels = 100;
        const delayPerReq = 40;
        this.timeout(3000);

        app = await createApp();
        const expected = Array(parallels).fill({
          data: { testStringWithDelay: 'it works' },
        });
        const req = request(app)
          .post('/graphql')
          .send(
            Array(parallels).fill({
              query: `query test($delay: Int!) { testStringWithDelay(delay: $delay) }`,
              operationName: 'test',
              variables: { delay: delayPerReq },
            }),
          );
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body).to.deep.equal(expected);
        });
      });

      it('clones batch context', async () => {
        app = await createApp({
          graphqlOptions: {
            schema,
            context: { testField: 'expected' },
          },
        });
        const expected = [
          {
            data: {
              testContext: 'expected',
            },
          },
          {
            data: {
              testContext: 'expected',
            },
          },
        ];
        const req = request(app)
          .post('/graphql')
          .send([
            {
              query: 'query test{ testContext }',
            },
            {
              query: 'query test{ testContext }',
            },
          ]);
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body).to.deep.equal(expected);
        });
      });

      it('executes batch context if it is a function', async () => {
        let callCount = 0;
        app = await createApp({
          graphqlOptions: {
            schema,
            context: () => {
              callCount++;
              return { testField: 'expected' };
            },
          },
        });
        const expected = [
          {
            data: {
              testContext: 'expected',
            },
          },
          {
            data: {
              testContext: 'expected',
            },
          },
        ];
        const req = request(app)
          .post('/graphql')
          .send([
            {
              query: 'query test{ testContext }',
            },
            {
              query: 'query test{ testContext }',
            },
          ]);
        return req.then(res => {
          expect(callCount).to.equal(2);
          expect(res.status).to.equal(200);
          return expect(res.body).to.deep.equal(expected);
        });
      });

      it('can handle a request with a mutation', async () => {
        app = await createApp();
        const expected = {
          testMutation: 'not really a mutation, but who cares: world',
        };
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'mutation test($echo: String){ testMutation(echo: $echo) }',
            variables: { echo: 'world' },
          });
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body.data).to.deep.equal(expected);
        });
      });

      it('applies the formatResponse function', async () => {
        app = await createApp({
          graphqlOptions: {
            schema,
            formatResponse(response) {
              response['extensions'] = { it: 'works' };
              return response;
            },
          },
        });
        const expected = { it: 'works' };
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'mutation test($echo: String){ testMutation(echo: $echo) }',
            variables: { echo: 'world' },
          });
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body.extensions).to.deep.equal(expected);
        });
      });

      it('passes the context to the resolver', async () => {
        const expected = 'context works';
        app = await createApp({
          graphqlOptions: {
            schema,
            context: { testField: expected },
          },
        });
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test{ testContext }',
          });
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body.data.testContext).to.equal(expected);
        });
      });

      it('passes the rootValue to the resolver', async () => {
        const expected = 'it passes rootValue';
        app = await createApp({
          graphqlOptions: {
            schema,
            rootValue: expected,
          },
        });
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test{ testRootValue }',
          });
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body.data.testRootValue).to.equal(expected);
        });
      });

      it('returns errors', async () => {
        const expected = 'Secret error message';
        app = await createApp({
          graphqlOptions: {
            schema,
          },
        });
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test{ testError }',
          });
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body.errors[0].message).to.equal(expected);
        });
      });

      it('applies formatError if provided', async () => {
        const expected = '--blank--';
        app = await createApp({
          graphqlOptions: {
            schema,
            formatError: err => ({ message: expected }),
          },
        });
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test{ testError }',
          });
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body.errors[0].message).to.equal(expected);
        });
      });

      it('sends internal server error when formatError fails', async () => {
        app = await createApp({
          graphqlOptions: {
            schema,
            formatError: err => {
              throw new Error('I should be caught');
            },
          },
        });
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test{ testError }',
          });
        return req.then(res => {
          return expect(res.body.errors[0].message).to.equal(
            'Internal server error',
          );
        });
      });

      it('sends stack trace to error if debug mode is set', async () => {
        const expected = /at resolveFieldValueOrError/;
        const stackTrace = [];
        const origError = console.error;
        console.error = (...args) => stackTrace.push(args);
        app = await createApp({
          graphqlOptions: {
            schema,
            debug: true,
          },
        });
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test{ testError }',
          });
        return req.then(res => {
          console.error = origError;
          return expect(stackTrace[0][0]).to.match(expected);
        });
      });

      it('sends stack trace to error log if debug mode is set', async () => {
        const logStub = stub(console, 'error');
        const expected = /at resolveFieldValueOrError/;
        app = await createApp({
          graphqlOptions: {
            schema,
            debug: true,
          },
        });
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test{ testError }',
          });
        return req.then(res => {
          logStub.restore();
          expect(logStub.callCount).to.equal(1);
          return expect(logStub.getCall(0).args[0]).to.match(expected);
        });
      });

      it('applies additional validationRules', async () => {
        const expected = 'alwaysInvalidRule was really invalid!';
        const alwaysInvalidRule = function(context) {
          return {
            enter() {
              context.reportError(new GraphQLError(expected));
              return BREAK;
            },
          };
        };
        app = await createApp({
          graphqlOptions: {
            schema,
            validationRules: [alwaysInvalidRule],
          },
        });
        const req = request(app)
          .post('/graphql')
          .send({
            query: 'query test{ testString }',
          });
        return req.then(res => {
          expect(res.status).to.equal(400);
          return expect(res.body.errors[0].message).to.equal(expected);
        });
      });
    });

    describe('renderGraphiQL', () => {
      it('presents GraphiQL when accepting HTML', async () => {
        app = await createApp({
          graphiqlOptions: {
            endpointURL: '/graphql',
          },
        });

        const req = request(app)
          .get('/graphiql')
          .query('query={test}')
          .set('Accept', 'text/html');
        return req.then(response => {
          expect(response.status).to.equal(200);
          expect(response.type).to.equal('text/html');
          expect(response.text).to.include('{test}');
          expect(response.text).to.include('/graphql');
          expect(response.text).to.include('graphiql.min.js');
        });
      });

      it('allows options to be a function', async () => {
        app = await createApp({
          graphiqlOptions: () => ({
            endpointURL: '/graphql',
          }),
        });

        const req = request(app)
          .get('/graphiql')
          .set('Accept', 'text/html');
        return req.then(response => {
          expect(response.status).to.equal(200);
        });
      });

      it('handles options function errors', async () => {
        app = await createApp({
          graphiqlOptions: () => {
            throw new Error('I should be caught');
          },
        });

        const req = request(app)
          .get('/graphiql')
          .set('Accept', 'text/html');
        return req.then(response => {
          expect(response.status).to.equal(500);
        });
      });

      it('presents options variables', async () => {
        app = await createApp({
          graphiqlOptions: {
            endpointURL: '/graphql',
            variables: { key: 'optionsValue' },
          },
        });

        const req = request(app)
          .get('/graphiql')
          .set('Accept', 'text/html');
        return req.then(response => {
          expect(response.status).to.equal(200);
          expect(response.text.replace(/\s/g, '')).to.include(
            'variables:"{\\n\\"key\\":\\"optionsValue\\"\\n}"',
          );
        });
      });

      it('presents query variables over options variables', async () => {
        app = await createApp({
          graphiqlOptions: {
            endpointURL: '/graphql',
            variables: { key: 'optionsValue' },
          },
        });

        const req = request(app)
          .get('/graphiql?variables={"key":"queryValue"}')
          .set('Accept', 'text/html');
        return req.then(response => {
          expect(response.status).to.equal(200);
          expect(response.text.replace(/\s/g, '')).to.include(
            'variables:"{\\n\\"key\\":\\"queryValue\\"\\n}"',
          );
        });
      });
    });

    describe('stored queries', () => {
      it('works with formatParams', async () => {
        const store = new OperationStore(schema);
        store.put('query testquery{ testString }');
        app = await createApp({
          graphqlOptions: {
            schema,
            formatParams(params) {
              params['query'] = store.get(params.operationName);
              return params;
            },
          },
        });
        const expected = { testString: 'it works' };
        const req = request(app)
          .post('/graphql')
          .send({
            operationName: 'testquery',
          });
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body.data).to.deep.equal(expected);
        });
      });

      it('can reject non-whitelisted queries', async () => {
        const store = new OperationStore(schema);
        store.put('query testquery{ testString }');
        app = await createApp({
          graphqlOptions: {
            schema,
            formatParams(params) {
              if (params.query) {
                throw new Error('Must not provide query, only operationName');
              }
              params['query'] = store.get(params.operationName);
              return params;
            },
          },
        });
        const expected = [
          {
            data: {
              testString: 'it works',
            },
          },
          {
            errors: [
              {
                message: 'Must not provide query, only operationName',
              },
            ],
          },
        ];

        const req = request(app)
          .post('/graphql')
          .send([
            {
              operationName: 'testquery',
            },
            {
              query: '{ testString }',
            },
          ]);
        return req.then(res => {
          expect(res.status).to.equal(200);
          return expect(res.body).to.deep.equal(expected);
        });
      });
    });

    describe('server setup', () => {
      it('throws error on 404 routes', async () => {
        app = await createApp();

        const query = {
          query: '{ testString }',
        };
        const req = request(app)
          .get('/bogus-route')
          .query(query);
        return req.then(res => {
          expect(res.status).to.equal(404);
        });
      });
    });
  });
};