mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-27 22:49:56 -05:00
abb3ee48e4
refs https://github.com/TryGhost/Team/issues/1372 - Added tests for Stripe webhooks that cancel a subscription (paid and complimentary) - Tests for manually adding a complimentary member, and removing it again (the 'new' way) - Added tests for creating new members (paid, complimentary) - Includes tests for the `stripe_customer_id` property when creating new members (this is broken, fixed by https://github.com/TryGhost/Members/pull/378#issuecomment-1070835800) - Deduplicated some nock code for Stripe - Improved event assertions and interference between multiple tests in the giant members test file (by asserting only for the affected member id instead of all events).
663 lines
25 KiB
JavaScript
663 lines
25 KiB
JavaScript
const assert = require('assert');
|
|
const nock = require('nock');
|
|
const should = require('should');
|
|
const stripe = require('stripe');
|
|
const {MemberStatusEvent} = require('../../../core/server/models/member-status-event');
|
|
const {Product} = require('../../../core/server/models/product');
|
|
const {agentProvider, mockManager, fixtureManager} = require('../../utils/e2e-framework');
|
|
const models = require('../../../core/server/models');
|
|
|
|
let membersAgent;
|
|
let adminAgent;
|
|
|
|
async function getPaidProduct() {
|
|
return await Product.findOne({type: 'paid'});
|
|
}
|
|
|
|
async function assertMemberEvents({eventType, memberId, asserts}) {
|
|
const events = await models[eventType].where('member_id', memberId).fetchAll();
|
|
events.map(e => e.toJSON()).should.match(asserts);
|
|
assert.equal(events.length, asserts.length, `Only ${asserts.length} ${eventType} should have been added.`);
|
|
}
|
|
|
|
describe('Members API', function () {
|
|
before(async function () {
|
|
const agents = await agentProvider.getAgentsForMembers();
|
|
membersAgent = agents.membersAgent;
|
|
adminAgent = agents.adminAgent;
|
|
|
|
await fixtureManager.init('members');
|
|
await adminAgent.loginAsOwner();
|
|
});
|
|
|
|
beforeEach(function () {
|
|
mockManager.mockLabsEnabled('multipleProducts');
|
|
mockManager.mockMail();
|
|
mockManager.mockStripe();
|
|
});
|
|
|
|
afterEach(function () {
|
|
mockManager.restore();
|
|
});
|
|
|
|
it('Can communicate with the frontend Members API', async function () {
|
|
await membersAgent.get('/api/site/')
|
|
.expectStatus(200);
|
|
});
|
|
|
|
// @todo: Test what happens when a complementary subscription ends (should create comped -> free event)
|
|
// @todo: Test what happens when a complementary subscription starts a paid subscription
|
|
|
|
describe('/webhooks/stripe/', function () {
|
|
// We create some shared stripe resources, so we don't have to have nocks in every test case
|
|
const subscription = {};
|
|
const customer = {};
|
|
const paymentMethod = {};
|
|
const setupIntent = {};
|
|
|
|
beforeEach(function () {
|
|
nock('https://api.stripe.com')
|
|
.persist()
|
|
.get(/v1\/.*/)
|
|
.reply((uri, body) => {
|
|
const [match, resource, id] = uri.match(/\/?v1\/(\w+)\/?(\w+)/) || [null];
|
|
|
|
if (!match) {
|
|
return [500];
|
|
}
|
|
|
|
if (resource === 'setup_intents') {
|
|
return [200, setupIntent];
|
|
}
|
|
|
|
if (resource === 'customers') {
|
|
if (customer.id !== id) {
|
|
return [404];
|
|
}
|
|
return [200, customer];
|
|
}
|
|
|
|
if (resource === 'subscriptions') {
|
|
if (subscription.id !== id) {
|
|
return [404];
|
|
}
|
|
return [200, subscription];
|
|
}
|
|
});
|
|
|
|
nock('https://api.stripe.com')
|
|
.persist()
|
|
.post(/v1\/.*/)
|
|
.reply((uri, body) => {
|
|
const [match, resource, id, action] = uri.match(/\/?v1\/(\w+)(?:\/?(\w+)){0,2}/) || [null];
|
|
|
|
if (!match) {
|
|
return [500];
|
|
}
|
|
|
|
if (resource === 'payment_methods') {
|
|
return [200, paymentMethod];
|
|
}
|
|
|
|
if (resource === 'subscriptions') {
|
|
return [200, subscription];
|
|
}
|
|
|
|
if (resource === 'customers') {
|
|
return [200, customer];
|
|
}
|
|
|
|
return [500];
|
|
});
|
|
});
|
|
|
|
afterEach(function () {
|
|
nock.cleanAll();
|
|
});
|
|
|
|
// Helper methods to update the customer and subscription
|
|
function set(object, newValues) {
|
|
for (const key of Object.keys(object)) {
|
|
delete object[key];
|
|
}
|
|
Object.assign(object, newValues);
|
|
}
|
|
|
|
/**
|
|
* Helper method to create an existing member based on a customer in stripe (= current customer)
|
|
*/
|
|
async function createMemberFromStripe() {
|
|
const initialMember = {
|
|
name: customer.name,
|
|
email: customer.email,
|
|
subscribed: true,
|
|
stripe_customer_id: customer.id
|
|
};
|
|
|
|
const {body} = await adminAgent
|
|
.post(`/members/`)
|
|
.body({members: [initialMember]})
|
|
.expectStatus(201);
|
|
assert.equal(body.members.length, 1, 'The member was not created');
|
|
const member = body.members[0];
|
|
return member;
|
|
}
|
|
|
|
it('Responds with a 401 when the signature is invalid', async function () {
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body({
|
|
fake: 'data'
|
|
})
|
|
.header('stripe-signature', 'dodgy')
|
|
.expectStatus(401);
|
|
});
|
|
|
|
it('Responds with a 200 to unknown events with valid signature', async function () {
|
|
const webhookPayload = JSON.stringify({
|
|
type: 'unknown',
|
|
data: {
|
|
id: 'id_123'
|
|
}
|
|
});
|
|
const webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature)
|
|
.expectStatus(200);
|
|
});
|
|
|
|
describe('Handling the end of subscriptions', function () {
|
|
let canceledPaidMember;
|
|
|
|
it('Handles cancellation of paid subscriptions correctly', async function () {
|
|
const customer_id = 'cust_3432';
|
|
const subscription_id = 'sub_3432';
|
|
|
|
// Create a new subscription in Stripe
|
|
set(subscription, {
|
|
id: subscription_id,
|
|
customer: customer_id,
|
|
status: 'active',
|
|
items: {
|
|
type: 'list',
|
|
data: [{
|
|
id: 'item_123',
|
|
price: {
|
|
id: 'price_123',
|
|
product: 'product_123',
|
|
active: true,
|
|
nickname: 'Monthly',
|
|
currency: 'USD',
|
|
recurring: {
|
|
interval: 'month'
|
|
},
|
|
unit_amount: 500,
|
|
type: 'recurring'
|
|
}
|
|
}]
|
|
},
|
|
start_date: Date.now() / 1000,
|
|
current_period_end: Date.now() / 1000 + (60 * 60 * 24 * 31),
|
|
cancel_at_period_end: false
|
|
});
|
|
|
|
// Create a new customer in Stripe
|
|
set(customer, {
|
|
id: customer_id,
|
|
name: 'Test Member',
|
|
email: 'cancel-paid-test@email.com',
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: [subscription]
|
|
}
|
|
});
|
|
|
|
// Make sure this customer has a corresponding member in the database
|
|
// And all the subscriptions are setup correctly
|
|
const initialMember = await createMemberFromStripe();
|
|
assert.equal(initialMember.status, 'paid', 'The member initial status should be paid');
|
|
assert.equal(initialMember.products.length, 1, 'The member should have one product');
|
|
should(initialMember.subscriptions).match([
|
|
{
|
|
status: 'active'
|
|
}
|
|
]);
|
|
|
|
// Cancel the previously created subscription in Stripe
|
|
set(subscription, {
|
|
...subscription,
|
|
status: 'canceled'
|
|
});
|
|
|
|
// Send the webhook call to anounce the cancelation
|
|
const webhookPayload = JSON.stringify({
|
|
type: 'customer.subscription.updated',
|
|
data: {
|
|
object: subscription
|
|
}
|
|
});
|
|
const webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature)
|
|
.expectStatus(200);
|
|
|
|
// Check status has been updated to 'free' after cancelling
|
|
const {body: body2} = await adminAgent.get('/members/' + initialMember.id + '/');
|
|
assert.equal(body2.members.length, 1, 'The member does not exist');
|
|
const updatedMember = body2.members[0];
|
|
assert.equal(updatedMember.status, 'free');
|
|
assert.equal(updatedMember.products.length, 0, 'The member should have no products');
|
|
should(updatedMember.subscriptions).match([
|
|
{
|
|
status: 'canceled'
|
|
}
|
|
]);
|
|
|
|
// Check the status events for this newly created member (should be NULL -> paid only)
|
|
assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [
|
|
{
|
|
from_status: null,
|
|
to_status: 'free'
|
|
},
|
|
{
|
|
from_status: 'free',
|
|
to_status: 'paid'
|
|
},
|
|
{
|
|
from_status: 'paid',
|
|
to_status: 'free'
|
|
}
|
|
]
|
|
});
|
|
|
|
assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [
|
|
{
|
|
mrr_delta: 500
|
|
},
|
|
{
|
|
mrr_delta: -500
|
|
}
|
|
]
|
|
});
|
|
|
|
canceledPaidMember = updatedMember;
|
|
});
|
|
|
|
it('Can create a comlimentary subscription after canceling a paid subscription', async function () {
|
|
const product = await getPaidProduct();
|
|
|
|
const compedPayload = {
|
|
id: canceledPaidMember.id,
|
|
products: [
|
|
{
|
|
id: product.id
|
|
}
|
|
]
|
|
};
|
|
|
|
const {body} = await adminAgent
|
|
.put(`/members/${canceledPaidMember.id}/`)
|
|
.body({members: [compedPayload]})
|
|
.expectStatus(200);
|
|
|
|
const updatedMember = body.members[0];
|
|
assert.equal(updatedMember.status, 'comped', 'A comped member should have the comped status');
|
|
assert.equal(updatedMember.products.length, 1, 'The member should have one product');
|
|
should(updatedMember.subscriptions).match([
|
|
{
|
|
status: 'canceled'
|
|
},
|
|
{
|
|
status: 'active'
|
|
}
|
|
]);
|
|
assert.equal(updatedMember.subscriptions.length, 2, 'The member should have two subscriptions');
|
|
|
|
// Check the status events for this newly created member (should be NULL -> paid only)
|
|
assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [
|
|
{
|
|
from_status: null,
|
|
to_status: 'free'
|
|
},
|
|
{
|
|
from_status: 'free',
|
|
to_status: 'paid'
|
|
},
|
|
{
|
|
from_status: 'paid',
|
|
to_status: 'free'
|
|
},
|
|
{
|
|
from_status: 'free',
|
|
to_status: 'comped'
|
|
}
|
|
]
|
|
});
|
|
|
|
assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [
|
|
{
|
|
mrr_delta: 500
|
|
},
|
|
{
|
|
mrr_delta: -500
|
|
}
|
|
]
|
|
});
|
|
});
|
|
|
|
it('Handles cancellation of old fashioned comped subscriptions correctly', async function () {
|
|
const customer_id = 'cust_3433';
|
|
const subscription_id = 'sub_3433';
|
|
|
|
const price = {
|
|
id: 'price_123',
|
|
product: 'product_123',
|
|
active: true,
|
|
nickname: 'Complimentary',
|
|
currency: 'USD',
|
|
recurring: {
|
|
interval: 'month'
|
|
},
|
|
unit_amount: 0,
|
|
type: 'recurring'
|
|
};
|
|
|
|
// Create a new subscription in Stripe
|
|
set(subscription, {
|
|
id: subscription_id,
|
|
customer: customer_id,
|
|
status: 'active',
|
|
items: {
|
|
type: 'list',
|
|
data: [{
|
|
id: 'item_123',
|
|
price
|
|
}]
|
|
},
|
|
plan: price, // Old stripe thing
|
|
start_date: Date.now() / 1000,
|
|
current_period_end: Date.now() / 1000 + (60 * 60 * 24 * 31),
|
|
cancel_at_period_end: false
|
|
});
|
|
|
|
// Create a new customer in Stripe
|
|
set(customer, {
|
|
id: customer_id,
|
|
name: 'Test Member',
|
|
email: 'cancel-complementary-test@email.com',
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: [subscription]
|
|
}
|
|
});
|
|
|
|
// Make sure this customer has a corresponding member in the database
|
|
// And all the subscriptions are setup correctly
|
|
const initialMember = await createMemberFromStripe();
|
|
assert.equal(initialMember.status, 'comped', 'The member initial status should be comped');
|
|
assert.equal(initialMember.products.length, 1, 'The member should have one product');
|
|
should(initialMember.subscriptions).match([
|
|
{
|
|
status: 'active'
|
|
}
|
|
]);
|
|
|
|
// Cancel the previously created subscription in Stripe
|
|
set(subscription, {
|
|
...subscription,
|
|
status: 'canceled'
|
|
});
|
|
|
|
// Send the webhook call to anounce the cancelation
|
|
const webhookPayload = JSON.stringify({
|
|
type: 'customer.subscription.updated',
|
|
data: {
|
|
object: subscription
|
|
}
|
|
});
|
|
const webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature)
|
|
.expectStatus(200);
|
|
|
|
// Check status has been updated to 'free' after cancelling
|
|
const {body: body2} = await adminAgent.get('/members/' + initialMember.id + '/');
|
|
assert.equal(body2.members.length, 1, 'The member does not exist');
|
|
const updatedMember = body2.members[0];
|
|
assert.equal(updatedMember.status, 'free');
|
|
assert.equal(updatedMember.products.length, 0, 'The member should have no products');
|
|
should(updatedMember.subscriptions).match([
|
|
{
|
|
status: 'canceled'
|
|
}
|
|
]);
|
|
|
|
// Check the status events for this newly created member (should be NULL -> paid only)
|
|
assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: [
|
|
{
|
|
from_status: null,
|
|
to_status: 'free'
|
|
},
|
|
{
|
|
from_status: 'free',
|
|
to_status: 'comped'
|
|
},
|
|
{
|
|
from_status: 'comped',
|
|
to_status: 'free'
|
|
}
|
|
]
|
|
});
|
|
|
|
assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: updatedMember.id,
|
|
asserts: []
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('checkout.session.completed', function () {
|
|
// The subscription that we got from Stripe was created 2 seconds earlier (used for testing events)
|
|
const beforeNow = Math.floor((Date.now() - 2000) / 1000) * 1000;
|
|
before(function () {
|
|
set(subscription, {
|
|
id: 'sub_123',
|
|
customer: 'cus_123',
|
|
status: 'active',
|
|
items: {
|
|
type: 'list',
|
|
data: [{
|
|
id: 'item_123',
|
|
price: {
|
|
id: 'price_123',
|
|
product: 'product_123',
|
|
active: true,
|
|
nickname: 'Monthly',
|
|
currency: 'USD',
|
|
recurring: {
|
|
interval: 'month'
|
|
},
|
|
unit_amount: 500,
|
|
type: 'recurring'
|
|
}
|
|
}]
|
|
},
|
|
start_date: beforeNow / 1000,
|
|
current_period_end: beforeNow / 1000 + (60 * 60 * 24 * 31),
|
|
cancel_at_period_end: false
|
|
});
|
|
});
|
|
|
|
it('Will create a member if one does not exist', async function () {
|
|
set(customer, {
|
|
id: 'cus_123',
|
|
name: 'Test Member',
|
|
email: 'checkout-webhook-test@email.com',
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: [subscription]
|
|
}
|
|
});
|
|
|
|
{ // ensure member didn't already exist
|
|
const {body} = await adminAgent.get('/members/?search=checkout-webhook-test@email.com');
|
|
assert.equal(body.members.length, 0, 'A member already existed');
|
|
}
|
|
|
|
const webhookPayload = JSON.stringify({
|
|
type: 'checkout.session.completed',
|
|
data: {
|
|
object: {
|
|
mode: 'subscription',
|
|
customer: customer.id,
|
|
subscription: subscription.id,
|
|
metadata: {}
|
|
}
|
|
}
|
|
});
|
|
|
|
const webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature);
|
|
|
|
const {body} = await adminAgent.get('/members/?search=checkout-webhook-test@email.com');
|
|
assert.equal(body.members.length, 1, 'The member was not created');
|
|
const member = body.members[0];
|
|
|
|
assert.equal(member.status, 'paid', 'The member should be "paid"');
|
|
assert.equal(member.subscriptions.length, 1, 'The member should have a single subscription');
|
|
|
|
mockManager.assert.sentEmail({
|
|
subject: '🙌 Thank you for signing up to Ghost!',
|
|
to: 'checkout-webhook-test@email.com'
|
|
});
|
|
|
|
// Check the status events for this newly created member (should be NULL -> paid only)
|
|
assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: member.id,
|
|
asserts: [
|
|
{
|
|
from_status: null,
|
|
to_status: 'free',
|
|
created_at: new Date(member.created_at)
|
|
},
|
|
{
|
|
from_status: 'free',
|
|
to_status: 'paid',
|
|
created_at: new Date(beforeNow)
|
|
}
|
|
]
|
|
});
|
|
|
|
assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: member.id,
|
|
asserts: [
|
|
{
|
|
mrr_delta: 500
|
|
}
|
|
]
|
|
});
|
|
});
|
|
|
|
it('Does not 500 if the member is unknown', async function () {
|
|
set(paymentMethod, {
|
|
id: 'card_456'
|
|
});
|
|
|
|
set(subscription, {
|
|
id: 'sub_456',
|
|
customer: 'cus_456',
|
|
status: 'active',
|
|
items: {
|
|
type: 'list',
|
|
data: [{
|
|
id: 'item_456',
|
|
price: {
|
|
id: 'price_456',
|
|
product: 'product_456',
|
|
active: true,
|
|
nickname: 'Monthly',
|
|
currency: 'USD',
|
|
recurring: {
|
|
interval: 'month'
|
|
},
|
|
unit_amount: 500,
|
|
type: 'recurring'
|
|
}
|
|
}]
|
|
},
|
|
start_date: Date.now() / 1000,
|
|
current_period_end: Date.now() / 1000 + (60 * 60 * 24 * 31),
|
|
cancel_at_period_end: false
|
|
});
|
|
|
|
set(setupIntent, {
|
|
id: 'setup_intent_456',
|
|
payment_method: paymentMethod.id,
|
|
metadata: {
|
|
customer_id: 'cus_456', // invalid customer id
|
|
subscription_id: subscription.id
|
|
}
|
|
});
|
|
|
|
const webhookPayload = JSON.stringify({
|
|
type: 'checkout.session.completed',
|
|
data: {
|
|
object: {
|
|
mode: 'setup',
|
|
customer: 'cus_456',
|
|
setup_intent: setupIntent.id
|
|
}
|
|
}
|
|
});
|
|
|
|
const webhookSignature = stripe.webhooks.generateTestHeaderString({
|
|
payload: webhookPayload,
|
|
secret: process.env.WEBHOOK_SECRET
|
|
});
|
|
|
|
await membersAgent.post('/webhooks/stripe/')
|
|
.body(webhookPayload)
|
|
.header('stripe-signature', webhookSignature)
|
|
.expectStatus(200);
|
|
});
|
|
});
|
|
});
|
|
});
|