Manual Reference Source Test

src/Server.js

import {FMFRequest, Fixture, Preset} from '.';
import ServerHistory from './helpers/ServerHistory';
import FMFException from './helpers/FMFException';
import presets from './presets';
import sinon from 'sinon';

/**
 * Build a mock server to respond to any fetch calls. It replaces
 * `window.fetch` with a [Sinon stub](https://sinonjs.org/releases/latest/stubs/). Therefore,
 * all functionnalities provided by stub are available
 *
 * **Note :** All the server data is stored in the current instance. That may have
 * unattended side effects when using the same instance through many test without
 * resetting it each time
 *
 * @since 1.0.0
 * @version 1.0.0
 * @author Liqueur de Toile <contact@liqueurdetoile.com>
 */
export class Server {
  /**
   * Store the fixtures loaded into the server or created on-the-fly
   * @type {Array}
   * @since 2.0.0
   * @see {@link Fixture}
   */
  _fixtures = [];

  /**
   * Store if server should output events to console
   * @type {Boolean}
   */
  _verbose = false;

  /**
   * Store wether FMF shoud throw or send a 500 HTTP response when an error is raised
   * @type {Boolean}
   * @since 2.0.0
   * @see {@link Server#throwOnError}
   * @see {@link Server#warnOnError}
   */
  _throwOnError = false;

  /**
   * Store wether FMF shoud display a warning message in console when an error is raised
   * @type {Boolean}
   * @since 2.0.0
   * @see {@link Server#throwOnError}
   * @see {@link Server#warnOnError}
   */
  _warnOnError = true

  /**
   * Store the loaded presets and those created on-the-fly
   * @type {Object}
   * @since 2.0.0
   */
  _presets = {};

  /**
   * Store the server history
   * @type {ServerHistory}
   * @since 2.0.0
   */
  history = new ServerHistory();

  /**
   * Import the default presets into server
   * @version 2.0.0
   * @since   1.0.0
   * @author Liqueur de Toile <contact@liqueurdetoile.com>
   */
  constructor() {
    // Load presets
    for (let name in presets) {
      this._presets[name] = new Preset(this, name, presets[name]);
    }
  }

  /**
   * Start the server by stubbing `window.fetch`
   * @version 2.0.0
   * @since   1.0.0
   * @return  {Server}               Server instance
   */
  start() {
    /* istanbul ignore else */
    if (!this.running) {
      sinon.stub(window, 'fetch');
      this.stub.callsFake(this._processRequest.bind(this));
    }

    return this;
  }

  /**
   * Stop the server and, optionnally reset it
   * @version 2.0.0
   * @since   1.0.0
   * @param   {Boolean} [resetServer=false] If `true`, `stop` will also reset server (see {@link Server#reset})
   * @return  {Server}               Server instance
   */
  stop(resetServer = false) {
    if (this.running) window.fetch.restore();

    if (resetServer) this.reset();

    return this;
  }

  /**
   * Reset the server configuration to default, clear server history and stub history
   * @version 2.0.0
   * @since   1.0.0
   * @param   {Boolean} [resetStub=true] If `true`, the stub history will also be resetted
   * @return  {Server}               Server instance
   */
  reset(resetStub = true) {
    if (this.running && resetStub) this.stub.resetHistory();
    this.history.reset();
    this._fixtures = [];

    return this;
  }

  /**
   * Set the verbose behavior of the server
   * @version 1.0.0
   * @since   2.1.0
   * @param   {Boolean}  verbose If `true` turn the verbose mode on
   * @return  {Server}               Server instance
   */
  verbose(verbose) {
    this._verbose = !!verbose;
    this.history._verbose = !!verbose;

    return this;
  }

  /**
   * Tells the server to display a warning in console when an error is raised or when
   * something seems to went wrong in configuration.
   *
   * Default settings is true
   *
   * @version 1.0.0
   * @since   2.0.0
   * @param   {Boolean}  warnOnError `true` will display warnings
   * @return  {Server}               Server instance
   */
  warnOnError(warnOnError) {
    this._warnOnError = !!warnOnError;

    return this;
  }

  /**
   * Set the behavior of the server when an Error is thrown. If set to `true`, the server will
   * also throw the error at runtime. If set to false, it will respond with a 500 HTTP error
   *
   * At default, the server is set to throw on error that will usually be
   * the most suitable behavior when running tests to discard FMF failures.
   *
   * **note** Only errors thrown during requests processing are affected by this parameter.
   * Errors that occured on settings processing will always be raised
   *
   * @version 1.0.0
   * @since   2.0.0
   * @param   {Boolean}  throwOnError If `true` server will throw
   * @return  {Server}               Server instance
   * @see {@link Server#_onError}
   */
  throwOnError(throwOnError) {
    this._throwOnError = !!throwOnError;

    return this;
  }

  /**
   * Displays a warning message in console. It can be overridden
   * to swap to another notification system
   * @version 1.0.0
   * @since   2.0.0
   * @param   {String|Error}  error Error description
   */
  warn(error) {
    console.warn(error.toString()); // eslint-disable-line
  }

  /**
   * Check if server is currently running by trying to access a stub property
   * @version 1.0.0
   * @since   1.1.0
   * @return  {Boolean}
   */
  get running() {
    return window.fetch.reset instanceof Function;
  }

  /**
   * Exposes the underlying stub or throws error if server is not started
   * @version 1.0.0
   * @since   1.1.0
   * @return  {Object}  Sinon stub
   */
  get stub() {
    if (this.running) return window.fetch;

    throw new FMFException('Server is not started');
  }

  /**
   * Returns the selected preset or a new one based on name resolution.
   *
   * It allow a quick preset creation or edition that can be configured at once
   * through the object provided within this call or with the classic
   * ResponseConfigurator
   *
   * @version 1.0.0
   * @since   2.0.0
   * @param   {String}  name        Preset name
   * @param   {Object}  [preset={}] Preset content
   * @return {Preset}
   * @see {@link ResponseConfigurator}
   */
  preset(name, preset = {}) {
    if (this._presets[name]) return this._presets[name].set(preset);

    let newPreset = new Preset(this, name, preset);

    this._presets[name] = newPreset;

    return newPreset;
  }

  /**
   * Import a fixture into the server pool. Fixture can be provided as a
   * fixture instance or as a configuration object
   * @version 1.0.0
   * @since   2.0.0
   * @param   {Fixture|Object|Array}  fixtures Fixture(s) to import
   * @return  {Server}               Server instance
   * @throws {FMFException} If fixture cannot be parsed
   */
  import(fixtures) {
    if (!(fixtures instanceof Array)) fixtures = [fixtures];

    for (let fixture of fixtures) {
      if (fixture instanceof Fixture) {
        fixture.server = this;
        this._fixtures.push(fixture);
      }
      else if (fixture instanceof Object) {
        let f = new Fixture(this);
        let conditions = fixture.on || fixture.when;

        if (!fixture.respond) throw new FMFException('Fixture provided as object must have a respond property');
        /* istanbul ignore else */
        if (conditions) f.on.equal(conditions);
        f.respond.set(fixture.respond);

        this._fixtures.push(f)
      }
      else throw new FMFException('Invalid fixture provided');
    }

    return this;
  }

  /**
   * This getter is used when configuring a fixture in-the-fly. It will return
   * and register a new Fixture and set it to `matching` mode
   * @version 1.0.0
   * @since   2.0.0
   * @return  {Fixture}  New Fixture
   */
  get on() {
    const fixture = new Fixture(this)

    this._fixtures.push(fixture);

    fixture._mode = 'on';

    return fixture.on;
  }

  /**
   * Alias for {@link Server#on}
   * @version 1.0.0
   * @since   2.0.0
   * @return  {Fixture}  New fixture
   */
  get when() {
    return this.on;
  }

  /**
   * Returns the existing registered on the server or create and register a new fallback fixture
   * to configure
   * @version 1.0.0
   * @since   2.0.0
   * @return  {Fixture}  Fallback fixture
   * @see {@link Server#_getDefaultFixture}
   */
  get fallback() {
    return this._getDefaultFixture();
  }

  /**
   * Returns the existing registered on the server or create a new fallback fixture
   * to configure
   * @version 1.0.0
   * @since   2.0.0
   * @return  {Fixture}  Fallback fixture
   */
  _getDefaultFixture() {
    // If a default fixture exists, return it
    const index = this._fixtures.findIndex(f => f._matcher === null);

    if (index >= 0) return this._fixtures[index];

    // Create a new default Fixture and register it
    const fixture = new Fixture(this);

    this._fixtures.push(fixture);
    return fixture;
  }

  /**
   * Process the respond call when called from a fixture to allow chainable
   * fixtures on-the-fly configuration
   * @version 1.0.0
   * @since   2.0.0
   * @param   {Object}  [fixture={}] Calling fixture or void object if not called from a fixture
   * @return  {Fixture}              Return either the default fixture or set the current to `respond` mode
   */
  _processRespond(fixture = {}) {
    if (fixture._mode === 'respond') fixture = this._getDefaultFixture();

    fixture._mode = 'respond';

    return fixture;
  }

  /**
   * Getter used when configuring fixture on-the-fly
   * @version 1.0.0
   * @since   2.0.0
   * @return  {Fixture}  Return either the default fixture or set the current to `respond` mode
   * @see {@link Server#_processRespond}
   */
  get respond() {
    return this._getDefaultFixture();
  }

  /**
   * Seeks for matching fixtures when processing a request
   *
   * An error will be raised if no fixtures have been set or if no matching fixtures have been
   * found.
   *
   * FMF will also send a warning to the console
   *
   * @version 1.0.0
   * @since   2.0.0
   * @param   {FMFRequest}  request Request
   * @return  {Promise}         Resolved in fixture instance
   * @throws  {FMFException}   If no fixtures are defined or no matching fixtures found
   */
  async _findFixture(request) {
    let matches = [];
    let fallback = null;

    if (!this._fixtures.length) throw new FMFException('No fixtures defined');

    for (let fixture of this._fixtures) {
      // Do not register fallback fixture
      if (fixture._matcher === null) {
        fallback = fixture;
        continue;
      }
      if (await fixture.match(request)) matches.push(fixture);
    }

    if (!matches.length) {
      if (!fallback) throw new FMFException('Unable to find a matching fixture for the current request and no fixture is set as fallback');
      matches[0] = fallback;
    }

    if (matches.length > 1) {
      this.warn(`FMF : Server found ${matches.length} fixtures matching the request "${request.url}". Using the first one.`); // eslint-disable-line
    }

    return matches[0];
  }

  /**
   * Process the incoming request and update history
   * @version 1.0.0
   * @since   2.0.0
   * @param   {String|Request}  request Incoming request
   * @param   {Object}  [init]  request options
   * @return  {Promise}         Response
   * @throws  {FMFException}  If request processing have failed
   */
  async _processRequest(request, init) {
    let response;

    try {
      // Build FMFRequest object
      request = new FMFRequest(request, init);

      // Log incoming request
      this.history.log(`Request : ${request.method} ${request.url}`)

      // Locate matching fixture
      let fixture = await this._findFixture(request.clone());

      // Prepare response
      response = await fixture.getResponse(request.clone());

      this.history.log(`Response sent (${response.status} ${response.statusText})`);
    } catch (err) {
      this.history.log(err.toString());

      if (this._warnOnError) this.warn(err);
      if (this._throwOnError) /* istanbul ignore next */ throw (err instanceof FMFException ? err : new FMFException('Request process failure', err));

      response = new Response(err.stack, {
        'content-type': 'text/html',
        status: 500,
        statusText: err.toString()
      })

      this.history.push(request.clone(), null);
    }

    // Store history
    this.history.push(request.clone(), response.clone());

    return response;
  }

  /**
   * Returs the number of calls made to server since start or last reset
   * @version 1.0.0
   * @since   2.0.0
   * @return  {Number}  Number of requests received
   */
  get calls() {
    return this.stub.callCount;
  }

  /**
   * Returns the last request received by the server
   * @version 1.0.0
   * @since   2.0.0
   * @return  {FMFRequest}
   * @see {@link ServerHistory}
   */
  get request() {
    return this.history.last.request;
  }

  /**
   * Returns the last response received by the server
   * @version 1.0.0
   * @since   2.0.0
   * @return  {FMFRequest}
   * @see {@link ServerHistory}
   */

  get response() {
    return this.history.last.response;
  }
}

export default Server;