Home Reference Source Test

test/http_test.js

"use strict";

import chai, { expect } from "chai";
import chaiAsPromised from "chai-as-promised";
import sinon from "sinon";
import { EventEmitter } from "events";
import { fakeServerResponse } from "./test_utils.js";
import HTTP from "../src/http.js";
import {
  NetworkTimeoutError,
  ServerResponse,
  UnparseableResponseError,
} from "../src/errors.js";

chai.use(chaiAsPromised);
chai.should();
chai.config.includeStack = true;

/** @test {HTTP} */
describe("HTTP class", () => {
  let sandbox, events, http;

  beforeEach(() => {
    sandbox = sinon.sandbox.create();
    events = new EventEmitter();
    http = new HTTP(events, { timeout: 100 });
  });

  afterEach(() => sandbox.restore());

  /** @test {HTTP#constructor} */
  describe("#constructor", () => {
    it("should expose a passed events instance", () => {
      const events = new EventEmitter();
      const http = new HTTP(events);
      expect(http.events).to.eql(events);
    });

    it("should accept a requestMode option", () => {
      expect(
        new HTTP(events, {
          requestMode: "no-cors",
        }).requestMode
      ).eql("no-cors");
    });

    it("should complain if an events handler is not provided", () => {
      expect(() => {
        new HTTP();
      }).to.Throw(Error, /No events handler provided/);
    });
  });

  /** @test {HTTP#request} */
  describe("#request()", () => {
    describe("Request headers", () => {
      beforeEach(() => {
        sandbox.stub(global, "fetch").returns(fakeServerResponse(200, {}, {}));
      });

      it("should set default headers", () => {
        http.request("/");

        expect(fetch.firstCall.args[1].headers).eql(
          HTTP.DEFAULT_REQUEST_HEADERS
        );
      });

      it("should merge custom headers with default ones", () => {
        http.request("/", { headers: { Foo: "Bar" } });

        expect(fetch.firstCall.args[1].headers.Foo).eql("Bar");
      });

      it("should drop custom content-type header for multipart body", () => {
        http.request("/", {
          headers: { "Content-Type": "application/foo" },
          body: new FormData(),
        });

        expect(fetch.firstCall.args[1].headers["Content-Type"]).to.be.undefined;
      });
    });

    describe("Request CORS mode", () => {
      beforeEach(() => {
        sandbox.stub(global, "fetch").returns(fakeServerResponse(200, {}, {}));
      });

      it("should use default CORS mode", () => {
        new HTTP(events).request("/");

        expect(fetch.firstCall.args[1].mode).eql("cors");
      });

      it("should use configured custom CORS mode", () => {
        new HTTP(events, { requestMode: "no-cors" }).request("/");

        expect(fetch.firstCall.args[1].mode).eql("no-cors");
      });
    });

    describe("Succesful request", () => {
      beforeEach(() => {
        sandbox
          .stub(global, "fetch")
          .returns(fakeServerResponse(200, { a: 1 }, { b: 2 }));
      });

      it("should resolve with HTTP status", () => {
        return http
          .request("/")
          .then(res => res.status)
          .should.eventually.become(200);
      });

      it("should resolve with JSON body", () => {
        return http
          .request("/")
          .then(res => res.json)
          .should.eventually.become({ a: 1 });
      });

      it("should resolve with headers", () => {
        return http
          .request("/")
          .then(res => res.headers.get("b"))
          .should.eventually.become(2);
      });
    });

    describe("Request timeout", () => {
      it("should timeout the request", () => {
        sandbox.stub(global, "fetch").returns(
          new Promise(resolve => {
            setTimeout(resolve, 20000);
          })
        );
        return http.request("/").should.be.rejectedWith(NetworkTimeoutError);
      });
    });

    describe("No content response", () => {
      it("should resolve with null JSON if Content-Length header is missing", () => {
        sandbox
          .stub(global, "fetch")
          .returns(fakeServerResponse(200, null, {}));

        return http
          .request("/")
          .then(res => res.json)
          .should.eventually.become(null);
      });
    });

    describe("Malformed JSON response", () => {
      it("should reject with an appropriate message", () => {
        sandbox.stub(global, "fetch").returns(
          Promise.resolve({
            status: 200,
            headers: {
              get(name) {
                if (name !== "Alert") {
                  return "fake";
                }
              },
            },
            text() {
              return Promise.resolve("an example of invalid JSON");
            },
          })
        );

        return http
          .request("/")
          .should.be.rejectedWith(
            UnparseableResponseError,
            /HTTP 200; SyntaxError: Unexpected token.+an example of invalid JSON/
          );
      });
    });

    describe("Business error responses", () => {
      it("should reject on status code > 400", () => {
        sandbox.stub(global, "fetch").returns(
          fakeServerResponse(400, {
            code: 400,
            details: [
              {
                description: "data is missing",
                location: "body",
                name: "data",
              },
            ],
            errno: 107,
            error: "Invalid parameters",
            message: "data is missing",
          })
        );

        return http
          .request("/")
          .should.be.rejectedWith(
            ServerResponse,
            /HTTP 400 Invalid parameters: Invalid request parameter \(data is missing\)/
          );
      });

      it("should expose JSON error bodies", () => {
        const errorBody = {
          code: 400,
          details: [
            {
              description: "data is missing",
              location: "body",
              name: "data",
            },
          ],
          errno: 107,
          error: "Invalid parameters",
          message: "data is missing",
        };
        sandbox
          .stub(global, "fetch")
          .returns(fakeServerResponse(400, errorBody));

        return http
          .request("/")
          .should.be.rejectedWith(ServerResponse)
          .and.eventually.deep.property("data", errorBody);
      });

      it("should reject on status code > 400 even with empty body", () => {
        sandbox.stub(global, "fetch").resolves({
          status: 400,
          statusText: "Cake Is A Lie",
          headers: {
            get(name) {
              if (name === "Content-Length") {
                return 0;
              }
            },
          },
          text() {
            return Promise.resolve("");
          },
        });

        return http
          .request("/")
          .should.be.rejectedWith(ServerResponse, /HTTP 400 Cake Is A Lie$/);
      });
    });

    describe("Deprecation header", () => {
      const eolObject = {
        code: "soft-eol",
        url: "http://eos-url",
        message: "This service will soon be decommissioned",
      };

      beforeEach(() => {
        sandbox.stub(console, "warn");
        sandbox.stub(events, "emit");
      });

      it("should handle deprecation header", () => {
        sandbox
          .stub(global, "fetch")
          .returns(
            fakeServerResponse(200, {}, { Alert: JSON.stringify(eolObject) })
          );

        return http.request("/").then(_ => {
          sinon.assert.calledOnce(console.warn);
          sinon.assert.calledWithExactly(
            console.warn,
            eolObject.message,
            eolObject.url
          );
        });
      });

      it("should handle deprecation header parse error", () => {
        sandbox
          .stub(global, "fetch")
          .returns(fakeServerResponse(200, {}, { Alert: "dafuq" }));

        return http.request("/").then(_ => {
          sinon.assert.calledOnce(console.warn);
          sinon.assert.calledWithExactly(
            console.warn,
            "Unable to parse Alert header message",
            "dafuq"
          );
        });
      });

      it("should emit a deprecated event on Alert header", () => {
        sandbox
          .stub(global, "fetch")
          .returns(
            fakeServerResponse(200, {}, { Alert: JSON.stringify(eolObject) })
          );

        return http.request("/").then(_ => {
          expect(events.emit.firstCall.args[0]).eql("deprecated");
          expect(events.emit.firstCall.args[1]).eql(eolObject);
        });
      });
    });

    describe("Backoff header handling", () => {
      beforeEach(() => {
        // Make Date#getTime always returning 1000000, for predictability
        sandbox.stub(Date.prototype, "getTime").returns(1000 * 1000);
        sandbox.stub(events, "emit");
      });

      it("should emit a backoff event on set Backoff header", () => {
        sandbox
          .stub(global, "fetch")
          .returns(fakeServerResponse(200, {}, { Backoff: "1000" }));

        return http.request("/").then(_ => {
          expect(events.emit.firstCall.args[0]).eql("backoff");
          expect(events.emit.firstCall.args[1]).eql(2000000);
        });
      });

      it("should emit a backoff event even on error responses", () => {
        sandbox
          .stub(global, "fetch")
          .returns(fakeServerResponse(503, {}, { Backoff: "1000" }));

        return http.request("/").should.be.rejected.then(() => {
          expect(events.emit.firstCall.args[0]).eql("backoff");
          expect(events.emit.firstCall.args[1]).eql(2000000);
        });
      });

      it("should emit a backoff event on missing Backoff header", () => {
        sandbox.stub(global, "fetch").returns(fakeServerResponse(200, {}, {}));

        return http.request("/").then(_ => {
          expect(events.emit.firstCall.args[0]).eql("backoff");
          expect(events.emit.firstCall.args[1]).eql(0);
        });
      });
    });

    describe("Retry-After header handling", () => {
      describe("Event", () => {
        beforeEach(() => {
          // Make Date#getTime always returning 1000000, for predictability
          sandbox.stub(Date.prototype, "getTime").returns(1000 * 1000);
          sandbox.stub(events, "emit");
        });

        it("should emit a retry-after event when Retry-After is set", () => {
          sandbox
            .stub(global, "fetch")
            .returns(fakeServerResponse(200, {}, { "Retry-After": "1000" }));

          return http.request("/", {}, { retry: 0 }).then(_ => {
            expect(events.emit.lastCall.args[0]).eql("retry-after");
            expect(events.emit.lastCall.args[1]).eql(2000000);
          });
        });
      });

      describe("Retry loop", () => {
        let fetch;

        beforeEach(() => {
          fetch = sandbox.stub(global, "fetch");
          // Avoid actually waiting real time for retries in test suites.
          // We can't use Sinon fakeTimers since we can't tick the fake
          // clock at the right moment (just after request failure).
          sandbox
            .stub(global, "setTimeout")
            .callsFake((fn, time) => setImmediate(fn));
        });

        it("should not retry the request by default", () => {
          fetch.returns(fakeServerResponse(503, {}, { "Retry-After": "1" }));
          return http
            .request("/")
            .should.eventually.be.rejectedWith(Error, /HTTP 503/);
        });

        it("should retry the request if specified", () => {
          const success = { success: true };
          fetch
            .onCall(0)
            .returns(fakeServerResponse(503, {}, { "Retry-After": "1" }));
          fetch.onCall(1).returns(fakeServerResponse(200, success));
          return http
            .request("/", {}, { retry: 1 })
            .then(res => res.json)
            .should.eventually.become(success);
        });

        it("should error when retries are exhausted", () => {
          fetch
            .onCall(0)
            .returns(fakeServerResponse(503, {}, { "Retry-After": "1" }));
          fetch
            .onCall(1)
            .returns(fakeServerResponse(503, {}, { "Retry-After": "1" }));
          fetch
            .onCall(2)
            .returns(fakeServerResponse(503, {}, { "Retry-After": "1" }));
          return http
            .request("/", {}, { retry: 2 })
            .should.eventually.be.rejectedWith(Error, /HTTP 503/);
        });
      });
    });
  });
});