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 conversion 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 unknown tiers to a member', async function () { const memberId = testUtils.DataGenerator.Content.members[0].id; const unknownProductId = 'blahblahid'; sinon.stub(logging, 'error'); await agent .put(`/members/${memberId}/`) .body({ members: [{ tiers: [{ id: unknownProductId }] }] }) .expectStatus(400) .matchBodySnapshot({ errors: [{ id: anyErrorId }] }); }); 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 }); }); it('can change the email address', async function () { const memberToChange = { name: 'Jon Snow', email: 'jon.snow@test.com', newsletters: [] }; const memberChanged = { email: 'aegon.targaryen@test.com' }; // Create member const {body} = await agent .post(`/members/`) .body({members: [memberToChange]}) .expectStatus(201) .matchBodySnapshot({ members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(0, 0)) }) .matchHeaderSnapshot({ 'content-version': anyContentVersion, etag: anyEtag, location: anyLocationFor('members') }); // Update email address const newMember = body.members[0]; 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 }); // Check member events await assertMemberEvents({ eventType: 'MemberEmailChangeEvent', memberId: newMember.id, asserts: [ { from_email: 'jon.snow@test.com', to_email: 'aegon.targaryen@test.com' } ] }); // 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; matchArrayWithoutOrder(events, [ { type: 'email_change_event', data: { from_email: 'jon.snow@test.com', to_email: 'aegon.targaryen@test.com' } }, { type: 'signup_event' } ]); }); 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: [] } }); }); });