/**
 * @license
 * Copyright The Closure Library Authors.
 * SPDX-License-Identifier: Apache-2.0
 */

goog.module('goog.labs.net.xhrTest');
goog.setTestOnly('goog.labs.net.xhrTest');

const DefaultXmlHttpFactory = goog.require('goog.net.DefaultXmlHttpFactory');
const EventType = goog.require('goog.events.EventType');
const GoogPromise = goog.require('goog.Promise');
const MockClock = goog.require('goog.testing.MockClock');
const TestCase = goog.require('goog.testing.TestCase');
const WrapperXmlHttpFactory = goog.require('goog.net.WrapperXmlHttpFactory');
const XhrLike = goog.require('goog.net.XhrLike');
const XmlHttp = goog.require('goog.net.XmlHttp');
const events = goog.require('goog.events');
const product = goog.require('goog.userAgent.product');
const testSuite = goog.require('goog.testing.testSuite');
const userAgent = goog.require('goog.userAgent');
const xhr = goog.require('goog.labs.net.xhr');
/** @suppress {extraRequire} Needed for G_testrunner */
goog.require('goog.testing.jsunit');

/** Path to a small download target used for testing binary requests. */
const TEST_IMAGE = 'testdata/cleardot.gif';


/** The expected bytes of the test image. */
const TEST_IMAGE_BYTES = [
  0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80,
  0xFF, 0x00, 0xC0, 0xC0, 0xC0, 0x00, 0x00, 0x00, 0x21, 0xF9, 0x04,
  0x01, 0x00, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01,
  0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3B
];

/**
 * @param {number} status HTTP status code.
 * @param {string=} opt_responseText
 * @param {number=} opt_latency
 *     Milliseconds between sending a request and receiving the response.
 * @return {!XhrLike} The XHR stub that will be returned from the factory.
 * @suppress {checkTypes} suppression added to enable type checking
 */
function stubXhrToReturn(status, opt_responseText, opt_latency) {
  if (opt_latency != null) {
    mockClock = new MockClock(true);
  }

  const stubXhr = {
    sent: false,
    aborted: false,
    status: 0,
    headers: {},
    open: function(method, url, async) {
      /** @suppress {globalThis} suppression added to enable type checking */
      this.method = method;
      /** @suppress {globalThis} suppression added to enable type checking */
      this.url = url;
      /** @suppress {globalThis} suppression added to enable type checking */
      this.async = async;
    },
    setRequestHeader: function(key, value) {
      /** @suppress {globalThis} suppression added to enable type checking */
      this.headers[key] = value;
    },
    overrideMimeType: function(mimeType) {
      /** @suppress {globalThis} suppression added to enable type checking */
      this.mimeType = mimeType;
    },
    abort: /**
              @suppress {globalThis} suppression added to enable type checking
            */
        function() {
          /**
           * @suppress {globalThis} suppression added to enable type
           * checking
           */
          this.aborted = true;
          this.load(0);
        },
    send: /**
             @suppress {globalThis} suppression added to enable type checking
           */
        function(data) {
          /**
           * @suppress {globalThis} suppression added to enable type
           * checking
           */
          this.data = data;
          /**
           * @suppress {globalThis} suppression added to enable type
           * checking
           */
          this.sent = true;

          // Fulfill the send asynchronously, or possibly with the
          // MockClock.
          window.setTimeout(
              goog.bind(this.load, this, status), opt_latency || 0);
          if (mockClock) {
            mockClock.tick(opt_latency);
          }
        },
    load: /**
             @suppress {globalThis} suppression added to enable type checking
           */
        function(status) {
          /**
           * @suppress {globalThis} suppression added to enable type
           * checking
           */
          this.status = status;
          if (opt_responseText != null) {
            /**
             * @suppress {globalThis} suppression added to enable type
             * checking
             */
            this.responseText = opt_responseText;
          }
          /**
           * @suppress {globalThis} suppression added to enable type
           * checking
           */
          this.readyState = 4;
          if (this.onreadystatechange) this.onreadystatechange();
        }
  };

  stubXmlHttpWith(stubXhr);
  return stubXhr;
}

/**
 * @param {!Error} err Error to be thrown when sending the stub XHR.
 */
function stubXhrToThrow(err) {
  stubXmlHttpWith(buildThrowingStubXhr(err));
}

/**
 * @param {!Error} err Error to be thrown when sending this stub XHR.
 * @return {!XhrLike} The XHR stub that will be returned from the factory.
 * @suppress {checkTypes} suppression added to enable type checking
 */
function buildThrowingStubXhr(err) {
  return {
    sent: false,
    aborted: false,
    status: 0,
    headers: {},
    open: function(method, url, async) {
      /** @suppress {globalThis} suppression added to enable type checking */
      this.method = method;
      /** @suppress {globalThis} suppression added to enable type checking */
      this.url = url;
      /** @suppress {globalThis} suppression added to enable type checking */
      this.async = async;
    },
    setRequestHeader: function(key, value) {
      /** @suppress {globalThis} suppression added to enable type checking */
      this.headers[key] = value;
    },
    overrideMimeType: function(mimeType) {
      /** @suppress {globalThis} suppression added to enable type checking */
      this.mimeType = mimeType;
    },
    send: function(data) {
      throw err;
    }
  };
}

/**
 * Replace XmlHttp with a function that returns a stub XHR.
 * @param {!XhrLike.OrNative} stubXhr
 * @suppress {checkTypes} suppression added to enable type checking
 */
function stubXmlHttpWith(stubXhr) {
  XmlHttp.setGlobalFactory({
    createInstance() {
      return stubXhr;
    },
    getOptions() {
      return {};
    }
  });
}

let mockClock;


/**
 * Tests whether the test was loaded from a file: protocol. Tests that use a
 * real network request cannot be run from the local file system due to
 * cross-origin restrictions, but will run if the tests are hosted on a server.
 * A log message is added to the test case to warn users that the a test was
 * skipped.
 *
 * @return {boolean} Whether the test is running on a local file system.
 */
function isRunningLocally() {
  if (window.location.protocol == 'file:') {
    const testCase = globalThis['G_testRunner'].testCase;
    testCase.saveMessage('Test skipped while running on local file system.');
    return true;
  }
  return false;
}

testSuite({
  setUpPage() {
    TestCase.getActiveTestCase().promiseTimeout = 10000;  // 10s
  },

  tearDown() {
    if (mockClock) {
      mockClock.dispose();
      mockClock = null;
    }

    XmlHttp.setGlobalFactory(new DefaultXmlHttpFactory());
  },

  /** @suppress {checkTypes} suppression added to enable type checking */
  testSimpleRequest() {
    if (isRunningLocally()) return;

    return xhr.send('GET', 'testdata/xhr_test_text.data').then(function(xhr) {
      assertEquals('Just some data.', xhr.responseText);
      assertEquals(200, xhr.status);
    });
  },

  testGetText() {
    if (isRunningLocally()) return;

    return xhr.get('testdata/xhr_test_text.data').then(function(responseText) {
      assertEquals('Just some data.', responseText);
    });
  },

  testGetTextWithJson() {
    if (isRunningLocally()) return;

    return xhr.get('testdata/xhr_test_json.data').then(function(responseText) {
      assertEquals('while(1);\n{"stat":"ok","count":12345}\n', responseText);
    });
  },

  testPostText() {
    if (isRunningLocally()) return;

    return xhr.post('testdata/xhr_test_text.data', 'post-data')
        .then(function(responseText) {
          // No good way to test post-data gets transported.
          assertEquals('Just some data.', responseText);
        });
  },

  testGetJson() {
    if (isRunningLocally()) return;

    return xhr
        .getJson('testdata/xhr_test_json.data', {xssiPrefix: 'while(1);\n'})
        .then(function(responseObj) {
          assertEquals('ok', responseObj['stat']);
          assertEquals(12345, responseObj['count']);
        });
  },

  testGetBlob() {
    if (isRunningLocally()) return;

    // IE9 and earlier do not support blobs.
    if (!('Blob' in globalThis)) {
      const err = assertThrows(function() {
        xhr.getBlob(TEST_IMAGE);
      });
      assertEquals(
          'Assertion failed: getBlob is not supported in this browser.',
          err.message);
      return;
    }

    const options = {withCredentials: true};

    return xhr.getBlob(TEST_IMAGE, options)
        .then(function(blob) {
          const reader = new FileReader();
          return new GoogPromise(function(resolve, reject) {
            events.listenOnce(reader, EventType.LOAD, resolve);
            reader.readAsArrayBuffer(blob);
          });
        })
        .then(function(e) {
          assertElementsEquals(
              TEST_IMAGE_BYTES, new Uint8Array(e.target.result));
          assertObjectEquals(
              'input options should not have mutated.', {withCredentials: true},
              options);
        });
  },

  testGetBytes() {
    if (isRunningLocally()) return;

    // IE8 requires a VBScript fallback to read the bytes from the response.
    if (userAgent.IE && !userAgent.isDocumentMode(9)) {
      const err = assertThrows(function() {
        xhr.getBytes(TEST_IMAGE);
      });
      assertEquals(
          'Assertion failed: getBytes is not supported in this browser.',
          err.message);
      return;
    }

    const options = {withCredentials: true};

    return xhr.getBytes(TEST_IMAGE).then(function(bytes) {
      assertElementsEquals(TEST_IMAGE_BYTES, bytes);
      assertObjectEquals(
          'input options should not have mutated.', {withCredentials: true},
          options);
    });
  },

  testSerialRequests() {
    if (isRunningLocally()) return;

    return xhr.get('testdata/xhr_test_text.data')
        .then(function(response) {
          return xhr.getJson(
              'testdata/xhr_test_json.data', {xssiPrefix: 'while(1);\n'});
        })
        .then(function(responseObj) {
          // Data that comes through to callbacks should be from the 2nd
          // request.
          assertEquals('ok', responseObj['stat']);
          assertEquals(12345, responseObj['count']);
        });
  },

  testBadUrlDetectedAsError() {
    if (isRunningLocally()) return;

    return xhr.getJson('unknown-file.dat')
        .then(
            fail /* opt_onFulfilled */
            ,    /**
                    @suppress {strictMissingProperties} suppression added to
                    enable type checking
                  */
            function(err) {
              assertTrue(
                  'Error should be an HTTP error',
                  err instanceof xhr.HttpError);
              assertEquals(404, err.status);
              assertNotNull(err.xhr);
            });
  },

  testBadOriginTriggersOnErrorHandler() {
    // TODO: b/296631792 - xhr.get below consistently hangs.
    if (product.SAFARI) return;

    if (userAgent.EDGE) return;  // failing b/62677027

    return xhr.get('http://www.google.com')
        .then(
            function() {
              fail(
                  'XHR to http://www.google.com should\'ve failed due to ' +
                  'same-origin policy.');
            } /* opt_onFulfilled */,
            /**
               @suppress {strictMissingProperties} suppression added to enable
               type checking
             */
            function(err) {
              // In IE this will be a goog.labs.net.xhr.Error since it is thrown
              //  when calling xhr.open(), other browsers will raise an
              //  HttpError.
              assertTrue(
                  'Error should be an xhr error', err instanceof xhr.Error);
              assertNotNull(err.xhr);
            });
  },

  //============================================================================
  // The following tests use a stubbed out XMLHttpRequest.
  //============================================================================

  testSendNoOptions() {
    let called = false;
    stubXhrToReturn(200);
    assertFalse('Callback should not yet have been called', called);
    return xhr.send('GET', 'test-url', null)
        .then(/**
                 @suppress {strictMissingProperties} suppression added to
                 enable type checking
               */
              function(stubXhr) {
                called = true;
                assertEquals('GET', stubXhr.method);
                assertEquals('test-url', stubXhr.url);
              });
  },

  testSendPostSetsDefaultHeader() {
    stubXhrToReturn(200);
    return xhr.send('POST', 'test-url', null)
        .then(/**
                 @suppress {strictMissingProperties,missingProperties}
                 suppression added to enable type checking
               */
              function(stubXhr) {
                assertEquals('POST', stubXhr.method);
                assertEquals('test-url', stubXhr.url);
                assertEquals(
                    'application/x-www-form-urlencoded;charset=utf-8',
                    stubXhr.headers['Content-Type']);
              });
  },

  testSendPostDoesntSetHeaderWithFormData() {
    if (!globalThis['FormData']) {
      return;
    }
    const formData = new globalThis['FormData']();
    formData.append('name', 'value');

    stubXhrToReturn(200);
    return xhr.send('POST', 'test-url', formData)
        .then(/**
                 @suppress {strictMissingProperties,missingProperties}
                 suppression added to enable type checking
               */
              function(stubXhr) {
                assertEquals('POST', stubXhr.method);
                assertEquals('test-url', stubXhr.url);
                assertEquals(undefined, stubXhr.headers['Content-Type']);
              });
  },

  testSendPostHeaders() {
    stubXhrToReturn(200);
    return xhr
        .send(
            'POST', 'test-url', null,
            {headers: {'Content-Type': 'text/plain', 'X-Made-Up': 'FooBar'}})
        .then(/**
                 @suppress {strictMissingProperties,missingProperties}
                 suppression added to enable type checking
               */
              function(stubXhr) {
                assertEquals('POST', stubXhr.method);
                assertEquals('test-url', stubXhr.url);
                assertEquals('text/plain', stubXhr.headers['Content-Type']);
                assertEquals('FooBar', stubXhr.headers['X-Made-Up']);
              });
  },

  testSendPostHeadersWithFormData() {
    if (!globalThis['FormData']) {
      return;
    }
    const formData = new globalThis['FormData']();
    formData.append('name', 'value');

    stubXhrToReturn(200);
    return xhr
        .send(
            'POST', 'test-url', formData,
            {headers: {'Content-Type': 'text/plain', 'X-Made-Up': 'FooBar'}})
        .then(/**
                 @suppress {strictMissingProperties,missingProperties}
                 suppression added to enable type checking
               */
              function(stubXhr) {
                assertEquals('POST', stubXhr.method);
                assertEquals('test-url', stubXhr.url);
                assertEquals('text/plain', stubXhr.headers['Content-Type']);
                assertEquals('FooBar', stubXhr.headers['X-Made-Up']);
              });
  },

  testSendNullPostHeaders() {
    stubXhrToReturn(200);
    return xhr
        .send('POST', 'test-url', null, {
          headers:
              {'Content-Type': null, 'X-Made-Up': 'FooBar', 'Y-Made-Up': null}
        })
        .then(/**
                 @suppress {strictMissingProperties,missingProperties}
                 suppression added to enable type checking
               */
              function(stubXhr) {
                assertEquals('POST', stubXhr.method);
                assertEquals('test-url', stubXhr.url);
                assertEquals(undefined, stubXhr.headers['Content-Type']);
                assertEquals('FooBar', stubXhr.headers['X-Made-Up']);
                assertEquals(undefined, stubXhr.headers['Y-Made-Up']);
              });
  },

  testSendNullPostHeadersWithFormData() {
    if (!globalThis['FormData']) {
      return;
    }
    const formData = new globalThis['FormData']();
    formData.append('name', 'value');

    stubXhrToReturn(200);
    return xhr
        .send('POST', 'test-url', formData, {
          headers:
              {'Content-Type': null, 'X-Made-Up': 'FooBar', 'Y-Made-Up': null}
        })
        .then(/**
                 @suppress {strictMissingProperties,missingProperties}
                 suppression added to enable type checking
               */
              function(stubXhr) {
                assertEquals('POST', stubXhr.method);
                assertEquals('test-url', stubXhr.url);
                assertEquals(undefined, stubXhr.headers['Content-Type']);
                assertEquals('FooBar', stubXhr.headers['X-Made-Up']);
                assertEquals(undefined, stubXhr.headers['Y-Made-Up']);
              });
  },

  testSendWithCredentials() {
    stubXhrToReturn(200);
    return xhr.send('POST', 'test-url', null, {withCredentials: true})
        .then(/**
                 @suppress {strictMissingProperties} suppression added to
                 enable type checking
               */
              function(stubXhr) {
                assertTrue('XHR should have been sent', stubXhr.sent);
                assertTrue(stubXhr.withCredentials);
              });
  },

  testSendWithMimeType() {
    stubXhrToReturn(200);
    return xhr.send('POST', 'test-url', null, {mimeType: 'text/plain'})
        .then(/**
                 @suppress {strictMissingProperties} suppression added to
                 enable type checking
               */
              function(stubXhr) {
                assertTrue('XHR should have been sent', stubXhr.sent);
                assertEquals('text/plain', stubXhr.mimeType);
              });
  },

  testSendWithHttpError() {
    stubXhrToReturn(500);
    return xhr.send('POST', 'test-url', null)
        .then(
            fail /* opt_onResolved */
            ,    /**
                    @suppress {strictMissingProperties} suppression added to
                    enable type checking
                  */
            function(err) {
              assertTrue(err instanceof xhr.HttpError);
              assertTrue(err.xhr.sent);
              assertEquals(500, err.status);
            });
  },

  /** @suppress {checkTypes} suppression added to enable type checking */
  testSendWithTimeoutNotHit() {
    stubXhrToReturn(200, null /* opt_responseText */, 1400 /* opt_latency */);
    return xhr.send('POST', 'test-url', null, {timeoutMs: 1500})
        .then(/**
                 @suppress {strictMissingProperties} suppression added to
                 enable type checking
               */
              function(stubXhr) {
                assertTrue(mockClock.getTimeoutsMade() > 0);
                assertTrue('XHR should have been sent', stubXhr.sent);
                assertFalse(
                    'XHR should not have been aborted', stubXhr.aborted);
              });
  },

  /** @suppress {checkTypes} suppression added to enable type checking */
  testSendWithTimeoutHit() {
    stubXhrToReturn(200, null /* opt_responseText */, 50 /* opt_latency */);
    return xhr.send('POST', 'test-url', null, {timeoutMs: 50})
        .then(
            fail /* opt_onResolved */
            ,    /**
                    @suppress {strictMissingProperties} suppression added to
                    enable type checking
                  */
            function(err) {
              assertTrue('XHR should have been sent', err.xhr.sent);
              assertTrue('XHR should have been aborted', err.xhr.aborted);
              assertTrue(err instanceof xhr.TimeoutError);
            });
  },

  testCancelRequest() {
    const request = stubXhrToReturn(200);
    /** @suppress {checkTypes} suppression added to enable type checking */
    const promise =
        xhr.send('GET', 'test-url')
            .then(
                fail /* opt_onResolved */
                ,    /**
                        @suppress {strictMissingProperties}
                        suppression added to enable type
                        checking
                      */
                function(error) {
                  assertTrue(error instanceof GoogPromise.CancellationError);
                  assertTrue('XHR should have been aborted', request.aborted);
                  return null;  // Return a non-error value for the test runner.
                });
    promise.cancel();
    return promise;
  },

  testGetJson_stubbed() {
    stubXhrToReturn(200, '{"a": 1, "b": 2}');
    xhr.getJson('test-url').then(function(responseObj) {
      assertObjectEquals({a: 1, b: 2}, responseObj);
    });
  },

  testGetJsonWithXssiPrefix() {
    stubXhrToReturn(200, 'while(1);\n{"a": 1, "b": 2}');
    return xhr.getJson('test-url', {xssiPrefix: 'while(1);\n'})
        .then(function(responseObj) {
          assertObjectEquals({a: 1, b: 2}, responseObj);
        });
  },

  testSendWithClientException() {
    stubXhrToThrow(new Error('CORS XHR with file:// schemas not allowed.'));
    return xhr.send('POST', 'file://test-url', null)
        .then(
            fail /* opt_onResolved */
            ,    /**
                    @suppress {strictMissingProperties} suppression added to
                    enable type checking
                  */
            function(err) {
              assertFalse('XHR should not have been sent', err.xhr.sent);
              assertTrue(err instanceof Error);
              assertTrue(/CORS XHR with file:\/\/ schemas not allowed./.test(
                  err.message));
            });
  },

  testSendWithFactory() {
    stubXhrToReturn(200);
    /** @suppress {checkTypes} suppression added to enable type checking */
    const options = {
      xmlHttpFactory: new WrapperXmlHttpFactory(
          goog.partial(buildThrowingStubXhr, new Error('Bad factory')),
          XmlHttp.getOptions)
    };
    return xhr.send('POST', 'file://test-url', null, options)
        .then(fail /* opt_onResolved */, function(err) {
          assertTrue(err instanceof Error);
        });
  },

});
