Ghost/ghost/core/test/e2e-api/admin/members.test.js
Simon Backx 17ec1e8937
Added email address alignment protections (#19094)
ref GRO-54
fixes GRO-63
fixes GRO-62
fixes GRO-69

When the config `hostSettings:managedEmail:enabled` is enabled, or the
new flag (`newEmailAddresses`) is enabled for self-hosters, we'll start
to check the from addresses of all outgoing emails more strictly.

- Current flow: nothing changes if the managedEmail config is not set or
the `newEmailAddresses` feature flag is not set
- When managedEmail is enabled: never allow to send an email from any
chosen email. We always use `mail.from` for all outgoing emails. Custom
addresses should be set as replyTo instead. Changing the newsletter
sender_email is not allowed anymore (and ignored if it is set).
- When managedEmail is enabled with a custom sending domain: if a from
address doesn't match the sending domain, we'll default to mail.from and
use the original as a replyTo if appropriate and only when no other
replyTo was set. A newsletter sender email addresss can only be set to
an email address on this domain.
- When `newEmailAddresses` is enabled: self hosters are free to set all
email addresses to whatever they want, without verification. In addition
to that, we stop making up our own email addresses and send from
`mail.from` by default instead of generating a `noreply`+ `@` +
`sitedomain.com` address

A more in depth example of all cases can be seen in
`ghost/core/test/integration/services/email-addresses.test.js`

Includes lots of new E2E tests for most new situations. Apart from that,
all email snapshots are changed because the from and replyTo addresses
are now included in snapshots (so we can see unexpected changes in the
future).

Dropped test coverage requirement, because tests were failing coverage
locally, but not in CI

Fixed settings test that set the site title to an array - bug tracked in
GRO-68
2023-11-23 10:25:30 +01:00

3359 lines
115 KiB
JavaScript

const {agentProvider, mockManager, fixtureManager, matchers, regexes} = require('../../utils/e2e-framework');
const {anyContentVersion, anyEtag, anyObjectId, anyUuid, anyISODateTime, anyISODate, anyString, anyArray, anyLocationFor, anyContentLength, anyErrorId, anyObject} = matchers;
const {queryStringToken} = regexes;
const ObjectId = require('bson-objectid').default;
const assert = require('assert/strict');
const nock = require('nock');
const sinon = require('sinon');
const should = require('should');
const testUtils = require('../../utils');
const configUtils = require('../../utils/configUtils');
const Papa = require('papaparse');
const models = require('../../../core/server/models');
const membersService = require('../../../core/server/services/members');
const memberAttributionService = require('../../../core/server/services/member-attribution');
const urlService = require('../../../core/server/services/url');
const urlUtils = require('../../../core/shared/url-utils');
const settingsCache = require('../../../core/shared/settings-cache');
const DomainEvents = require('@tryghost/domain-events');
const logging = require('@tryghost/logging');
const {stripeMocker, mockLabsDisabled} = require('../../utils/e2e-framework-mock-manager');
/**
* Assert that haystack and needles match, ignoring the order.
*/
function matchArrayWithoutOrder(haystack, needles) {
// Order shouldn't matter here
for (const a of needles) {
haystack.should.matchAny(a);
}
assert.equal(haystack.length, needles.length, `Expected ${needles.length} items, but got ${haystack.length}`);
}
async function assertMemberEvents({eventType, memberId, asserts}) {
const events = await models[eventType].where('member_id', memberId).fetchAll();
const eventsJSON = events.map(e => e.toJSON());
// Order shouldn't matter here
for (const a of asserts) {
eventsJSON.should.matchAny(a);
}
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 getOtherPaidProduct() {
return (await models.Product.findAll({type: 'paid'})).models[0];
}
async function getNewsletters() {
return (await models.Newsletter.findAll({filter: 'status:active'})).models;
}
async function createMember(data) {
const member = await models.Member.add({
name: '',
email_disabled: false,
...data
});
return member;
}
const newsletterSnapshot = {
id: anyObjectId
};
const attributionSnapshot = {
id: null
};
const subscriptionSnapshot = {
id: anyString,
start_date: anyString,
current_period_end: anyString,
price: {
id: anyString,
price_id: anyObjectId,
tier: {
id: anyString,
tier_id: anyObjectId
}
},
plan: {
id: anyString
},
customer: {
id: anyString
}
};
const tierSnapshot = {
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime,
monthly_price_id: anyString,
yearly_price_id: anyString
};
const subscriptionSnapshotWithTier = {
...subscriptionSnapshot,
tier: tierSnapshot
};
function buildMemberWithoutIncludesSnapshot(options) {
return {
id: anyObjectId,
uuid: anyUuid,
created_at: anyISODateTime,
updated_at: anyISODateTime,
newsletters: new Array(options.newsletters).fill(newsletterSnapshot)
};
}
function buildMemberWithIncludesSnapshot(options) {
return {
id: anyObjectId,
uuid: anyUuid,
created_at: anyISODateTime,
updated_at: anyISODateTime,
attribution: attributionSnapshot,
newsletters: new Array(options.newsletters).fill(newsletterSnapshot),
subscriptions: anyArray,
labels: anyArray
};
}
const tierMatcher = {
id: anyObjectId,
created_at: anyISODateTime,
updated_at: anyISODateTime,
monthly_price_id: anyObjectId,
yearly_price_id: anyObjectId
};
const memberMatcherShallowIncludes = {
id: anyObjectId,
uuid: anyUuid,
created_at: anyISODateTime,
updated_at: anyISODateTime,
subscriptions: anyArray,
labels: anyArray
};
/**
*
* @param {number} tiersCount
* @param {number} newsletterCount
* @returns
*/
const buildMemberMatcherShallowIncludesWithTiers = (tiersCount, newsletterCount) => {
const matcher = {
...memberMatcherShallowIncludes
};
if (tiersCount !== undefined) {
matcher.tiers = new Array(tiersCount).fill(tierMatcher);
}
if (newsletterCount !== undefined) {
matcher.newsletters = new Array(newsletterCount).fill(newsletterSnapshot);
}
return matcher;
};
let agent;
describe('Members API without Stripe', function () {
before(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init();
await agent.loginAsOwner();
await agent
.delete('/settings/stripe/connect/')
.expectStatus(204);
});
beforeEach(function () {
mockManager.mockMail();
mockLabsDisabled('newEmailAddresses');
});
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({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
});
});
});
// Tests specific for member attribution
describe('Members API - member attribution', function () {
const signupAttributions = [];
before(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('posts', 'newsletters', 'members:newsletters', 'comments');
await agent.loginAsOwner();
// This is required so that the only members in this test are created by this test, and not from fixtures.
await models.Member.query().del();
});
beforeEach(function () {
mockManager.mockStripe();
mockManager.mockMail();
});
afterEach(function () {
mockManager.restore();
});
it('Can read member attributed to a post', async function () {
const id = fixtureManager.get('posts', 0).id;
const post = await models.Post.where('id', id).fetch({require: true});
// Set the attribution for this member manually
const member = await membersService.api.members.create({
email: 'member-attributed-to-post@test.com',
attribution: memberAttributionService.attributionBuilder.build({
id,
url: '/out-of-date/',
type: 'post',
referrerSource: null,
referrerMedium: null,
referrerUrl: null
})
});
const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true});
await agent
.get(`/members/${member.id}/`)
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(0, 2))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.expect(({body}) => {
should(body.members[0].attribution).eql({
id: post.id,
url: absoluteUrl,
type: 'post',
title: post.get('title'),
referrer_source: null,
referrer_medium: null,
referrer_url: null
});
signupAttributions.push(body.members[0].attribution);
});
});
it('Can read member attributed to a page', async function () {
const id = fixtureManager.get('posts', 5).id;
const post = await models.Post.where('id', id).fetch({require: true});
// Set the attribution for this member manually
const member = await membersService.api.members.create({
email: 'member-attributed-to-page@test.com',
attribution: memberAttributionService.attributionBuilder.build({
id,
url: '/out-of-date/',
type: 'page',
referrerSource: null,
referrerMedium: null,
referrerUrl: null
})
});
const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true});
await agent
.get(`/members/${member.id}/`)
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(0, 2))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.expect(({body}) => {
should(body.members[0].attribution).eql({
id: post.id,
url: absoluteUrl,
type: 'page',
title: post.get('title'),
referrer_source: null,
referrer_medium: null,
referrer_url: null
});
signupAttributions.push(body.members[0].attribution);
});
});
it('Can read member attributed to a tag', async function () {
const id = fixtureManager.get('tags', 0).id;
const tag = await models.Tag.where('id', id).fetch({require: true});
// Set the attribution for this member manually
const member = await membersService.api.members.create({
email: 'member-attributed-to-tag@test.com',
attribution: memberAttributionService.attributionBuilder.build({
id,
url: '/out-of-date/',
type: 'tag',
referrerSource: null,
referrerMedium: null,
referrerUrl: null
})
});
const absoluteUrl = urlService.getUrlByResourceId(tag.id, {absolute: true});
await agent
.get(`/members/${member.id}/`)
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(0, 2))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.expect(({body}) => {
should(body.members[0].attribution).eql({
id: tag.id,
url: absoluteUrl,
type: 'tag',
title: tag.get('name'),
referrer_source: null,
referrer_medium: null,
referrer_url: null
});
signupAttributions.push(body.members[0].attribution);
});
});
it('Can read member attributed to an author', async function () {
const id = fixtureManager.get('users', 0).id;
const author = await models.User.where('id', id).fetch({require: true});
// Set the attribution for this member manually
const member = await membersService.api.members.create({
email: 'member-attributed-to-author@test.com',
attribution: memberAttributionService.attributionBuilder.build({
id,
url: '/out-of-date/',
type: 'author',
referrerSource: null,
referrerMedium: null,
referrerUrl: null
})
});
const absoluteUrl = urlService.getUrlByResourceId(author.id, {absolute: true});
await agent
.get(`/members/${member.id}/`)
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(0, 2))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.expect(({body}) => {
should(body.members[0].attribution).eql({
id: author.id,
url: absoluteUrl,
type: 'author',
title: author.get('name'),
referrer_source: null,
referrer_medium: null,
referrer_url: null
});
signupAttributions.push(body.members[0].attribution);
});
});
it('Can read member attributed to an url', async function () {
// Set the attribution for this member manually
const member = await membersService.api.members.create({
email: 'member-attributed-to-url@test.com',
attribution: memberAttributionService.attributionBuilder.build({
id: null,
url: '/a-static-page/',
type: 'url',
referrerSource: null,
referrerMedium: null,
referrerUrl: null
})
});
const absoluteUrl = urlUtils.createUrl('/a-static-page/', true);
await agent
.get(`/members/${member.id}/`)
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(0, 2))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.expect(({body}) => {
should(body.members[0].attribution).eql({
id: null,
url: absoluteUrl,
type: 'url',
title: '/a-static-page/',
referrer_source: null,
referrer_medium: null,
referrer_url: null
});
signupAttributions.push(body.members[0].attribution);
});
});
// Activity feed
it('Returns sign up attributions of all types in activity feed', async function () {
// Check activity feed
await agent
.get(`/members/events/?filter=type:signup_event`)
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
events: new Array(signupAttributions.length).fill({
type: anyString,
data: anyObject
})
})
.expect(({body}) => {
should(body.events.find(e => e.type !== 'signup_event')).be.undefined();
should(body.events.map(e => e.data.attribution)).containDeep(signupAttributions);
});
});
});
describe('Members API', function () {
let newsletters;
let emailMockReceiver;
before(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('posts', 'newsletters', 'members:newsletters', 'comments', 'redirects', 'clicks');
await agent.loginAsOwner();
newsletters = await getNewsletters();
});
beforeEach(function () {
mockManager.mockStripe();
emailMockReceiver = mockManager.mockMail();
});
afterEach(function () {
mockManager.restore();
});
// List Members
it('Can browse', async function () {
await agent
.get('/members/')
.expectStatus(200)
.matchBodySnapshot({
members: [
buildMemberMatcherShallowIncludesWithTiers(undefined, 0),
buildMemberMatcherShallowIncludesWithTiers(undefined, 0),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 2),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1)
]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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(buildMemberMatcherShallowIncludesWithTiers(undefined, 1))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can filter by signup attribution', async function () {
await agent
.get('/members/?filter=signup:' + fixtureManager.get('posts', 0).id)
.expectStatus(200)
.matchBodySnapshot({
members: [
buildMemberMatcherShallowIncludesWithTiers(undefined, 0),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1)
]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can filter by signup attribution', async function () {
await agent
.get('/members/?filter=conversion:' + fixtureManager.get('posts', 0).id)
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(undefined, 1))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can browse with search', async function () {
await agent
.get('/members/?search=member1')
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(undefined, 1))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can filter by paid status', async function () {
await agent
.get('/members/?filter=status:paid')
.expectStatus(200)
.matchBodySnapshot({
members: [
buildMemberMatcherShallowIncludesWithTiers(undefined, 0),
buildMemberMatcherShallowIncludesWithTiers(undefined, 0),
buildMemberMatcherShallowIncludesWithTiers(undefined, 2),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1)
]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can filter by tier id', async function () {
const products = await getPaidProduct();
await agent
.get(`/members/?filter=tier_id:[${products.toJSON().id}]`)
.expectStatus(200)
.matchBodySnapshot({
members: [
buildMemberMatcherShallowIncludesWithTiers(undefined, 0),
buildMemberMatcherShallowIncludesWithTiers(undefined, 2),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1)
]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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(buildMemberMatcherShallowIncludesWithTiers(undefined, 0))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can ignore any unknown includes', async function () {
await agent
.get('/members/?filter=status:paid&include=emailRecipients')
.expectStatus(200)
.matchBodySnapshot({
members: [
buildMemberMatcherShallowIncludesWithTiers(undefined, 0),
buildMemberMatcherShallowIncludesWithTiers(undefined, 0),
buildMemberMatcherShallowIncludesWithTiers(undefined, 2),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1)
]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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': anyContentLength,
'content-version': anyContentVersion
})
.matchBodySnapshot({
members: [
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 2),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 0),
buildMemberMatcherShallowIncludesWithTiers(undefined, 0)
]
})
.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': anyContentLength,
'content-version': anyContentVersion
})
.matchBodySnapshot({
members: [
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 2),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 0),
buildMemberMatcherShallowIncludesWithTiers(undefined, 0)
]
})
.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: [buildMemberMatcherShallowIncludesWithTiers(undefined, 1)]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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: [buildMemberMatcherShallowIncludesWithTiers(undefined, 1)]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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: [buildMemberMatcherShallowIncludesWithTiers(undefined, 1)]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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({
'content-version': anyContentVersion,
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(buildMemberMatcherShallowIncludesWithTiers(0, 1))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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(buildMemberMatcherShallowIncludesWithTiers(0, 1))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can read and include tiers', async function () {
await agent
.get(`/members/${testUtils.DataGenerator.Content.members[0].id}/?include=tiers`)
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(0, 1))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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(buildMemberMatcherShallowIncludesWithTiers(0, 0))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('members')
});
const newMember = body.members[0];
// Cannot add same member twice
const loggingStub = sinon.stub(logging, 'error');
await agent
.post(`/members/`)
.body({members: [member]})
.expectStatus(422);
sinon.assert.calledOnce(loggingStub);
await assertMemberEvents({
eventType: 'MemberStatusEvent',
memberId: newMember.id,
asserts: [
{
from_status: null,
to_status: 'free'
}
]
});
});
it('Can add a member and trigger host email verification limits', async function () {
configUtils.set('hostSettings:emailVerification', {
apiThreshold: 0,
adminThreshold: 1,
importThreshold: 0,
verified: false,
escalationAddress: 'test@example.com'
});
assert.ok(!settingsCache.get('email_verification_required'), 'Email verification should NOT be required');
const member = {
name: 'pass verification',
email: 'memberPassVerifivation@test.com'
};
const {body: passBody} = await agent
.post(`/members/`)
.body({members: [member]})
.expectStatus(201)
.matchBodySnapshot({
members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(0, 2))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('members')
});
const memberPassVerification = passBody.members[0];
await DomainEvents.allSettled();
assert.ok(!settingsCache.get('email_verification_required'), 'Email verification should NOT be required');
const memberFailLimit = {
name: 'fail verification',
email: 'memberFailVerifivation@test.com'
};
const {body: failBody} = await agent
.post(`/members/`)
.body({members: [memberFailLimit]})
.expectStatus(201)
.matchBodySnapshot({
members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(0, 2))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('members')
});
const memberFailVerification = failBody.members[0];
await DomainEvents.allSettled();
assert.ok(settingsCache.get('email_verification_required'), 'Email verification should be required');
mockManager.assert.sentEmail({
subject: 'Email needs verification'
});
// state cleanup
await agent.delete(`/members/${memberPassVerification.id}`);
await agent.delete(`/members/${memberFailVerification.id}`);
await configUtils.restore();
});
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]
]
};
// Set site title to something with a special character to ensure subject line doesn't get escaped
// Refs https://github.com/TryGhost/Team/issues/2895
await agent.put('/settings/')
.body({
settings: [
{
key: 'title',
value: 'Ghost\'s Test Site'
}
]
})
.expectStatus(200);
const {body} = await agent
.post('/members/?send_email=true&email_type=signup')
.body({members: [member]})
.expectStatus(201)
.matchBodySnapshot({
members: [
buildMemberWithoutIncludesSnapshot({
newsletters: 2
})
]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyString
});
const newMember = body.members[0];
emailMockReceiver
.assertSentEmailCount(1)
.matchHTMLSnapshot([{
pattern: queryStringToken('token'),
replacement: 'token=REPLACED_TOKEN'
}])
.matchPlaintextSnapshot([{
pattern: queryStringToken('token'),
replacement: 'token=REPLACED_TOKEN'
}])
.matchMetadataSnapshot();
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({
'content-version': anyContentVersion,
etag: anyEtag
})
.expectStatus(204);
// There should be no MemberSubscribeEvent remaining.
await assertMemberEvents({
eventType: 'MemberSubscribeEvent',
memberId: newMember.id,
asserts: []
});
// Reset the site title to the default
await agent.put('/settings/')
.body({
settings: [
{
key: 'title',
value: 'Ghost'
}
]
})
.expectStatus(200);
});
it('Add should fail when passing incorrect email_type query parameter', async function () {
const newMember = {
name: 'test',
email: 'memberTestAdd@test.com'
};
const statusEventsBefore = await models.MemberStatusEvent.findAll();
sinon.stub(logging, 'error');
await agent
.post(`members/?send_email=true&email_type=lel`)
.body({members: [newMember]})
.expectStatus(422)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
});
const statusEvents = await models.MemberStatusEvent.findAll();
assert.equal(statusEvents.models.length, statusEventsBefore.models.length, '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(buildMemberMatcherShallowIncludesWithTiers(0, 1))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('members')
});
const newMember = body.members[0];
await agent
.put(`/members/${newMember.id}/`)
.body({members: [compedPayload]})
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(1, 1))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
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
}]
});
});
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,
tiers: [
{
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.tiers.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]],
tiers: [
{
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.tiers.length, 1, 'The member should have 1 product');
// Remove it
const removePayload = {
id: newMember.id,
email: newMember.email,
tiers: []
};
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.tiers.length, 0, 'The member should have 0 tiers');
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 (complimentary)', async function () {
const product = await getPaidProduct();
const initialMember = {
name: 'Name',
email: 'compedtest4@test.com',
subscribed: true,
newsletters: [newsletters[0]],
tiers: [
{
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,
tiers: 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({
'content-version': anyContentVersion,
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_2',
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]
}
};
stripeMocker.customers.push(fakeCustomer);
stripeMocker.subscriptions.push(fakeSubscription);
stripeMocker.prices.push(fakePrice);
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,
tiers: new Array(1).fill(tierMatcher),
newsletters: new Array(1).fill(newsletterSnapshot)
})
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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]
}
};
stripeMocker.customers.push(fakeCustomer);
stripeMocker.subscriptions.push(fakeSubscription);
stripeMocker.prices.push(fakePrice);
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,
tiers: new Array(1).fill(tierMatcher),
newsletters: new Array(1).fill(newsletterSnapshot)
})
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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 tests
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 tiers while browsing
const {body: browseBody} = await agent.get(`/members/?search=${memberWithPaidSubscription.email}&include=tiers`);
assert.equal(browseBody.members.length, 1, 'The member was not found in browse');
const browseMember = browseBody.members[0];
// Ignore attribution for now
delete readMember.attribution;
for (const sub of readMember.subscriptions) {
delete sub.attribution;
}
// Ignore attribution for now
delete memberWithPaidSubscription.attribution;
for (const sub of memberWithPaidSubscription.subscriptions) {
delete sub.attribution;
}
// 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('Cannot add complimentary subscriptions to a member with an active subscription', async function () {
if (!memberWithPaidSubscription) {
// Previous test failed
this.skip();
}
const product = await getOtherPaidProduct();
const compedPayload = {
id: memberWithPaidSubscription.id,
tiers: [
...memberWithPaidSubscription.tiers,
{
id: product.id
}
]
};
sinon.stub(logging, 'error');
await agent
.put(`/members/${memberWithPaidSubscription.id}/`)
.body({members: [compedPayload]})
.expectStatus(400);
});
it('Cannot remove non complimentary subscriptions directly from a member', async function () {
if (!memberWithPaidSubscription) {
// Previous test failed
this.skip();
}
const compedPayload = {
id: memberWithPaidSubscription.id,
// Remove all paid subscriptions (= not allowed atm)
tiers: []
};
sinon.stub(logging, 'error');
await agent
.put(`/members/${memberWithPaidSubscription.id}/`)
.body({members: [compedPayload]})
.expectStatus(400);
});
it('Can remove a complimentary subscription directly from a member with other active subscriptions', async function () {
// This tests for an edge case that shouldn't be possible, but the API should support this to resolve issues
// refs https://github.com/TryGhost/Team/issues/1859
if (!memberWithPaidSubscription) {
// Previous test failed
this.skip();
}
// Check that the product that we are going to add is not the same as the existing one
const product = await getOtherPaidProduct();
should(memberWithPaidSubscription.tiers).have.length(1);
should(memberWithPaidSubscription.tiers[0].id).not.eql(product.id);
// Add it manually
await models.Member.edit({
products: [
...memberWithPaidSubscription.tiers,
{
id: product.id
}
]
}, {id: memberWithPaidSubscription.id});
// Check status
const {body: body2} = await agent
.get(`/members/${memberWithPaidSubscription.id}/`)
.expectStatus(200);
const beforeMember = body2.members[0];
assert.equal(beforeMember.tiers.length, 2, 'The member should have two tiers now');
// Now try to remove only the complimentary one
const compedPayload = {
id: memberWithPaidSubscription.id,
// Remove all complimentary subscriptions
tiers: memberWithPaidSubscription.tiers
};
const {body} = await agent
.put(`/members/${memberWithPaidSubscription.id}/`)
.body({members: [compedPayload]})
.expectStatus(200);
const updatedMember = body.members[0];
assert.equal(updatedMember.status, 'paid', 'Member should still have the paid status');
assert.equal(updatedMember.tiers.length, 1, 'The member should have one product now');
assert.equal(updatedMember.tiers[0].id, memberWithPaidSubscription.tiers[0].id, 'The member should have the paid product');
});
it('Can keep tiers unchanged when modifying a paid member', async function () {
if (!memberWithPaidSubscription) {
// Previous test failed
this.skip();
}
const compedPayload = {
id: memberWithPaidSubscription.id,
// Not changed tiers
tiers: [...memberWithPaidSubscription.tiers]
};
await agent
.put(`/members/${memberWithPaidSubscription.id}/`)
.body({members: [compedPayload]})
.expectStatus(200);
});
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(buildMemberMatcherShallowIncludesWithTiers(0, 1))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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(buildMemberMatcherShallowIncludesWithTiers(0, 0))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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
}
]
});
});
// Internally a different error is thrown for newsletters/tiers changes
it('Cannot edit a non-existing id with newsletters', async function () {
const memberChanged = {
name: 'changed',
email: 'just-a-member@test.com',
newsletters: []
};
await agent
.put(`/members/${ObjectId().toHexString()}/`)
.body({members: [memberChanged]})
.expectStatus(404)
.matchBodySnapshot({
errors: [{
id: anyUuid,
context: anyString
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Cannot edit a non-existing id', async function () {
const memberChanged = {
name: 'changed',
email: 'just-a-member@test.com'
};
await agent
.put(`/members/${ObjectId().toHexString()}/`)
.body({members: [memberChanged]})
.expectStatus(404)
.matchBodySnapshot({
errors: [{
id: anyUuid,
context: anyString
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can subscribe to a newsletter', async function () {
const clock = sinon.useFakeTimers(Date.now());
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(buildMemberMatcherShallowIncludesWithTiers(0, 1))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('members')
});
const newMember = body.members[0];
const before = new Date();
before.setMilliseconds(0);
await assertMemberEvents({
eventType: 'MemberSubscribeEvent',
memberId: newMember.id,
asserts: [{
subscribed: true,
source: 'admin',
newsletter_id: newsletters[0].id,
created_at: before
}]
});
// Wait 5 seconds to guarantee event ordering
clock.tick(5000);
const after = new Date();
after.setMilliseconds(0);
await agent
.put(`/members/${newMember.id}/`)
.body({members: [memberChanged]})
.expectStatus(200)
.matchBodySnapshot({
members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(0, 1))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
await assertMemberEvents({
eventType: 'MemberSubscribeEvent',
memberId: newMember.id,
asserts: [
{
subscribed: true,
source: 'admin',
newsletter_id: newsletters[0].id,
created_at: before
}, {
subscribed: true,
source: 'admin',
newsletter_id: newsletters[1].id,
created_at: after
}, {
subscribed: false,
source: 'admin',
newsletter_id: newsletters[0].id,
created_at: after
}
]
});
clock.tick(5000);
// Check activity feed
const {body: eventsBody} = await agent
.get(`/members/events?filter=data.member_id:'${newMember.id}'`)
.body({members: [memberChanged]})
.expectStatus(200)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
const events = eventsBody.events;
// The order will be different in each test because two newsletter_events have the same created_at timestamp. And events are ordered by created_at desc, id desc (id will be different each time).
matchArrayWithoutOrder(events, [
{
type: 'newsletter_event',
data: {
subscribed: true,
source: 'admin',
newsletter_id: newsletters[1].id,
newsletter: {
id: newsletters[1].id
}
}
},
{
type: 'newsletter_event',
data: {
subscribed: false,
source: 'admin',
newsletter_id: newsletters[0].id,
newsletter: {
id: newsletters[0].id
}
}
},
{
type: 'signup_event'
},
{
type: 'newsletter_event',
data: {
subscribed: true,
source: 'admin',
newsletter_id: newsletters[0].id,
newsletter: {
id: newsletters[0].id
}
}
}
]);
clock.restore();
});
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(buildMemberMatcherShallowIncludesWithTiers(0, 2))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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;
// Get the stripe price ID of the default price for month
const price = await stripeMocker.getPriceForTier('default-product', 'month');
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: [subscriptionSnapshotWithTier],
newsletters: new Array(1).fill(newsletterSnapshot),
tiers: [tierSnapshot]
})
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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: [subscriptionSnapshotWithTier],
newsletters: new Array(1).fill(newsletterSnapshot),
tiers: [tierSnapshot]
})
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
describe('email_disabled', function () {
const testMemberId = '6543c13c13575e086a06b222';
const suppressedEmail = 'suppressed@email.com';
const okEmail = 'ok@email.com';
let testMember;
let suppression;
beforeEach(async function () {
testMember = await models.Member.add({id: testMemberId, email: okEmail, name: 'Test Member 123', email_disabled: false});
suppression = await models.Suppression.add({
email: suppressedEmail,
reason: 'bounce'
});
});
afterEach(async function () {
// Delete member & suppression
await models.Member.destroy({id: testMember.id});
await models.Suppression.destroy({id: suppression.id});
});
it('Updates the email_disabled field when a member email is updated', async function () {
// Now update the email address of the test member to suppressed email
await agent
.put(`/members/${testMember.id}/`)
.body({members: [{email: suppressedEmail}]})
.expectStatus(200);
// email_disabled should be true
await testMember.refresh();
should(testMember.get('email_disabled')).be.true();
// Now update the email address of that member to a non-suppressed email
await agent
.put(`/members/${testMember.id}/`)
.body({members: [{email: okEmail}]})
.expectStatus(200);
// email_disabled should be false
await testMember.refresh();
should(testMember.get('email_disabled')).be.false();
});
});
// Log out
it('Can log out', async function () {
const member = await createMember({
name: 'test',
email: 'member-log-out-test@test.com'
});
const startTransientId = member.get('transient_id');
await agent
.delete(`/members/${member.id}/sessions/`)
.expectStatus(204)
.matchBodySnapshot()
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
await member.refresh();
assert.notEqual(member.get('transient_id'), startTransientId, 'The transient_id should have changed');
});
// 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(buildMemberMatcherShallowIncludesWithTiers(0, 2))
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('members')
});
const newMember = body.members[0];
await agent
.delete(`/members/${newMember.id}`)
.expectStatus(204)
.expectEmptyBody()
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
await agent
.get(`/members/${newMember.id}/`)
.expectStatus(404)
.matchBodySnapshot({
errors: [{
id: anyUuid
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Cannot delete a non-existent member', async function () {
await agent
.delete('/members/abcd1234abcd1234abcd1234')
.expectStatus(404)
.matchBodySnapshot({
errors: [{
id: anyUuid
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
// Export members to CSV
it('Can export CSV', async function () {
const res = await agent
.get(`/members/upload/?limit=all`)
.expectStatus(200)
.expectEmptyBody() // express-test body parsing doesn't support CSV
.matchHeaderSnapshot({
etag: anyEtag,
'content-version': anyContentVersion,
'content-length': anyContentLength,
'content-disposition': anyString
});
res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,tiers/);
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'));
should.exist(csv.data.find(row => row.tiers.length > 0));
should.exist(csv.data.find(row => row.labels.length > 0));
});
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-version': anyContentVersion,
'content-disposition': anyString
});
res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,tiers/);
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'));
// note that this member doesn't have tiers
should.exist(csv.data.find(row => row.labels.length > 0));
});
it('Can delete a member without cancelling Stripe Subscription', async function () {
let subscriptionCanceled = false;
mockManager.restore();
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({
'content-version': anyContentVersion,
etag: anyEtag
});
assert.equal(subscriptionCanceled, false, 'expected subscription not to be canceled');
});
it('Can delete a member while cancelling Stripe Subscription', async function () {
let subscriptionCanceled = false;
mockManager.restore();
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', 3);
await agent
.delete(`members/${memberToDelete.id}/?cancel=true`)
.expectStatus(204)
.expectEmptyBody()
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
assert.equal(subscriptionCanceled, true, 'expected subscription to be canceled');
});
// Get stats
it('Can fetch member counts stats', async function () {
await agent
.get(`/members/stats/count/`)
.expectStatus(200)
.matchBodySnapshot({
data: [{
date: anyISODate
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Errors when fetching stats with unknown days param value', async function () {
sinon.stub(logging, 'error');
await agent
.get('members/stats/?days=nope')
.expectStatus(422)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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: [
buildMemberMatcherShallowIncludesWithTiers(undefined, 2),
buildMemberMatcherShallowIncludesWithTiers(undefined, 1),
buildMemberMatcherShallowIncludesWithTiers(undefined, 2)
]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can filter on tier slug', async function () {
await agent
.get('/members/?include=tiers&filter=tier:default-product')
.expectStatus(200)
.matchBodySnapshot({
members: [
buildMemberMatcherShallowIncludesWithTiers(1, 1),
buildMemberMatcherShallowIncludesWithTiers(1, 1),
buildMemberMatcherShallowIncludesWithTiers(1, 1),
buildMemberMatcherShallowIncludesWithTiers(1, 1),
buildMemberMatcherShallowIncludesWithTiers(1, 1),
buildMemberMatcherShallowIncludesWithTiers(1, 0),
buildMemberMatcherShallowIncludesWithTiers(1, 2),
buildMemberMatcherShallowIncludesWithTiers(1, 1)
]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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({
'content-version': anyContentVersion,
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({
'content-version': anyContentVersion,
etag: anyEtag
});
sinon.stub(logging, 'error');
await agent
.post(`/members/`)
.body({members: [member]})
.expectStatus(422);
});
it('Setting subscribed when editing a member won\'t reset to default newsletters', async function () {
// First check that this newsletter is off by default, or this test would not make sense
const newsletter = await models.Newsletter.findOne({id: testUtils.DataGenerator.Content.newsletters[0].id}, {require: true});
assert.equal(newsletter.get('subscribe_on_signup'), false, 'This test expects the newsletter to be off by default');
// Add custom newsletter list to new member
const member = {
name: 'test newsletter',
email: 'memberTestChangeSubscribedAttribute@test.com',
newsletters: [
{
id: testUtils.DataGenerator.Content.newsletters[0].id // This is off by default
},
{
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(2).fill(newsletterSnapshot)
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('members')
});
const memberId = body.members[0].id;
const editedMember = {
subscribed: true // no change
};
// Edit member
const {body: body2} = 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(2).fill(newsletterSnapshot)
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
const changedMember = body2.members[0];
assert.equal(changedMember.newsletters.length, 2);
assert.ok(changedMember.newsletters.find(n => n.id === testUtils.DataGenerator.Content.newsletters[0].id), 'The member is still subscribed for a newsletter that is off by default');
assert.ok(changedMember.newsletters.find(n => n.id === testUtils.DataGenerator.Content.newsletters[1].id), 'The member is still subscribed for the newsletter it subscribed to');
});
it('Adding newsletters to member with no subscriptions works even with subscribed false', async function () {
// Add member with no subscriptions
const member = {
name: 'test newsletter',
email: 'memberAddNewsletterSubscribed@test.com',
newsletters: []
};
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(0).fill(newsletterSnapshot)
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('members')
});
const memberId = body.members[0].id;
const editedMember = {
subscribed: false,
newsletters: [
{
id: testUtils.DataGenerator.Content.newsletters[0].id
}
]
};
// Edit member
const {body: body2} = 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({
'content-version': anyContentVersion,
etag: anyEtag
});
const changedMember = body2.members[0];
assert.equal(changedMember.newsletters.length, 1);
assert.ok(changedMember.newsletters.find(n => n.id === testUtils.DataGenerator.Content.newsletters[0].id), 'The member should be subscribed to the newsletter');
});
it('Updating member data without newsletters does not change newsletters', async function () {
// check that this newsletter is archived, or this test would not make sense
const archivedNewsletterId = testUtils.DataGenerator.Content.newsletters[2].id;
const archivedNewsletter = await models.Newsletter.findOne({id: archivedNewsletterId}, {require: true});
assert.equal(archivedNewsletter.get('status'), 'archived', 'This test expects the newsletter to be archived');
const member = await models.Member.findOne({id: testUtils.DataGenerator.Content.members[5].id}, {withRelated: ['newsletters']});
const memberNewsletters = member.related('newsletters').models;
// NOTE: removed this call for now; it's not necessary as it's just 'bonus validation' before executing the api calls
// unfortunately it led to some issues where the object id was not in sync between fixture data and the db (unsure of cause)
// assert.equal(memberNewsletters[1].id, archivedNewsletterId, 'This test expects the member to be subscribed to an archived newsletter');
assert.equal(memberNewsletters.length, 2, 'This test expects the member to have two newsletter subscriptions');
const memberId = member.get('id');
const editedMember = {
id: memberId,
name: 'new name'
};
// edit member
const {body} = 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({
'content-version': anyContentVersion,
etag: anyEtag
});
const changedMember = body.members[0];
assert.equal(changedMember.newsletters.length, 1); // the api only returns active newsletters
assert.ok(changedMember.newsletters.find(n => n.id === testUtils.DataGenerator.Content.newsletters[1].id), 'The member is still subscribed to an active newsletter');
const changedMemberFromDb = await models.Member.findOne({id: testUtils.DataGenerator.Content.members[5].id}, {withRelated: ['newsletters']});
assert.ok(changedMemberFromDb.related('newsletters').models.find(n => n.id === testUtils.DataGenerator.Content.newsletters[2].id), 'The member is still subscribed to the archived newsletter it subscribed to');
});
it('Updating newsletter subscriptions does not unsubscribe member from archived newsletter', async function () {
// check that this newsletter is archived, or this test would not make sense
const archivedNewsletterId = testUtils.DataGenerator.Content.newsletters[2].id;
const archivedNewsletter = await models.Newsletter.findOne({id: archivedNewsletterId}, {require: true});
assert.equal(archivedNewsletter.get('status'), 'archived', 'This test expects the newsletter to be archived');
const member = await models.Member.findOne({id: testUtils.DataGenerator.Content.members[5].id}, {withRelated: ['newsletters']});
const memberNewsletters = member.related('newsletters').models;
// NOTE: removed this call for now; it's not necessary as it's just 'bonus validation' before executing the api calls
// unfortunately it led to some issues where the object id was not in sync between fixture data and the db (unsure of cause)
// assert.equal(memberNewsletters[1].id, archivedNewsletterId, 'This test expects the member to be subscribed to an archived newsletter');
assert.equal(memberNewsletters.length, 2, 'This test expects the member to have two newsletter subscriptions');
// remove active newsletter subscriptions
const memberId = member.get('id');
const editedMember = {
newsletters: []
};
// edit member
const {body} = 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: new Array(0)
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
const changedMember = body.members[0];
assert.equal(changedMember.newsletters.length, 0); // the api only returns active newsletters, so this member should have none
const changedMemberFromDb = await models.Member.findOne({id: testUtils.DataGenerator.Content.members[5].id}, {withRelated: ['newsletters']});
assert.ok(changedMemberFromDb.related('newsletters').models.find(n => n.id === testUtils.DataGenerator.Content.newsletters[2].id), 'The member is still subscribed to the archived newsletter it subscribed to');
});
it('Can add and send a signup confirmation email (old)', async function () {
const filteredNewsletters = newsletters.filter(n => n.get('subscribe_on_signup'));
filteredNewsletters.length.should.be.greaterThan(0, 'For this test to work, we need at least one newsletter fixture with subscribe_on_signup = true');
const member = {
name: 'Send Me Confirmation',
email: 'member_getting_confirmation_old@test.com',
// Mapped to subscribe_on_signup newsletters
subscribed: true
};
const {body} = await agent
.post('/members/?send_email=true&email_type=signup')
.body({members: [member]})
.expectStatus(201)
.matchBodySnapshot({
members: [
buildMemberWithoutIncludesSnapshot({
newsletters: filteredNewsletters.length
})
]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyString
});
const newMember = body.members[0];
mockManager.assert.sentEmail({
subject: '🙌 Complete your sign up to Ghost!',
to: 'member_getting_confirmation_old@test.com'
});
await assertMemberEvents({
eventType: 'MemberStatusEvent',
memberId: newMember.id,
asserts: [
{
from_status: null,
to_status: 'free'
}
]
});
await assertMemberEvents({
eventType: 'MemberSubscribeEvent',
memberId: newMember.id,
asserts: filteredNewsletters.map((n) => {
return {
subscribed: true,
newsletter_id: n.id,
source: 'admin'
};
})
});
// @TODO: do we really need to delete this member here?
await agent
.delete(`members/${body.members[0].id}/`)
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
})
.expectStatus(204);
// There should be no MemberSubscribeEvent remaining.
await assertMemberEvents({
eventType: 'MemberSubscribeEvent',
memberId: newMember.id,
asserts: []
});
});
it('Can add a member that is not subscribed (old)', async function () {
const filteredNewsletters = newsletters.filter(n => n.get('subscribe_on_signup'));
filteredNewsletters.length.should.be.greaterThan(0, 'For this test to work, we need at least one newsletter fixture with subscribe_on_signup = true');
const member = {
name: 'Send Me Confirmation',
email: 'member_getting_confirmation_old_2@test.com',
// Mapped to empty newsletters
subscribed: false
};
const {body} = await agent
.post('/members/?send_email=true&email_type=signup')
.body({members: [member]})
.expectStatus(201)
.matchBodySnapshot({
members: [
buildMemberWithoutIncludesSnapshot({
newsletters: 0
})
]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyString
});
const newMember = body.members[0];
mockManager.assert.sentEmail({
subject: '🙌 Complete your sign up to Ghost!',
to: 'member_getting_confirmation_old_2@test.com'
});
await assertMemberEvents({
eventType: 'MemberStatusEvent',
memberId: newMember.id,
asserts: [
{
from_status: null,
to_status: 'free'
}
]
});
await assertMemberEvents({
eventType: 'MemberSubscribeEvent',
memberId: newMember.id,
asserts: []
});
});
it('Can unsubscribe by setting (old) subscribed property to false', async function () {
const memberToChange = {
name: 'change me',
email: 'member2unsusbcribeold@test.com',
note: 'initial note',
newsletters: [
newsletters[0]
]
};
const memberChanged = {
subscribed: false
};
const {body} = await agent
.post(`/members/`)
.body({members: [memberToChange]})
.expectStatus(201)
.matchBodySnapshot({
members: [
buildMemberWithIncludesSnapshot({
newsletters: 1
})
]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
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: [
buildMemberWithIncludesSnapshot({
newsletters: 0
})
]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
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 by setting (old) subscribed property to true', async function () {
const filteredNewsletters = newsletters.filter(n => n.get('subscribe_on_signup'));
filteredNewsletters.length.should.be.greaterThan(0, 'For this test to work, we need at least one newsletter fixture with subscribe_on_signup = true');
const memberToChange = {
name: 'change me',
email: 'member2subscribe@test.com',
note: 'initial note',
newsletters: []
};
const memberChanged = {
subscribed: true
};
const {body} = await agent
.post(`/members/`)
.body({members: [memberToChange]})
.expectStatus(201)
.matchBodySnapshot({
members: [
buildMemberWithIncludesSnapshot({
newsletters: 0
})
]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag,
location: anyLocationFor('members')
});
const newMember = body.members[0];
await assertMemberEvents({
eventType: 'MemberSubscribeEvent',
memberId: newMember.id,
asserts: []
});
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: [
buildMemberWithIncludesSnapshot({
newsletters: filteredNewsletters.length
})
]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
await assertMemberEvents({
eventType: 'MemberSubscribeEvent',
memberId: newMember.id,
asserts: filteredNewsletters.map((n) => {
return {
subscribed: true,
source: 'admin',
newsletter_id: n.id
};
})
});
});
});
describe('Members API Bulk operations', function () {
beforeEach(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('newsletters', 'members:newsletters');
await agent.loginAsOwner();
mockManager.mockStripe();
mockManager.mockMail();
});
afterEach(function () {
mockManager.restore();
});
it('Can bulk unsubscribe members with filter', async function () {
// This member has 2 subscriptions
const member = fixtureManager.get('members', 4);
const newsletterCount = 2;
const model = await models.Member.findOne({id: member.id}, {withRelated: 'newsletters'});
should(model.relations.newsletters.models.length).equal(newsletterCount, 'This test requires a member with 2 or more newsletters');
await agent
.put(`/members/bulk/?filter=id:'${member.id}'`)
.body({bulk: {
action: 'unsubscribe'
}})
.expectStatus(200)
.matchBodySnapshot({
bulk: {
meta: {
stats: {
// Should contain the count of members, not the newsletter count!
successful: 1,
unsuccessful: 0
},
unsuccessfulData: [],
errors: []
}
}
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
const updatedModel = await models.Member.findOne({id: member.id}, {withRelated: 'newsletters'});
should(updatedModel.relations.newsletters.models.length).equal(0, 'This member should be unsubscribed from all newsletters');
// When we do it again, we should still receive a count of 1, because we unsubcribed one member (who happens to be already unsubscribed)
await agent
.put(`/members/bulk/?filter=id:'${member.id}'`)
.body({bulk: {
action: 'unsubscribe'
}})
.expectStatus(200)
.matchBodySnapshot({
bulk: {
meta: {
stats: {
// Should contain the count of members, not the newsletter count!
successful: 1,
unsuccessful: 0
},
unsuccessfulData: [],
errors: []
}
}
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can bulk unsubscribe members from specific newsletter', async function () {
const member = fixtureManager.get('members', 4);
const newsletterCount = 2;
const model = await models.Member.findOne({id: member.id}, {withRelated: 'newsletters'});
should(model.relations.newsletters.models.length).equal(newsletterCount, 'This test requires a member with 2 or more newsletters');
await agent
.put(`/members/bulk/?all=true`)
.body({bulk: {
action: 'unsubscribe',
newsletter: model.relations.newsletters.models[0].id,
meta: {}
}})
.expectStatus(200)
.matchBodySnapshot({
bulk: {
meta: {
stats: {
successful: 4,
unsuccessful: 0
},
unsuccessfulData: [],
errors: []
}
}
});
const updatedModel = await models.Member.findOne({id: member.id}, {withRelated: 'newsletters'});
// ensure they were unsubscribed from the single 'chosen' newsletter
should(updatedModel.relations.newsletters.models.length).equal(newsletterCount - 1);
});
it('Can bulk unsubscribe members with deprecated subscribed filter', async function () {
await agent
.put(`/members/bulk/?filter=subscribed:false`)
.body({bulk: {
action: 'unsubscribe'
}})
.expectStatus(200)
.matchBodySnapshot({
bulk: {
meta: {
stats: {
successful: 2, // We have two members who are subscribed to an inactive newsletter
unsuccessful: 0
},
unsuccessfulData: [],
errors: []
}
}
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can bulk unsubscribe members with deprecated subscribed filter (actual)', async function () {
// This member is subscribed to an inactive newsletter
const ignoredMember = fixtureManager.get('members', 6);
await agent
.put(`/members/bulk/?filter=subscribed:true`)
.body({bulk: {
action: 'unsubscribe'
}})
.expectStatus(200)
.matchBodySnapshot({
bulk: {
meta: {
stats: {
successful: 6, // not 7 because members subscribed to an inactive newsletter aren't subscribed (newsletter fixture[2])
unsuccessful: 0
},
unsuccessfulData: [],
errors: []
}
}
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
const allMembers = await models.Member.findAll({withRelated: 'newsletters'});
for (const model of allMembers) {
if (model.id === ignoredMember.id) {
continue;
}
should(model.relations.newsletters.models.length).equal(0, 'This member should be unsubscribed from all newsletters');
}
});
it('Can bulk delete a label from members', async function () {
await agent
.put(`/members/bulk/?all=true`)
.body({bulk: {
action: 'removeLabel',
meta: {
label: {
// Note! this equals DataGenerator.Content.labels[2]
// the index is different in the fixtureManager
id: fixtureManager.get('labels', 1).id
}
}
}})
.expectStatus(200)
.matchBodySnapshot({
bulk: {
meta: {
stats: {
successful: 2,
unsuccessful: 0
},
unsuccessfulData: [],
errors: []
}
}
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
await agent
.put(`/members/bulk/?all=true`)
.body({bulk: {
action: 'removeLabel',
meta: {
label: {
id: fixtureManager.get('labels', 0).id
}
}
}})
.expectStatus(200)
.matchBodySnapshot({
bulk: {
meta: {
stats: {
successful: 1,
unsuccessful: 0
},
unsuccessfulData: [],
errors: []
}
}
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it(`Doesn't delete labels apart from the passed label id`, async function () {
const member = fixtureManager.get('members', 1);
// Manually add 2 labels to a member
await models.Member.edit({labels: [{name: 'first-tag'}, {name: 'second-tag'}]}, {id: member.id});
const model = await models.Member.findOne({id: member.id}, {withRelated: 'labels'});
should(model.relations.labels.models.map(m => m.get('name'))).match(['first-tag', 'second-tag']);
const firstId = model.relations.labels.models[0].id;
const secondId = model.relations.labels.models[1].id;
// Delete first label only
await agent
.put(`/members/bulk/?all=true`)
.body({bulk: {
action: 'removeLabel',
meta: {
label: {
id: secondId
}
}
}})
.expectStatus(200)
.matchBodySnapshot({
bulk: {
meta: {
stats: {
successful: 1,
unsuccessful: 0
},
unsuccessfulData: [],
errors: []
}
}
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
const updatedModel = await models.Member.findOne({id: member.id}, {withRelated: 'labels'});
should(updatedModel.relations.labels.models.map(m => m.id)).match([firstId]);
});
it('Can bulk delete a label from members with filters', async function () {
const member1 = fixtureManager.get('members', 0);
const member2 = fixtureManager.get('members', 1);
// Manually add 2 labels to a member
await models.Member.edit({labels: [{name: 'first-tag'}, {name: 'second-tag'}]}, {id: member1.id});
const model1 = await models.Member.findOne({id: member1.id}, {withRelated: 'labels'});
should(model1.relations.labels.models.map(m => m.get('name'))).match(['first-tag', 'second-tag']);
const firstId = model1.relations.labels.models[0].id;
const secondId = model1.relations.labels.models[1].id;
await models.Member.edit({labels: [{name: 'first-tag'}, {name: 'second-tag'}]}, {id: member2.id});
const model2 = await models.Member.findOne({id: member2.id}, {withRelated: 'labels'});
should(model2.relations.labels.models.map(m => m.id)).match([firstId, secondId]);
await agent
.put(`/members/bulk/?filter=id:'${member1.id}'`)
.body({bulk: {
action: 'removeLabel',
meta: {
label: {
// Note! this equals DataGenerator.Content.labels[2]
// the index is different in the fixtureManager
id: firstId
}
}
}})
.expectStatus(200)
.matchBodySnapshot({
bulk: {
meta: {
stats: {
successful: 1,
unsuccessful: 0
},
unsuccessfulData: [],
errors: []
}
}
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
const updatedModel1 = await models.Member.findOne({id: member1.id}, {withRelated: 'labels'});
should(updatedModel1.relations.labels.models.map(m => m.id)).match([secondId]);
const updatedModel2 = await models.Member.findOne({id: member2.id}, {withRelated: 'labels'});
should(updatedModel2.relations.labels.models.map(m => m.id)).match([firstId, secondId]);
});
it('Can bulk delete members', async function () {
await agent
.delete('/members?all=true')
.expectStatus(200)
.matchBodySnapshot({
meta: {
stats: {
successful: 8,
unsuccessful: 0
},
unsuccessfulIds: [],
errors: []
}
});
});
});