HabitRPG/habitrpg

View on GitHub
test/api/unit/libs/webhooks.test.js

Summary

Maintainability
F
5 days
Test Coverage
import got from 'got';
import moment from 'moment';
import {
  WebhookSender,
  taskScoredWebhook,
  groupChatReceivedWebhook,
  taskActivityWebhook,
  questActivityWebhook,
  userActivityWebhook,
} from '../../../../website/server/libs/webhook';
import {
  model as User,
} from '../../../../website/server/models/user';
import {
  generateUser,
  defer,
  sleep,
} from '../../../helpers/api-unit.helper';
import logger from '../../../../website/server/libs/logger';

describe('webhooks', () => {
  let webhooks; let
    user;

  beforeEach(() => {
    sandbox.stub(got, 'post').returns(defer().promise);

    webhooks = [{
      id: 'taskActivity',
      url: 'http://task-scored.com',
      enabled: true,
      type: 'taskActivity',
      options: {
        created: true,
        updated: true,
        deleted: true,
        scored: true,
        checklistScored: true,
      },
    }, {
      id: 'questActivity',
      url: 'http://quest-activity.com',
      enabled: true,
      type: 'questActivity',
      options: {
        questStarted: true,
        questFinised: true,
        questInvited: true,
      },
    }, {
      id: 'userActivity',
      url: 'http://user-activity.com',
      enabled: true,
      type: 'userActivity',
      options: {
        petHatched: true,
        mountRaised: true,
        leveledUp: true,
      },
    }, {
      id: 'groupChatReceived',
      url: 'http://group-chat-received.com',
      enabled: true,
      type: 'groupChatReceived',
      options: {
        groupId: 'group-id',
      },
    }];

    user = generateUser();
    user.webhooks = webhooks;
  });

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

  describe('WebhookSender', () => {
    it('creates a new WebhookSender object', () => {
      const sendWebhook = new WebhookSender({
        type: 'custom',
      });

      expect(sendWebhook.type).to.equal('custom');
      expect(sendWebhook).to.respondTo('send');
    });

    it('provides default function for data transformation', () => {
      sandbox.spy(WebhookSender, 'defaultTransformData');
      const sendWebhook = new WebhookSender({
        type: 'custom',
      });

      const body = { foo: 'bar' };

      user.webhooks = [{
        id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom',
      }];
      sendWebhook.send(user, body);

      expect(WebhookSender.defaultTransformData).to.be.calledOnce;
      expect(got.post).to.be.calledOnce;
      expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
        json: body,
      });
    });

    it('adds default data (user and webhookType) to the body', () => {
      const sendWebhook = new WebhookSender({
        type: 'custom',
      });
      sandbox.spy(sendWebhook, 'attachDefaultData');

      const body = { foo: 'bar' };

      user.webhooks = [{
        id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom',
      }];
      sendWebhook.send(user, body);

      expect(sendWebhook.attachDefaultData).to.be.calledOnce;
      expect(got.post).to.be.calledOnce;
      expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
        json: body,
      });

      expect(body).to.eql({
        foo: 'bar',
        user: { _id: user._id },
        webhookType: 'custom',
      });
    });

    it('can pass in a data transformation function', () => {
      sandbox.spy(WebhookSender, 'defaultTransformData');
      const sendWebhook = new WebhookSender({
        type: 'custom',
        transformData (data) {
          const dataToSend = { baz: 'biz', ...data };

          return dataToSend;
        },
      });

      const body = { foo: 'bar' };

      user.webhooks = [{
        id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom',
      }];
      sendWebhook.send(user, body);

      expect(WebhookSender.defaultTransformData).to.not.be.called;
      expect(got.post).to.be.calledOnce;
      expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
        json: {
          foo: 'bar',
          baz: 'biz',
        },
      });
    });

    it('provides a default filter function', () => {
      sandbox.spy(WebhookSender, 'defaultWebhookFilter');
      const sendWebhook = new WebhookSender({
        type: 'custom',
      });

      const body = { foo: 'bar' };

      user.webhooks = [{
        id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom',
      }];
      sendWebhook.send(user, body);

      expect(WebhookSender.defaultWebhookFilter).to.be.calledOnce;
    });

    it('can pass in a webhook filter function', () => {
      sandbox.spy(WebhookSender, 'defaultWebhookFilter');
      const sendWebhook = new WebhookSender({
        type: 'custom',
        webhookFilter (hook) {
          return hook.url !== 'http://custom-url.com';
        },
      });

      const body = { foo: 'bar' };

      user.webhooks = [{
        id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom',
      }];
      sendWebhook.send(user, body);

      expect(WebhookSender.defaultWebhookFilter).to.not.be.called;
      expect(got.post).to.not.be.called;
    });

    it('can pass in a webhook filter function that filters on data', () => {
      sandbox.spy(WebhookSender, 'defaultWebhookFilter');
      const sendWebhook = new WebhookSender({
        type: 'custom',
        webhookFilter (hook, data) {
          return hook.options.foo === data.foo;
        },
      });

      const body = { foo: 'bar' };

      user.webhooks = [
        {
          id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom', options: { foo: 'bar' },
        },
        {
          id: 'other-custom-webhook', url: 'http://other-custom-url.com', enabled: true, type: 'custom', options: { foo: 'foo' },
        },
      ];
      sendWebhook.send(user, body);

      expect(got.post).to.be.calledOnce;
      expect(got.post).to.be.calledWithMatch('http://custom-url.com');
    });

    it('ignores disabled webhooks', () => {
      const sendWebhook = new WebhookSender({
        type: 'custom',
      });

      const body = { foo: 'bar' };

      user.webhooks = [{
        id: 'custom-webhook', url: 'http://custom-url.com', enabled: false, type: 'custom',
      }];
      sendWebhook.send(user, body);

      expect(got.post).to.not.be.called;
    });

    it('ignores webhooks with invalid urls', () => {
      const sendWebhook = new WebhookSender({
        type: 'custom',
      });

      const body = { foo: 'bar' };

      user.webhooks = [{
        id: 'custom-webhook', url: 'httxp://custom-url!!!', enabled: true, type: 'custom',
      }];
      sendWebhook.send(user, body);

      expect(got.post).to.not.be.called;
    });

    it('ignores webhooks of other types', () => {
      const sendWebhook = new WebhookSender({
        type: 'custom',
      });

      const body = { foo: 'bar' };

      user.webhooks = [
        {
          id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom',
        },
        {
          id: 'other-webhook', url: 'http://other-url.com', enabled: true, type: 'other',
        },
      ];
      sendWebhook.send(user, body);

      expect(got.post).to.be.calledOnce;
      expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
        json: body,
      });
    });

    it('sends every type of activity to global webhooks', () => {
      const sendWebhook = new WebhookSender({
        type: 'custom',
      });

      const body = { foo: 'bar' };

      user.webhooks = [
        {
          id: 'global-webhook', url: 'http://custom-url.com', enabled: true, type: 'globalActivity',
        },
      ];
      sendWebhook.send(user, body);

      expect(got.post).to.be.calledOnce;
      expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
        json: body,
      });
    });

    it('sends multiple webhooks of the same type', () => {
      const sendWebhook = new WebhookSender({
        type: 'custom',
      });

      const body = { foo: 'bar' };

      user.webhooks = [
        {
          id: 'custom-webhook', url: 'http://custom-url.com', enabled: true, type: 'custom',
        },
        {
          id: 'other-custom-webhook', url: 'http://other-url.com', enabled: true, type: 'custom',
        },
      ];
      sendWebhook.send(user, body);

      expect(got.post).to.be.calledTwice;
      expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
        json: body,
      });
      expect(got.post).to.be.calledWithMatch('http://other-url.com', {
        json: body,
      });
    });

    describe('failures', () => {
      let sendWebhook;

      beforeEach(async () => {
        sandbox.restore();
        sandbox.stub(got, 'post').returns(Promise.reject());

        sendWebhook = new WebhookSender({ type: 'taskActivity' });
        user.webhooks = [{
          url: 'http://custom-url.com', enabled: true, type: 'taskActivity',
        }];
        await user.save();

        expect(user.webhooks[0].failures).to.equal(0);
        expect(user.webhooks[0].lastFailureAt).to.equal(undefined);
      });

      it('does not increase failures counter if request is successfull', async () => {
        sandbox.restore();
        sandbox.stub(got, 'post').returns(Promise.resolve());

        const body = {};
        sendWebhook.send(user, body);

        expect(got.post).to.be.calledOnce;
        expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
          json: body,
        });

        await sleep(0.1);
        user = await User.findById(user._id).exec();

        expect(user.webhooks[0].failures).to.equal(0);
        expect(user.webhooks[0].lastFailureAt).to.equal(undefined);
      });

      it('records failures', async () => {
        sinon.stub(logger, 'error');
        const body = {};
        sendWebhook.send(user, body);

        expect(got.post).to.be.calledOnce;
        expect(got.post).to.be.calledWithMatch('http://custom-url.com', {
          json: body,
        });

        await sleep(0.1);
        user = await User.findById(user._id).exec();

        expect(user.webhooks[0].failures).to.equal(1);
        expect((Date.now() - user.webhooks[0].lastFailureAt.getTime()) < 10000).to.be.true;

        expect(logger.error).to.be.calledOnce;
        logger.error.restore();
      });

      it('disables a webhook after 10 failures', async () => {
        const times = 10;
        for (let i = 0; i < times; i += 1) {
          sendWebhook.send(user, {});
          await sleep(0.1); // eslint-disable-line no-await-in-loop
          user = await User.findById(user._id).exec(); // eslint-disable-line no-await-in-loop
        }

        expect(got.post).to.be.callCount(10);
        expect(got.post).to.be.calledWithMatch('http://custom-url.com');

        await sleep(0.1);
        user = await User.findById(user._id).exec();

        expect(user.webhooks[0].enabled).to.equal(false);
        expect(user.webhooks[0].failures).to.equal(0);
      });

      it('resets failures after a month ', async () => {
        const oneMonthAgo = moment().subtract(1, 'months').subtract(1, 'days').toDate();
        user.webhooks[0].lastFailureAt = oneMonthAgo;
        user.webhooks[0].failures = 9;

        await user.save();

        sendWebhook.send(user, []);

        expect(got.post).to.be.calledOnce;
        expect(got.post).to.be.calledWithMatch('http://custom-url.com');

        await sleep(0.1);
        user = await User.findById(user._id).exec();

        expect(user.webhooks[0].failures).to.equal(1);
        // Check that the stored date is whitin 10s from now
        expect((Date.now() - user.webhooks[0].lastFailureAt.getTime()) < 10000).to.be.true;
      });
    });
  });

  describe('taskScoredWebhook', () => {
    let data;

    beforeEach(() => {
      data = {
        user: {
          _tmp: { foo: 'bar' },
          stats: {
            lvl: 5,
            int: 10,
            str: 5,
            exp: 423,
            toJSON () {
              return this;
            },
          },
        },
        task: {
          text: 'text',
        },
        direction: 'up',
        delta: 176,
      };

      const mockStats = {
        maxHealth: 50,
        maxMP: 103,
        toNextLevel: 40,
        ...data.user.stats,
      };
      delete mockStats.toJSON;

      sandbox.stub(User, 'addComputedStatsToJSONObj').returns(mockStats);
    });

    it('sends task and stats data', () => {
      taskScoredWebhook.send(user, data);

      expect(got.post).to.be.calledOnce;
      expect(got.post).to.be.calledWithMatch(webhooks[0].url, {
        json: {
          type: 'scored',
          webhookType: 'taskActivity',
          user: {
            _id: user._id,
            _tmp: { foo: 'bar' },
            stats: {
              lvl: 5,
              int: 10,
              str: 5,
              exp: 423,
              toNextLevel: 40,
              maxHealth: 50,
              maxMP: 103,
            },
          },
          task: {
            text: 'text',
          },
          direction: 'up',
          delta: 176,
        },
      });
    });

    it('sends task and stats data to globalActivity webhookd', () => {
      user.webhooks = [{
        id: 'globalActivity',
        url: 'http://global-activity.com',
        enabled: true,
        type: 'globalActivity',
      }];

      taskScoredWebhook.send(user, data);

      expect(got.post).to.be.calledOnce;
      expect(got.post).to.be.calledWithMatch('http://global-activity.com', {
        json: {
          type: 'scored',
          webhookType: 'taskActivity',
          user: {
            _id: user._id,
            _tmp: { foo: 'bar' },
            stats: {
              lvl: 5,
              int: 10,
              str: 5,
              exp: 423,
              toNextLevel: 40,
              maxHealth: 50,
              maxMP: 103,
            },
          },
          task: {
            text: 'text',
          },
          direction: 'up',
          delta: 176,
        },
      });
    });

    it('does not send task scored data if scored option is not true', () => {
      webhooks[0].options.scored = false;

      taskScoredWebhook.send(user, data);

      expect(got.post).to.not.be.called;
    });
  });

  describe('taskActivityWebhook', () => {
    let data;

    beforeEach(() => {
      data = {
        task: {
          text: 'text',
        },
      };
    });

    ['created', 'updated', 'deleted'].forEach(type => {
      it(`sends ${type} tasks`, () => {
        data.type = type;

        taskActivityWebhook.send(user, data);

        expect(got.post).to.be.calledOnce;
        expect(got.post).to.be.calledWithMatch(webhooks[0].url, {
          json: {
            type,
            webhookType: 'taskActivity',
            user: {
              _id: user._id,
            },
            task: data.task,
          },
        });
      });

      it(`does not send task ${type} data if ${type} option is not true`, () => {
        data.type = type;
        webhooks[0].options[type] = false;

        taskActivityWebhook.send(user, data);

        expect(got.post).to.not.be.called;
      });
    });

    describe('checklistScored', () => {
      beforeEach(() => {
        data = {
          task: {
            text: 'text',
          },
          item: {
            text: 'item-text',
          },
        };
      });

      it('sends \'checklistScored\' tasks', () => {
        data.type = 'checklistScored';

        taskActivityWebhook.send(user, data);

        expect(got.post).to.be.calledOnce;
        expect(got.post).to.be.calledWithMatch(webhooks[0].url, {
          json: {
            webhookType: 'taskActivity',
            user: {
              _id: user._id,
            },
            type: data.type,
            task: data.task,
            item: data.item,
          },
        });
      });

      it('does not send task \'checklistScored\' data if \'checklistScored\' option is not true', () => {
        data.type = 'checklistScored';
        webhooks[0].options.checklistScored = false;

        taskActivityWebhook.send(user, data);

        expect(got.post).to.not.be.called;
      });
    });
  });

  describe('userActivityWebhook', () => {
    let data;

    beforeEach(() => {
      data = {
        something: true,
      };
    });

    ['petHatched', 'mountRaised', 'leveledUp'].forEach(type => {
      it(`sends ${type} webhooks`, () => {
        data.type = type;

        userActivityWebhook.send(user, data);

        expect(got.post).to.be.calledOnce;
        expect(got.post).to.be.calledWithMatch(webhooks[2].url, {
          json: {
            type,
            webhookType: 'userActivity',
            user: {
              _id: user._id,
            },
            something: true,
          },
        });
      });

      it(`does not send webhook ${type} data if ${type} option is not true`, () => {
        data.type = type;
        webhooks[2].options[type] = false;

        userActivityWebhook.send(user, data);

        expect(got.post).to.not.be.called;
      });
    });
  });

  describe('questActivityWebhook', () => {
    let data;

    beforeEach(() => {
      data = {
        group: {
          id: 'group-id',
          name: 'some group',
          otherData: 'foo',
          quest: {},
        },
        quest: {
          key: 'some-key',
          questOwner: 'user-id',
        },
      };
    });

    ['questStarted', 'questFinised', 'questInvited'].forEach(type => {
      it(`sends ${type} webhooks`, () => {
        data.type = type;

        questActivityWebhook.send(user, data);

        expect(got.post).to.be.calledOnce;
        expect(got.post).to.be.calledWithMatch(webhooks[1].url, {
          json: {
            type,
            webhookType: 'questActivity',
            user: {
              _id: user._id,
            },
            group: {
              id: 'group-id',
              name: 'some group',
            },
            quest: {
              key: 'some-key',
            },
          },
        });
      });

      it(`does not send webhook ${type} data if ${type} option is not true`, () => {
        data.type = type;
        webhooks[1].options[type] = false;

        userActivityWebhook.send(user, data);

        expect(got.post).to.not.be.called;
      });
    });
  });

  describe('groupChatReceivedWebhook', () => {
    it('sends chat data', () => {
      const data = {
        group: {
          id: 'group-id',
          name: 'some group',
          otherData: 'foo',
        },
        chat: {
          id: 'some-id',
          text: 'message',
        },
      };

      groupChatReceivedWebhook.send(user, data);

      expect(got.post).to.be.calledOnce;
      expect(got.post).to.be.calledWithMatch(webhooks[webhooks.length - 1].url, {
        json: {
          webhookType: 'groupChatReceived',
          user: {
            _id: user._id,
          },
          group: {
            id: 'group-id',
            name: 'some group',
          },
          chat: {
            id: 'some-id',
            text: 'message',
          },
        },
      });
    });

    it('does not send chat data for group if not selected', () => {
      const data = {
        group: {
          id: 'not-group-id',
          name: 'some group',
          otherData: 'foo',
        },
        chat: {
          id: 'some-id',
          text: 'message',
        },
      };

      groupChatReceivedWebhook.send(user, data);

      expect(got.post).to.not.be.called;
    });
  });
});