mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-24 03:14:03 +03:00
b9646cceb1
refs https://github.com/TryGhost/Team/issues/1520 **Changes in members repo** Bumped to `5.12.0`, with the following changes: - Compare differences via https://github.com/TryGhost/Members/compare/%40tryghost/members-api%405.11.1...%40tryghost/members-api%405.12.0 - Instead of doing the matching of the offers and subscriptions by looking at the offer redemptions, we can now look at the offer_id from subscriptions. - This also fixes an issue where we don't attach the offer object to subscriptions in the members' browse method - Updated browse behaviour to match the read behaviour of members (product relation needs to get loaded because it is missing in member.products if the subscription is expired). **Tests** - Includes test to see if the API correctly returns the offer object when fetching one or multiple members - Check if the return format is the same for the read, edit and browse members admin API endpoints (offer was missing in subscriptions) - Snapshot files have been updated because now the offer is returned in subscriptions (content length increased)
1495 lines
46 KiB
JavaScript
1495 lines
46 KiB
JavaScript
const {agentProvider, mockManager, fixtureManager, matchers} = require('../../utils/e2e-framework');
|
|
const {anyEtag, anyObjectId, anyUuid, anyISODateTime, anyISODate, anyString, anyArray, anyLocationFor, anyErrorId} = matchers;
|
|
|
|
const assert = require('assert');
|
|
const nock = require('nock');
|
|
const should = require('should');
|
|
const sinon = require('sinon');
|
|
const testUtils = require('../../utils');
|
|
const Papa = require('papaparse');
|
|
|
|
const models = require('../../../core/server/models');
|
|
const {Product} = require('../../../core/server/models/product');
|
|
|
|
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.`);
|
|
}
|
|
|
|
async function assertSubscription(subscriptionId, asserts) {
|
|
// eslint-disable-next-line dot-notation
|
|
const subscription = await models['StripeCustomerSubscription'].where('subscription_id', subscriptionId).fetch({require: true});
|
|
|
|
// We use the native toJSON to prevent calling the overriden serialize method
|
|
models.Base.Model.prototype.serialize.call(subscription).should.match(asserts);
|
|
}
|
|
|
|
async function getPaidProduct() {
|
|
return await Product.findOne({type: 'paid'});
|
|
}
|
|
|
|
const memberMatcherNoIncludes = {
|
|
id: anyObjectId,
|
|
uuid: anyUuid,
|
|
created_at: anyISODateTime,
|
|
updated_at: anyISODateTime
|
|
};
|
|
|
|
const memberMatcherShallowIncludes = {
|
|
id: anyObjectId,
|
|
uuid: anyUuid,
|
|
created_at: anyISODateTime,
|
|
updated_at: anyISODateTime,
|
|
subscriptions: anyArray,
|
|
labels: anyArray
|
|
};
|
|
|
|
const memberMatcherShallowIncludesForNewsletters = {
|
|
id: anyObjectId,
|
|
uuid: anyUuid,
|
|
created_at: anyISODateTime,
|
|
updated_at: anyISODateTime,
|
|
subscriptions: anyArray,
|
|
labels: anyArray,
|
|
newsletters: anyArray
|
|
};
|
|
|
|
let agent;
|
|
|
|
describe('Members API without Stripe', function () {
|
|
before(async function () {
|
|
agent = await agentProvider.getAdminAPIAgent();
|
|
await fixtureManager.init();
|
|
await agent.loginAsOwner();
|
|
});
|
|
|
|
before(async function () {
|
|
await agent
|
|
.delete('/settings/stripe/connect/')
|
|
.expectStatus(200);
|
|
});
|
|
|
|
beforeEach(function () {
|
|
mockManager.mockMail();
|
|
});
|
|
|
|
afterEach(function () {
|
|
mockManager.restore();
|
|
});
|
|
|
|
it('Add should fail when comped flag is passed in but Stripe is not enabled', async function () {
|
|
const newMember = {
|
|
email: 'memberTestAdd@test.com',
|
|
comped: true
|
|
};
|
|
|
|
await agent
|
|
.post(`members/`)
|
|
.body({members: [newMember]})
|
|
.expectStatus(422)
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
})
|
|
.matchBodySnapshot({
|
|
errors: [{
|
|
id: anyErrorId
|
|
}]
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Members API', function () {
|
|
before(async function () {
|
|
agent = await agentProvider.getAdminAPIAgent();
|
|
await fixtureManager.init('members');
|
|
await agent.loginAsOwner();
|
|
});
|
|
|
|
beforeEach(function () {
|
|
mockManager.mockLabsEnabled('multipleProducts');
|
|
mockManager.mockStripe();
|
|
mockManager.mockMail();
|
|
});
|
|
|
|
afterEach(function () {
|
|
mockManager.restore();
|
|
});
|
|
|
|
// List Members
|
|
|
|
it('Can browse', async function () {
|
|
await agent
|
|
.get('/members/')
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: new Array(8).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
it('Can browse with filter', async function () {
|
|
await agent
|
|
.get('/members/?filter=label:label-1')
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
it('Can browse with search', async function () {
|
|
await agent
|
|
.get('/members/?search=member1')
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
it('Can filter by paid status', async function () {
|
|
await agent
|
|
.get('/members/?filter=status:paid')
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: new Array(5).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
it('Can filter using contains operators', async function () {
|
|
await agent
|
|
.get(`/members/?filter=name:~'Venkman'`)
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
it('Can ignore any unknown includes', async function () {
|
|
await agent
|
|
.get('/members/?filter=status:paid&include=emailRecipients')
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: new Array(5).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
it('Can order by email_open_rate', async function () {
|
|
await agent
|
|
.get('members/?order=email_open_rate%20desc')
|
|
.expectStatus(200)
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag,
|
|
'content-length': anyString
|
|
})
|
|
.matchBodySnapshot({
|
|
members: new Array(8).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.expect(({body}) => {
|
|
const {members} = body;
|
|
assert.equal(members[0].email_open_rate > members[1].email_open_rate, true, 'Expected the first member to have a greater open rate than the second.');
|
|
});
|
|
|
|
await agent
|
|
.get('members/?order=email_open_rate%20asc')
|
|
.expectStatus(200)
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag,
|
|
'content-length': anyString
|
|
})
|
|
.matchBodySnapshot({
|
|
members: new Array(8).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.expect(({body}) => {
|
|
const {members} = body;
|
|
assert.equal(members[0].email_open_rate < members[1].email_open_rate, true, 'Expected the first member to have a smaller open rate than the second.');
|
|
});
|
|
});
|
|
|
|
it('Search by case-insensitive name egg receives member with name Mr Egg', async function () {
|
|
await agent
|
|
.get('members/?search=egg')
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: [memberMatcherShallowIncludes]
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
it('Search by case-insensitive email MEMBER2 receives member with email member2@test.com', async function () {
|
|
await agent
|
|
.get('members/?search=MEMBER2')
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: [memberMatcherShallowIncludes]
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
it('Search for paid members retrieves member with email paid@test.com', async function () {
|
|
await agent
|
|
.get('members/?search=egon&paid=true')
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: [memberMatcherShallowIncludes]
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
it('Search for non existing member returns empty result set', async function () {
|
|
await agent
|
|
.get('members/?search=do_not_exist')
|
|
.expectStatus(200)
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
})
|
|
.matchBodySnapshot({
|
|
members: []
|
|
});
|
|
});
|
|
|
|
// Read a member
|
|
|
|
it('Can read', async function () {
|
|
await agent
|
|
.get(`/members/${testUtils.DataGenerator.Content.members[0].id}/`)
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
it('Can read and include email_recipients', async function () {
|
|
await agent
|
|
.get(`/members/${testUtils.DataGenerator.Content.members[0].id}/?include=email_recipients`)
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
// Create a member
|
|
|
|
it('Can add', async function () {
|
|
const member = {
|
|
name: 'test',
|
|
email: 'memberTestAdd@test.com',
|
|
note: 'test note',
|
|
subscribed: false,
|
|
labels: ['test-label']
|
|
};
|
|
|
|
const {body} = await agent
|
|
.post(`/members/`)
|
|
.body({members: [member]})
|
|
.expectStatus(201)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag,
|
|
location: anyLocationFor('members')
|
|
});
|
|
const newMember = body.members[0];
|
|
|
|
await agent
|
|
.post(`/members/`)
|
|
.body({members: [member]})
|
|
.expectStatus(422);
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: newMember.id,
|
|
asserts: [
|
|
{
|
|
from_status: null,
|
|
to_status: 'free'
|
|
}
|
|
]
|
|
});
|
|
});
|
|
|
|
it('Can add and send a signup confirmation email', async function () {
|
|
const member = {
|
|
name: 'Send Me Confirmation',
|
|
email: 'member_getting_confirmation@test.com',
|
|
subscribed: true
|
|
};
|
|
|
|
const queryParams = {
|
|
send_email: true,
|
|
email_type: 'signup'
|
|
};
|
|
|
|
const {body} = await agent
|
|
.post('/members/?send_email=true&email_type=signup')
|
|
.body({members: [member]})
|
|
.expectStatus(201)
|
|
.matchBodySnapshot({
|
|
members: [memberMatcherNoIncludes]
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag,
|
|
location: anyString
|
|
});
|
|
|
|
const newMember = body.members[0];
|
|
|
|
mockManager.assert.sentEmail({
|
|
subject: '🙌 Complete your sign up to Ghost!',
|
|
to: 'member_getting_confirmation@test.com'
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: newMember.id,
|
|
asserts: [
|
|
{
|
|
from_status: null,
|
|
to_status: 'free'
|
|
}
|
|
]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberSubscribeEvent',
|
|
memberId: newMember.id,
|
|
asserts: [
|
|
{
|
|
subscribed: true,
|
|
source: 'admin'
|
|
}
|
|
]
|
|
});
|
|
|
|
// @TODO: do we really need to delete this member here?
|
|
await agent
|
|
.delete(`members/${body.members[0].id}/`)
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
})
|
|
.expectStatus(204);
|
|
|
|
const events = await models.MemberSubscribeEvent.findAll();
|
|
assert.equal(events.models.length, 0, 'There should be no MemberSubscribeEvent remaining.');
|
|
});
|
|
|
|
it('Add should fail when passing incorrect email_type query parameter', async function () {
|
|
const newMember = {
|
|
name: 'test',
|
|
email: 'memberTestAdd@test.com'
|
|
};
|
|
|
|
await agent
|
|
.post(`members/?send_email=true&email_type=lel`)
|
|
.body({members: [newMember]})
|
|
.expectStatus(422)
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
})
|
|
.matchBodySnapshot({
|
|
errors: [{
|
|
id: anyErrorId
|
|
}]
|
|
});
|
|
|
|
const statusEvents = await models.MemberStatusEvent.findAll();
|
|
assert.equal(statusEvents.models.length, 1, 'No MemberStatusEvent should have been added after failing to create a subscription.');
|
|
});
|
|
|
|
// Edit a member
|
|
|
|
it('Can add complimentary subscription (out of date)', async function () {
|
|
const stripeService = require('../../../core/server/services/stripe');
|
|
const fakePrice = {
|
|
id: 'price_1',
|
|
product: '',
|
|
active: true,
|
|
nickname: 'Complimentary',
|
|
unit_amount: 0,
|
|
currency: 'usd',
|
|
type: 'recurring',
|
|
recurring: {
|
|
interval: 'year'
|
|
}
|
|
};
|
|
const fakeSubscription = {
|
|
id: 'sub_1',
|
|
customer: 'cus_1',
|
|
status: 'active',
|
|
cancel_at_period_end: false,
|
|
metadata: {},
|
|
current_period_end: Date.now() / 1000,
|
|
start_date: Date.now() / 1000,
|
|
plan: fakePrice,
|
|
items: {
|
|
data: [{
|
|
price: fakePrice
|
|
}]
|
|
}
|
|
};
|
|
sinon.stub(stripeService.api, 'createCustomer').callsFake(async function (data) {
|
|
return {
|
|
id: 'cus_1',
|
|
email: data.email
|
|
};
|
|
});
|
|
sinon.stub(stripeService.api, 'createPrice').resolves(fakePrice);
|
|
sinon.stub(stripeService.api, 'createSubscription').resolves(fakeSubscription);
|
|
sinon.stub(stripeService.api, 'getSubscription').resolves(fakeSubscription);
|
|
const initialMember = {
|
|
name: 'Name',
|
|
email: 'compedtest@test.com',
|
|
subscribed: true
|
|
};
|
|
|
|
const compedPayload = {
|
|
comped: true
|
|
};
|
|
|
|
const {body} = await agent
|
|
.post(`/members/`)
|
|
.body({members: [initialMember]})
|
|
.expectStatus(201)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag,
|
|
location: anyLocationFor('members')
|
|
});
|
|
|
|
const newMember = body.members[0];
|
|
|
|
const {body: body2} = await agent
|
|
.put(`/members/${newMember.id}/`)
|
|
.body({members: [compedPayload]})
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: newMember.id,
|
|
asserts: [{
|
|
from_status: null,
|
|
to_status: 'free'
|
|
}]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberSubscribeEvent',
|
|
memberId: newMember.id,
|
|
asserts: [{
|
|
subscribed: true,
|
|
source: 'admin'
|
|
}]
|
|
});
|
|
});
|
|
|
|
it('Can add complimentary subscription by assigning a product to a member', async function () {
|
|
const initialMember = {
|
|
name: 'Name',
|
|
email: 'compedtest2@test.com',
|
|
subscribed: true
|
|
};
|
|
|
|
const {body} = await agent
|
|
.post(`/members/`)
|
|
.body({members: [initialMember]})
|
|
.expectStatus(201);
|
|
|
|
const newMember = body.members[0];
|
|
assert.equal(newMember.status, 'free', 'A new member should have the free status');
|
|
|
|
const product = await getPaidProduct();
|
|
|
|
const compedPayload = {
|
|
id: newMember.id,
|
|
email: newMember.email,
|
|
products: [
|
|
{
|
|
id: product.id
|
|
}
|
|
]
|
|
};
|
|
|
|
const {body: body2} = await agent
|
|
.put(`/members/${newMember.id}/`)
|
|
.body({members: [compedPayload]})
|
|
.expectStatus(200);
|
|
|
|
const updatedMember = body2.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');
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: newMember.id,
|
|
asserts: [
|
|
{
|
|
from_status: null,
|
|
to_status: 'free'
|
|
},
|
|
{
|
|
from_status: 'free',
|
|
to_status: 'comped'
|
|
}
|
|
]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberSubscribeEvent',
|
|
memberId: newMember.id,
|
|
asserts: [{
|
|
subscribed: true,
|
|
source: 'admin'
|
|
}]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: newMember.id,
|
|
asserts: []
|
|
});
|
|
});
|
|
|
|
it('Can end a complimentary subscription by removing a product from a member', async function () {
|
|
const product = await getPaidProduct();
|
|
const initialMember = {
|
|
name: 'Name',
|
|
email: 'compedtest3@test.com',
|
|
subscribed: true,
|
|
products: [
|
|
{
|
|
id: product.id
|
|
}
|
|
]
|
|
};
|
|
|
|
const {body} = await agent
|
|
.post(`/members/`)
|
|
.body({members: [initialMember]})
|
|
.expectStatus(201);
|
|
|
|
const newMember = body.members[0];
|
|
assert.equal(newMember.status, 'comped', 'The new member should have the comped status');
|
|
assert.equal(newMember.products.length, 1, 'The member should have 1 product');
|
|
|
|
// Remove it
|
|
const removePayload = {
|
|
id: newMember.id,
|
|
email: newMember.email,
|
|
products: []
|
|
};
|
|
|
|
const {body: body2} = await agent
|
|
.put(`/members/${newMember.id}/`)
|
|
.body({members: [removePayload]})
|
|
.expectStatus(200);
|
|
|
|
const updatedMember = body2.members[0];
|
|
assert.equal(updatedMember.status, 'free', 'The member should have the free status');
|
|
assert.equal(updatedMember.products.length, 0, 'The member should have 0 products');
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: newMember.id,
|
|
asserts: [
|
|
{
|
|
from_status: null,
|
|
to_status: 'comped'
|
|
},
|
|
{
|
|
from_status: 'comped',
|
|
to_status: 'free'
|
|
}
|
|
]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberSubscribeEvent',
|
|
memberId: newMember.id,
|
|
asserts: [
|
|
{
|
|
subscribed: true,
|
|
source: 'admin'
|
|
}
|
|
]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: newMember.id,
|
|
asserts: []
|
|
});
|
|
});
|
|
|
|
it('Can create a new member with a product (complementary)', async function () {
|
|
const product = await getPaidProduct();
|
|
const initialMember = {
|
|
name: 'Name',
|
|
email: 'compedtest4@test.com',
|
|
subscribed: true,
|
|
products: [
|
|
{
|
|
id: product.id
|
|
}
|
|
]
|
|
};
|
|
|
|
const {body} = await agent
|
|
.post(`/members/`)
|
|
.body({members: [initialMember]})
|
|
.expectStatus(201)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill({
|
|
id: anyObjectId,
|
|
uuid: anyUuid,
|
|
created_at: anyISODateTime,
|
|
updated_at: anyISODateTime,
|
|
labels: anyArray,
|
|
subscriptions: anyArray,
|
|
products: new Array(1).fill({
|
|
id: anyObjectId,
|
|
monthly_price_id: anyObjectId,
|
|
yearly_price_id: anyObjectId,
|
|
created_at: anyISODateTime,
|
|
updated_at: anyISODateTime
|
|
})
|
|
})
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag,
|
|
location: anyLocationFor('members')
|
|
});
|
|
|
|
const newMember = body.members[0];
|
|
assert.equal(newMember.status, 'comped', 'The newly imported member should have the comped status');
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: newMember.id,
|
|
asserts: [{
|
|
from_status: null,
|
|
to_status: 'comped'
|
|
}]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberSubscribeEvent',
|
|
memberId: newMember.id,
|
|
asserts: [{
|
|
subscribed: true,
|
|
source: 'admin'
|
|
}]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: newMember.id,
|
|
asserts: []
|
|
});
|
|
});
|
|
|
|
it('Can create a member with an existing complimentary subscription', async function () {
|
|
const fakePrice = {
|
|
id: 'price_1',
|
|
product: '',
|
|
active: true,
|
|
nickname: 'Complimentary',
|
|
unit_amount: 0,
|
|
currency: 'usd',
|
|
type: 'recurring',
|
|
recurring: {
|
|
interval: 'year'
|
|
}
|
|
};
|
|
|
|
const fakeSubscription = {
|
|
id: 'sub_1',
|
|
customer: 'cus_1234',
|
|
status: 'active',
|
|
cancel_at_period_end: false,
|
|
metadata: {},
|
|
current_period_end: Date.now() / 1000 + 1000,
|
|
start_date: Date.now() / 1000,
|
|
plan: fakePrice,
|
|
items: {
|
|
data: [{
|
|
price: fakePrice
|
|
}]
|
|
}
|
|
};
|
|
|
|
const fakeCustomer = {
|
|
id: 'cus_1234',
|
|
name: 'Test Member',
|
|
email: 'create-member-comped-test@email.com',
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: [fakeSubscription]
|
|
}
|
|
};
|
|
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 === 'customers') {
|
|
return [200, fakeCustomer];
|
|
}
|
|
|
|
if (resource === 'subscriptions') {
|
|
return [200, fakeSubscription];
|
|
}
|
|
});
|
|
|
|
const initialMember = {
|
|
name: fakeCustomer.name,
|
|
email: fakeCustomer.email,
|
|
subscribed: true,
|
|
stripe_customer_id: fakeCustomer.id
|
|
};
|
|
|
|
const {body} = await agent
|
|
.post(`/members/`)
|
|
.body({members: [initialMember]})
|
|
.expectStatus(201)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill({
|
|
id: anyObjectId,
|
|
uuid: anyUuid,
|
|
created_at: anyISODateTime,
|
|
updated_at: anyISODateTime,
|
|
labels: anyArray,
|
|
subscriptions: anyArray,
|
|
products: anyArray
|
|
})
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag,
|
|
location: anyLocationFor('members')
|
|
});
|
|
|
|
const newMember = body.members[0];
|
|
assert.equal(newMember.status, 'comped', 'The created member should have the comped status');
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: newMember.id,
|
|
asserts: [
|
|
{
|
|
from_status: null,
|
|
to_status: 'free'
|
|
},
|
|
{
|
|
from_status: 'free',
|
|
to_status: 'comped'
|
|
}
|
|
]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberSubscribeEvent',
|
|
memberId: newMember.id,
|
|
asserts: [
|
|
{
|
|
subscribed: true,
|
|
source: 'admin'
|
|
}
|
|
]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: newMember.id,
|
|
asserts: [{
|
|
mrr_delta: 0
|
|
}]
|
|
});
|
|
});
|
|
|
|
let memberWithPaidSubscription;
|
|
|
|
it('Can create a member with an existing paid subscription', async function () {
|
|
const fakePrice = {
|
|
id: 'price_1',
|
|
product: 'product_1234',
|
|
active: true,
|
|
nickname: 'Paid',
|
|
unit_amount: 1200,
|
|
currency: 'usd',
|
|
type: 'recurring',
|
|
recurring: {
|
|
interval: 'year'
|
|
}
|
|
};
|
|
|
|
const fakeSubscription = {
|
|
id: 'sub_987623',
|
|
customer: 'cus_12345',
|
|
status: 'active',
|
|
cancel_at_period_end: false,
|
|
metadata: {},
|
|
current_period_end: Date.now() / 1000 + 1000,
|
|
start_date: Date.now() / 1000,
|
|
plan: fakePrice,
|
|
items: {
|
|
data: [{
|
|
id: 'item_123',
|
|
price: fakePrice
|
|
}]
|
|
}
|
|
};
|
|
|
|
const fakeCustomer = {
|
|
id: 'cus_12345',
|
|
name: 'Test Member',
|
|
email: 'create-member-paid-test@email.com',
|
|
subscriptions: {
|
|
type: 'list',
|
|
data: [fakeSubscription]
|
|
}
|
|
};
|
|
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 === 'customers') {
|
|
return [200, fakeCustomer];
|
|
}
|
|
|
|
if (resource === 'subscriptions') {
|
|
return [200, fakeSubscription];
|
|
}
|
|
});
|
|
|
|
const initialMember = {
|
|
name: fakeCustomer.name,
|
|
email: fakeCustomer.email,
|
|
subscribed: true,
|
|
stripe_customer_id: fakeCustomer.id
|
|
};
|
|
|
|
const {body} = await agent
|
|
.post(`/members/`)
|
|
.body({members: [initialMember]})
|
|
.expectStatus(201)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill({
|
|
id: anyObjectId,
|
|
uuid: anyUuid,
|
|
created_at: anyISODateTime,
|
|
updated_at: anyISODateTime,
|
|
labels: anyArray,
|
|
subscriptions: anyArray,
|
|
products: anyArray
|
|
})
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag,
|
|
location: anyLocationFor('members')
|
|
});
|
|
|
|
const newMember = body.members[0];
|
|
|
|
assert.equal(newMember.status, 'paid', 'The created member should have the paid status');
|
|
assert.equal(newMember.subscriptions.length, 1, 'The member should have a single subscription');
|
|
assert.equal(newMember.subscriptions[0].id, fakeSubscription.id, 'The returned subscription should have an ID assigned');
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: newMember.id,
|
|
asserts: [
|
|
{
|
|
from_status: null,
|
|
to_status: 'free'
|
|
}, {
|
|
from_status: 'free',
|
|
to_status: 'paid'
|
|
}
|
|
]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberSubscribeEvent',
|
|
memberId: newMember.id,
|
|
asserts: [{
|
|
subscribed: true,
|
|
source: 'admin'
|
|
}]
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberPaidSubscriptionEvent',
|
|
memberId: newMember.id,
|
|
asserts: [
|
|
{
|
|
mrr_delta: 100
|
|
}
|
|
]
|
|
});
|
|
|
|
await assertSubscription(fakeSubscription.id, {
|
|
subscription_id: fakeSubscription.id,
|
|
status: 'active',
|
|
cancel_at_period_end: false,
|
|
plan_amount: 1200,
|
|
plan_interval: 'year',
|
|
plan_currency: 'usd',
|
|
mrr: 100
|
|
});
|
|
|
|
// Save this member for the next test
|
|
memberWithPaidSubscription = newMember;
|
|
});
|
|
|
|
it('Returns an identical member format for read, edit and browse', async function () {
|
|
if (!memberWithPaidSubscription) {
|
|
// Previous test failed
|
|
this.skip();
|
|
}
|
|
|
|
// Check status has been updated to 'free' after cancelling
|
|
const {body: readBody} = await agent.get('/members/' + memberWithPaidSubscription.id + '/');
|
|
assert.equal(readBody.members.length, 1, 'The member was not found in read');
|
|
const readMember = readBody.members[0];
|
|
|
|
// Note that we explicitly need to ask to include products while browsing
|
|
const {body: browseBody} = await agent.get(`/members/?search=${memberWithPaidSubscription.email}&include=products`);
|
|
assert.equal(browseBody.members.length, 1, 'The member was not found in browse');
|
|
const browseMember = browseBody.members[0];
|
|
|
|
// Check for this member with a paid subscription that the body results for the patch, get and browse endpoints are 100% identical
|
|
should.deepEqual(browseMember, readMember, 'Browsing a member returns a different format than reading a member');
|
|
should.deepEqual(memberWithPaidSubscription, readMember, 'Editing a member returns a different format than reading a member');
|
|
});
|
|
|
|
it('Can edit by id', async function () {
|
|
const memberToChange = {
|
|
name: 'change me',
|
|
email: 'member2Change@test.com',
|
|
note: 'initial note',
|
|
subscribed: true
|
|
};
|
|
|
|
const memberChanged = {
|
|
name: 'changed',
|
|
email: 'cantChangeMe@test.com',
|
|
note: 'edited note',
|
|
subscribed: false
|
|
};
|
|
|
|
const {body} = await agent
|
|
.post(`/members/`)
|
|
.body({members: [memberToChange]})
|
|
.expectStatus(201)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag,
|
|
location: anyLocationFor('members')
|
|
});
|
|
const newMember = body.members[0];
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberSubscribeEvent',
|
|
memberId: newMember.id,
|
|
asserts: [{
|
|
subscribed: true,
|
|
source: 'admin'
|
|
}]
|
|
});
|
|
await assertMemberEvents({
|
|
eventType: 'MemberStatusEvent',
|
|
memberId: newMember.id,
|
|
asserts: [{
|
|
from_status: null,
|
|
to_status: 'free'
|
|
}]
|
|
});
|
|
|
|
await agent
|
|
.put(`/members/${newMember.id}/`)
|
|
.body({members: [memberChanged]})
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
|
|
await assertMemberEvents({
|
|
eventType: 'MemberEmailChangeEvent',
|
|
memberId: newMember.id,
|
|
asserts: [{
|
|
from_email: memberToChange.email,
|
|
to_email: memberChanged.email
|
|
}]
|
|
});
|
|
await assertMemberEvents({
|
|
eventType: 'MemberSubscribeEvent',
|
|
memberId: newMember.id,
|
|
asserts: [
|
|
{
|
|
subscribed: true,
|
|
source: 'admin'
|
|
}, {
|
|
subscribed: false,
|
|
source: 'admin'
|
|
}
|
|
]
|
|
});
|
|
});
|
|
|
|
it('Can add a subscription', async function () {
|
|
const memberId = testUtils.DataGenerator.Content.members[0].id;
|
|
const price = testUtils.DataGenerator.Content.stripe_prices[0];
|
|
|
|
function nockCallback(method, uri, body) {
|
|
const [match, resource, id] = uri.match(/\/?v1\/(\w+)(?:\/(\w+))?/) || [null];
|
|
|
|
if (!match) {
|
|
return [500];
|
|
}
|
|
|
|
if (resource === 'customers') {
|
|
return [200, {id: 'cus_123', email: 'member1@test.com'}];
|
|
}
|
|
|
|
if (resource === 'subscriptions') {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
return [200, {id: 'sub_123', customer: 'cus_123', cancel_at_period_end: false, items: {
|
|
data: [{price: {
|
|
id: price.stripe_price_id,
|
|
recurring: {
|
|
interval: price.interval
|
|
},
|
|
unit_amount: price.amount,
|
|
currency: price.currency.toLowerCase()
|
|
}}]
|
|
}, status: 'active', current_period_end: now + 24 * 3600, start_date: now}];
|
|
}
|
|
}
|
|
|
|
nock('https://api.stripe.com:443')
|
|
.persist()
|
|
.post(/v1\/.*/)
|
|
.reply((uri, body) => nockCallback('POST', uri, body));
|
|
|
|
nock('https://api.stripe.com:443')
|
|
.persist()
|
|
.get(/v1\/.*/)
|
|
.reply((uri, body) => nockCallback('GET', uri, body));
|
|
|
|
await agent
|
|
.post(`/members/${memberId}/subscriptions/`)
|
|
.body({
|
|
stripe_price_id: price.id
|
|
})
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill({
|
|
id: anyObjectId,
|
|
uuid: anyUuid,
|
|
created_at: anyISODateTime,
|
|
updated_at: anyISODateTime,
|
|
labels: anyArray,
|
|
subscriptions: [{
|
|
start_date: anyString,
|
|
current_period_end: anyString,
|
|
price: {
|
|
price_id: anyObjectId,
|
|
product: {
|
|
product_id: anyObjectId
|
|
}
|
|
}
|
|
}]
|
|
})
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
|
|
// Check member read with a subscription
|
|
await agent
|
|
.get(`/members/${memberId}/`)
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill({
|
|
id: anyObjectId,
|
|
uuid: anyUuid,
|
|
created_at: anyISODateTime,
|
|
updated_at: anyISODateTime,
|
|
labels: anyArray,
|
|
subscriptions: [{
|
|
start_date: anyString,
|
|
current_period_end: anyString,
|
|
price: {
|
|
price_id: anyObjectId,
|
|
product: {
|
|
product_id: anyObjectId
|
|
}
|
|
}
|
|
}]
|
|
})
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
// Delete a member
|
|
|
|
it('Can destroy', async function () {
|
|
const member = {
|
|
name: 'test',
|
|
email: 'memberTestDestroy@test.com'
|
|
};
|
|
|
|
const {body} = await agent
|
|
.post(`/members/`)
|
|
.body({members: [member]})
|
|
.expectStatus(201)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill(memberMatcherShallowIncludes)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag,
|
|
location: anyLocationFor('members')
|
|
});
|
|
|
|
const newMember = body.members[0];
|
|
|
|
await agent
|
|
.delete(`/members/${newMember.id}`)
|
|
.expectStatus(204)
|
|
.expectEmptyBody()
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
|
|
await agent
|
|
.get(`/members/${newMember.id}/`)
|
|
.expectStatus(404)
|
|
.matchBodySnapshot({
|
|
errors: [{
|
|
id: anyUuid
|
|
}]
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
it('Can delete a member without cancelling Stripe Subscription', async function () {
|
|
let subscriptionCanceled = false;
|
|
nock('https://api.stripe.com')
|
|
.persist()
|
|
.delete(/v1\/.*/)
|
|
.reply((uri) => {
|
|
const [match, resource, id] = uri.match(/\/?v1\/(\w+)(?:\/(\w+))/) || [null];
|
|
|
|
if (match && resource === 'subscriptions') {
|
|
subscriptionCanceled = true;
|
|
return [200, {
|
|
id,
|
|
status: 'canceled'
|
|
}];
|
|
}
|
|
|
|
return [500];
|
|
});
|
|
|
|
// @TODO This is wrong because it changes the state for the rest of the tests
|
|
// We need to add a member via a fixture and then remove them OR work out how
|
|
// to reapply fixtures before each test
|
|
const memberToDelete = fixtureManager.get('members', 2);
|
|
|
|
await agent
|
|
.delete(`members/${memberToDelete.id}/`)
|
|
.expectStatus(204)
|
|
.expectEmptyBody()
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
|
|
assert.equal(subscriptionCanceled, false, 'expected subscription not to be canceled');
|
|
});
|
|
|
|
// Export members to CSV
|
|
|
|
it('Can export CSV', async function () {
|
|
const res = await agent
|
|
.get(`/members/upload/`)
|
|
.expectStatus(200)
|
|
.expectEmptyBody() // express-test body parsing doesn't support CSV
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag,
|
|
'content-length': anyString, //For some reason the content-length changes between 1220 and 1317
|
|
'content-disposition': anyString
|
|
});
|
|
|
|
res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at/);
|
|
|
|
const csv = Papa.parse(res.text, {header: true});
|
|
should.exist(csv.data.find(row => row.name === 'Mr Egg'));
|
|
should.exist(csv.data.find(row => row.name === 'Winston Zeddemore'));
|
|
should.exist(csv.data.find(row => row.name === 'Ray Stantz'));
|
|
should.exist(csv.data.find(row => row.email === 'member2@test.com'));
|
|
});
|
|
|
|
it('Can export a filtered CSV', async function () {
|
|
const res = await agent
|
|
.get(`/members/upload/?search=Egg`)
|
|
.expectStatus(200)
|
|
.expectEmptyBody() // express-test body parsing doesn't support CSV
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag,
|
|
'content-disposition': anyString
|
|
});
|
|
|
|
res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at/);
|
|
|
|
const csv = Papa.parse(res.text, {header: true});
|
|
should.exist(csv.data.find(row => row.name === 'Mr Egg'));
|
|
should.not.exist(csv.data.find(row => row.name === 'Egon Spengler'));
|
|
should.not.exist(csv.data.find(row => row.name === 'Ray Stantz'));
|
|
should.not.exist(csv.data.find(row => row.email === 'member2@test.com'));
|
|
});
|
|
|
|
// Get stats
|
|
|
|
it('Can fetch member counts stats', async function () {
|
|
await agent
|
|
.get(`/members/stats/count/`)
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
data: [{
|
|
date: anyISODate
|
|
}]
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
it('Errors when fetching stats with unknown days param value', async function () {
|
|
await agent
|
|
.get('members/stats/?days=nope')
|
|
.expectStatus(422)
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
})
|
|
.matchBodySnapshot({
|
|
errors: [{
|
|
id: anyErrorId
|
|
}]
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Members API: with multiple newsletters', function () {
|
|
before(async function () {
|
|
agent = await agentProvider.getAdminAPIAgent();
|
|
await fixtureManager.init('members', 'newsletters');
|
|
await agent.loginAsOwner();
|
|
});
|
|
|
|
beforeEach(function () {
|
|
mockManager.mockLabsEnabled('multipleNewsletters');
|
|
mockManager.mockStripe();
|
|
mockManager.mockMail();
|
|
});
|
|
|
|
afterEach(function () {
|
|
mockManager.restore();
|
|
});
|
|
|
|
// List Members
|
|
|
|
it('Can browse', async function () {
|
|
await agent
|
|
.get('/members/')
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: new Array(8).fill(memberMatcherShallowIncludesForNewsletters)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
// Read a member
|
|
|
|
it('Can read', async function () {
|
|
await agent
|
|
.get(`/members/${testUtils.DataGenerator.Content.members[0].id}/`)
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill(memberMatcherShallowIncludesForNewsletters)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
// Create a member
|
|
it('Can add with default newsletters', async function () {
|
|
const member = {
|
|
name: 'test',
|
|
email: 'memberTestNewsletterAdd@test.com',
|
|
note: 'test note',
|
|
subscribed: false,
|
|
labels: ['test-label']
|
|
};
|
|
|
|
await agent
|
|
.post(`/members/`)
|
|
.body({members: [member]})
|
|
.expectStatus(201)
|
|
.matchBodySnapshot({
|
|
members: [{
|
|
id: anyObjectId,
|
|
uuid: anyUuid,
|
|
created_at: anyISODateTime,
|
|
updated_at: anyISODateTime,
|
|
subscriptions: anyArray,
|
|
labels: anyArray,
|
|
newsletters: Array(2).fill({
|
|
id: matchers.anyObjectId
|
|
})
|
|
}]
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag,
|
|
location: anyLocationFor('members')
|
|
});
|
|
});
|
|
|
|
it('Can filter on newsletter slug', async function () {
|
|
await agent
|
|
.get('/members/?filter=newsletters:weekly-newsletter')
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: new Array(1).fill(memberMatcherShallowIncludesForNewsletters)
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
});
|
|
|
|
// Edit a member
|
|
it('Can add and edit with custom newsletters', async function () {
|
|
// Add custom newsletter list to new member
|
|
const member = {
|
|
name: 'test newsletter',
|
|
email: 'memberTestAddNewsletter2@test.com',
|
|
note: 'test note',
|
|
subscribed: false,
|
|
labels: ['test-label'],
|
|
newsletters: [{id: testUtils.DataGenerator.Content.newsletters[1].id}]
|
|
};
|
|
|
|
const {body} = await agent
|
|
.post(`/members/`)
|
|
.body({members: [member]})
|
|
.expectStatus(201)
|
|
.matchBodySnapshot({
|
|
members: [{
|
|
id: anyObjectId,
|
|
uuid: anyUuid,
|
|
created_at: anyISODateTime,
|
|
updated_at: anyISODateTime,
|
|
subscriptions: anyArray,
|
|
labels: anyArray,
|
|
newsletters: Array(1).fill({
|
|
id: matchers.anyObjectId
|
|
})
|
|
}]
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag,
|
|
location: anyLocationFor('members')
|
|
});
|
|
|
|
const memberId = body.members[0].id;
|
|
const editedMember = {
|
|
newsletters: [{id: testUtils.DataGenerator.Content.newsletters[0].id}]
|
|
};
|
|
|
|
// Edit newsletter list for member
|
|
await agent
|
|
.put(`/members/${memberId}`)
|
|
.body({members: [editedMember]})
|
|
.expectStatus(200)
|
|
.matchBodySnapshot({
|
|
members: [{
|
|
id: anyObjectId,
|
|
uuid: anyUuid,
|
|
created_at: anyISODateTime,
|
|
updated_at: anyISODateTime,
|
|
subscriptions: anyArray,
|
|
labels: anyArray,
|
|
newsletters: Array(1).fill({
|
|
id: matchers.anyObjectId
|
|
})
|
|
}]
|
|
})
|
|
.matchHeaderSnapshot({
|
|
etag: anyEtag
|
|
});
|
|
|
|
await agent
|
|
.post(`/members/`)
|
|
.body({members: [member]})
|
|
.expectStatus(422);
|
|
});
|
|
});
|