HabitRPG/habitrpg

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

Summary

Maintainability
F
6 days
Test Coverage
import stripeModule from 'stripe';
import nconf from 'nconf';
import { v4 as uuid } from 'uuid';
import moment from 'moment';
import {
  generateGroup,
} from '../../../../../helpers/api-unit.helper';
import { model as User } from '../../../../../../website/server/models/user';
import stripePayments from '../../../../../../website/server/libs/payments/stripe';
import payments from '../../../../../../website/server/libs/payments/payments';
import common from '../../../../../../website/common';
import logger from '../../../../../../website/server/libs/logger';
import * as oneTimePayments from '../../../../../../website/server/libs/payments/stripe/oneTimePayments';
import * as subscriptions from '../../../../../../website/server/libs/payments/stripe/subscriptions';

const { i18n } = common;

describe('Stripe - Webhooks', () => {
  const stripe = stripeModule('test');
  const endpointSecret = nconf.get('STRIPE_WEBHOOKS_ENDPOINT_SECRET');
  const headers = {};
  const body = {};

  describe('all events', () => {
    let event;
    let constructEventStub;

    beforeEach(() => {
      event = { type: 'payment_intent.created' };
      constructEventStub = sandbox.stub(stripe.webhooks, 'constructEvent');
      constructEventStub.returns(event);
      sandbox.stub(logger, 'error');
    });

    it('throws if the event can\'t be validated', async () => {
      const err = new Error('fail');
      constructEventStub.throws(err);
      await expect(stripePayments.handleWebhooks({ body: event, headers }, stripe))
        .to.eventually.be.rejected.and.to.eql({
          httpCode: 400,
          name: 'BadRequest',
          message: `Webhook Error: ${err.message}`,
        });

      expect(logger.error).to.have.been.calledOnce;
      const calledWith = logger.error.getCall(0).args;
      expect(calledWith[0].message).to.equal('Error verifying Stripe webhook');
      expect(calledWith[1]).to.eql({ err });
    });

    it('logs an error if an unsupported webhook event is passed', async () => {
      event.type = 'account.updated';
      await expect(stripePayments.handleWebhooks({ body, headers }, stripe))
        .to.eventually.be.rejected.and.to.eql({
          httpCode: 400,
          name: 'BadRequest',
          message: `Missing handler for Stripe webhook ${event.type}`,
        });

      expect(logger.error).to.have.been.calledOnce;
      const calledWith = logger.error.getCall(0).args;
      expect(calledWith[0].message).to.equal('Error handling Stripe webhook');
      expect(calledWith[1].event).to.eql(event);
      expect(calledWith[1].err.message).to.eql(`Missing handler for Stripe webhook ${event.type}`);
    });

    it('retrieves and validates the event from Stripe', async () => {
      await stripePayments.handleWebhooks({ body, headers }, stripe);
      expect(stripe.webhooks.constructEvent).to.have.been.calledOnce;
      expect(stripe.webhooks.constructEvent)
        .to.have.been.calledWith(body, undefined, endpointSecret);
    });
  });

  describe('customer.subscription.deleted', () => {
    const eventType = 'customer.subscription.deleted';
    let event;
    let constructEventStub;

    beforeEach(() => {
      event = { type: eventType };
      constructEventStub = sandbox.stub(stripe.webhooks, 'constructEvent');
      constructEventStub.returns(event);
    });

    beforeEach(() => {
      sandbox.stub(stripe.customers, 'del').resolves({});
      sandbox.stub(payments, 'cancelSubscription').resolves({});
    });

    it('does not do anything if event.request is not null (subscription cancelled manually)', async () => {
      constructEventStub.returns({
        id: 123,
        type: eventType,
        request: { id: 123 },
      });

      await stripePayments.handleWebhooks({ body, headers }, stripe);

      expect(stripe.webhooks.constructEvent).to.have.been.calledOnce;
      expect(stripe.customers.del).to.not.have.been.called;
      expect(payments.cancelSubscription).to.not.have.been.called;
    });

    describe('user subscription', () => {
      it('throws an error if the user is not found', async () => {
        const customerId = 456;
        constructEventStub.returns({
          id: 123,
          type: eventType,
          data: {
            object: {
              plan: {
                id: 'basic_earned',
              },
              customer: customerId,
            },
          },
          request: { id: null },
        });

        await expect(stripePayments.handleWebhooks({ body, headers }, stripe))
          .to.eventually.be.rejectedWith({
            message: i18n.t('userNotFound'),
            httpCode: 404,
            name: 'NotFound',
          });

        expect(stripe.customers.del).to.not.have.been.called;
        expect(payments.cancelSubscription).to.not.have.been.called;
      });

      it('deletes the customer on Stripe and calls payments.cancelSubscription', async () => {
        const customerId = '456';

        const subscriber = new User();
        subscriber.purchased.plan.customerId = customerId;
        subscriber.purchased.plan.paymentMethod = 'Stripe';
        await subscriber.save();

        constructEventStub.returns({
          id: 123,
          type: eventType,
          data: {
            object: {
              plan: {
                id: 'basic_earned',
              },
              customer: customerId,
            },
          },
          request: { id: null },
        });

        await stripePayments.handleWebhooks({ body, headers }, stripe);

        expect(stripe.customers.del).to.have.been.calledOnce;
        expect(stripe.customers.del).to.have.been.calledWith(customerId);
        expect(payments.cancelSubscription).to.have.been.calledOnce;

        const cancelSubscriptionOpts = payments.cancelSubscription.lastCall.args[0];
        expect(cancelSubscriptionOpts.user._id).to.equal(subscriber._id);
        expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe');
        expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3);
        expect(cancelSubscriptionOpts.groupId).to.be.undefined;
      });
    });

    describe('group plan subscription', () => {
      it('throws an error if the group is not found', async () => {
        const customerId = 456;
        constructEventStub.returns({
          id: 123,
          type: eventType,
          data: {
            object: {
              plan: {
                id: 'group_monthly',
              },
              customer: customerId,
            },
          },
          request: { id: null },
        });

        await expect(stripePayments.handleWebhooks({ body, headers }, stripe))
          .to.eventually.be.rejectedWith({
            message: i18n.t('groupNotFound'),
            httpCode: 404,
            name: 'NotFound',
          });

        expect(stripe.customers.del).to.not.have.been.called;
        expect(payments.cancelSubscription).to.not.have.been.called;
      });

      it('throws an error if the group leader is not found', async () => {
        const customerId = 456;

        const subscriber = generateGroup({
          name: 'test group',
          type: 'guild',
          privacy: 'public',
          leader: uuid(),
        });
        subscriber.purchased.plan.customerId = customerId;
        subscriber.purchased.plan.paymentMethod = 'Stripe';
        await subscriber.save();

        constructEventStub.returns({
          id: 123,
          type: eventType,
          data: {
            object: {
              plan: {
                id: 'group_monthly',
              },
              customer: customerId,
            },
          },
          request: { id: null },
        });

        await expect(stripePayments.handleWebhooks({ body, headers }, stripe))
          .to.eventually.be.rejectedWith({
            message: i18n.t('userNotFound'),
            httpCode: 404,
            name: 'NotFound',
          });

        expect(stripe.customers.del).to.not.have.been.called;
        expect(payments.cancelSubscription).to.not.have.been.called;
      });

      it('deletes the customer on Stripe and calls payments.cancelSubscription', async () => {
        const customerId = '456';

        const leader = new User();
        await leader.save();

        const subscriber = generateGroup({
          name: 'test group',
          type: 'guild',
          privacy: 'public',
          leader: leader._id,
        });
        subscriber.purchased.plan.customerId = customerId;
        subscriber.purchased.plan.paymentMethod = 'Stripe';
        await subscriber.save();

        constructEventStub.returns({
          id: 123,
          type: eventType,
          data: {
            object: {
              plan: {
                id: 'group_monthly',
              },
              customer: customerId,
            },
          },
          request: { id: null },
        });

        await stripePayments.handleWebhooks({ body, headers }, stripe);

        expect(stripe.customers.del).to.have.been.calledOnce;
        expect(stripe.customers.del).to.have.been.calledWith(customerId);
        expect(payments.cancelSubscription).to.have.been.calledOnce;

        const cancelSubscriptionOpts = payments.cancelSubscription.lastCall.args[0];
        expect(cancelSubscriptionOpts.user._id).to.equal(leader._id);
        expect(cancelSubscriptionOpts.paymentMethod).to.equal('Stripe');
        expect(Math.round(moment(cancelSubscriptionOpts.nextBill).diff(new Date(), 'days', true))).to.equal(3);
        expect(cancelSubscriptionOpts.groupId).to.equal(subscriber._id);
      });
    });
  });

  describe('checkout.session.completed', () => {
    const eventType = 'checkout.session.completed';
    let event;
    let constructEventStub;
    const session = {};

    beforeEach(() => {
      session.metadata = {};
      event = { type: eventType, data: { object: session } };
      constructEventStub = sandbox.stub(stripe.webhooks, 'constructEvent');
      constructEventStub.returns(event);

      sandbox.stub(oneTimePayments, 'applyGemPayment').resolves({});
      sandbox.stub(subscriptions, 'applySubscription').resolves({});
      sandbox.stub(subscriptions, 'handlePaymentMethodChange').resolves({});
    });

    it('handles changing an user sub', async () => {
      session.metadata.type = 'edit-card-user';

      await stripePayments.handleWebhooks({ body, headers }, stripe);

      expect(stripe.webhooks.constructEvent).to.have.been.calledOnce;
      expect(subscriptions.handlePaymentMethodChange).to.have.been.calledOnce;
      expect(subscriptions.handlePaymentMethodChange).to.have.been.calledWith(session);
    });

    it('handles changing a group sub', async () => {
      session.metadata.type = 'edit-card-group';

      await stripePayments.handleWebhooks({ body, headers }, stripe);

      expect(stripe.webhooks.constructEvent).to.have.been.calledOnce;
      expect(subscriptions.handlePaymentMethodChange).to.have.been.calledOnce;
      expect(subscriptions.handlePaymentMethodChange).to.have.been.calledWith(session);
    });

    it('applies a subscription', async () => {
      session.metadata.type = 'subscription';

      await stripePayments.handleWebhooks({ body, headers }, stripe);

      expect(stripe.webhooks.constructEvent).to.have.been.calledOnce;
      expect(subscriptions.applySubscription).to.have.been.calledOnce;
      expect(subscriptions.applySubscription).to.have.been.calledWith(session);
    });

    it('handles a one time payment', async () => {
      session.metadata.type = 'something else';

      await stripePayments.handleWebhooks({ body, headers }, stripe);

      expect(stripe.webhooks.constructEvent).to.have.been.calledOnce;
      expect(oneTimePayments.applyGemPayment).to.have.been.calledOnce;
      expect(oneTimePayments.applyGemPayment).to.have.been.calledWith(session);
    });
  });
});