Ghost/test/e2e-api/admin/members.test.js
Rishabh Garg 756f86dbdc
Added uuid to newsletter schema (#14600)
refs https://github.com/TryGhost/Team/issues/1561

With multiple newsletters, unsubscribe links will also need to have a unique reference to the newsletter that the email is for, so that we can unsubscribe members from the particular newsletter automatically when they click on the link.
As our existing pattern for members is to use UUID as the external unique reference, this change adds UUID to newsletter schema and populates the existing newsletters with a UUID value.

- adds new `uuid` column to newsletter schema
- updates newsletter model to add default uuid
- updates default newsletter migration to add `uuid`
- drops nullable on `uuid` column later in migrations once we have populated existing newsletters
2022-04-27 19:20:25 +05:30

1629 lines
51 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');
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 models.Product.findOne({type: 'paid'});
}
async function getNewsletters() {
return (await models.Newsletter.findAll({filter: 'status:active'})).models;
}
const newsletterSnapshot = {
id: anyObjectId,
uuid: anyUuid,
created_at: anyISODateTime,
updated_at: anyISODateTime
};
function buildMemberWithoutIncludesSnapshot(options) {
return {
id: anyObjectId,
uuid: anyUuid,
created_at: anyISODateTime,
updated_at: anyISODateTime,
newsletters: new Array(options.newsletters).fill(newsletterSnapshot)
};
}
const memberMatcherShallowIncludes = {
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 () {
let newsletters;
before(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('newsletters', 'members:newsletters');
await agent.loginAsOwner();
newsletters = await getNewsletters();
});
beforeEach(function () {
mockManager.mockLabsEnabled('multipleProducts');
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(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',
newsletters: [],
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 (old)', 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: [news]
})
.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('Can add and send a signup confirmation email', async function () {
const member = {
name: 'Send Me Confirmation',
email: 'member_getting_confirmation@test.com',
newsletters: [
newsletters[0],
newsletters[1]
]
};
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: [
buildMemberWithoutIncludesSnapshot({
newsletters: 2
})
]
})
.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',
newsletter_id: newsletters[0].id
},
{
subscribed: true,
source: 'admin',
newsletter_id: newsletters[1].id
}
]
});
// @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',
newsletters: [newsletters[0]]
};
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',
newsletter_id: newsletters[0].id
}]
});
});
it('Can add complimentary subscription by assigning a product to a member', async function () {
const initialMember = {
name: 'Name',
email: 'compedtest2@test.com',
newsletters: [newsletters[0]]
};
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',
newsletter_id: newsletters[0].id
}]
});
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',
newsletters: [newsletters[0]],
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',
newsletter_id: newsletters[0].id
}
]
});
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,
newsletters: [newsletters[0]],
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
}),
newsletters: new Array(1).fill(newsletterSnapshot)
})
})
.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,
newsletters: [newsletters[0]],
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,
newsletters: new Array(1).fill(newsletterSnapshot)
})
})
.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,
newsletters: [newsletters[0]],
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,
newsletters: new Array(1).fill(newsletterSnapshot)
})
})
.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',
newsletters: [
newsletters[0]
]
};
const memberChanged = {
name: 'changed',
email: 'cantChangeMe@test.com',
note: 'edited note',
newsletters: []
};
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',
newsletter_id: newsletters[0].id
}]
});
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',
newsletter_id: newsletters[0].id
}, {
subscribed: false,
source: 'admin',
newsletter_id: newsletters[0].id
}
]
});
});
it('Can subscribe to a newsletter', async function () {
const memberToChange = {
name: 'change me',
email: 'member3change@test.com',
newsletters: [
newsletters[0]
]
};
const memberChanged = {
newsletters: [
newsletters[1]
]
};
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',
newsletter_id: newsletters[0].id
}]
});
await agent
.put(`/members/${newMember.id}/`)
.body({members: [memberChanged]})
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(memberMatcherShallowIncludes)
})
.matchHeaderSnapshot({
etag: anyEtag
});
await assertMemberEvents({
eventType: 'MemberSubscribeEvent',
memberId: newMember.id,
asserts: [
{
subscribed: true,
source: 'admin',
newsletter_id: newsletters[0].id
}, {
subscribed: true,
source: 'admin',
newsletter_id: newsletters[1].id
}, {
subscribed: false,
source: 'admin',
newsletter_id: newsletters[0].id
}
]
});
});
it('Subscribes to default newsletters', async function () {
const filtered = newsletters.filter(n => n.get('subscribe_on_signup'));
filtered.length.should.be.greaterThan(0, 'There should be at least one newsletter with subscribe on signup for this test to work');
const memberToCreate = {
name: 'create me',
email: 'member2create@test.com'
};
const {body} = await agent
.post(`/members/`)
.body({members: [memberToCreate]})
.expectStatus(201)
.matchBodySnapshot({
members: new Array(1).fill(memberMatcherShallowIncludes)
})
.matchHeaderSnapshot({
etag: anyEtag,
location: anyLocationFor('members')
});
const newMember = body.members[0];
newMember.newsletters.should.match([
{
id: filtered[0].id
},
{
id: filtered[1].id
}
]);
await assertMemberEvents({
eventType: 'MemberSubscribeEvent',
memberId: newMember.id,
asserts: filtered.map((n) => {
return {
subscribed: true,
source: 'admin',
newsletter_id: n.id
};
})
});
});
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
}
}
}],
newsletters: anyArray
})
})
.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
}
}
}],
newsletters: anyArray
})
})
.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
}]
});
});
it('Can filter on newsletter slug', async function () {
await agent
.get('/members/?filter=newsletters:weekly-newsletter')
.expectStatus(200)
.matchBodySnapshot({
members: new Array(4).fill(memberMatcherShallowIncludes)
})
.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(newsletterSnapshot)
}]
})
.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(newsletterSnapshot)
}]
})
.matchHeaderSnapshot({
etag: anyEtag
});
await agent
.post(`/members/`)
.body({members: [member]})
.expectStatus(422);
});
});