0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

🐛 Fixed member exports timing out for large sites (#14876) (#14878)

refs TryGhost/Team#1641

This commit adds a custom query for the members export, to improve the performance and to prevent any timeouts from happening when exporting large amounts of members.

Co-authored-by: Simon Backx <simon@ghost.org>
Co-authored-by: Matt Hanley <git@matthanley.co.uk>
This commit is contained in:
Hannah Wolfe 2022-05-20 21:25:23 +01:00 committed by GitHub
parent eae0a6a3b9
commit 8dd009ffa0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 622 additions and 14 deletions

View file

@ -349,10 +349,9 @@ module.exports = {
},
validation: {},
async query(frame) {
frame.options.withRelated = ['labels', 'stripeSubscriptions', 'stripeSubscriptions.customer', 'products', 'newsletters'];
const page = await membersService.api.members.list(frame.options);
return page;
return {
data: await membersService.export(frame.options)
};
}
},

View file

@ -77,18 +77,13 @@ function bulkAction(bulkActionResult, _apiConfig, frame) {
/**
* @template PageMeta
*
* @param {{data: import('bookshelf').Model[], meta: PageMeta}} page
* @param {APIConfig} _apiConfig
* @param {Frame} frame
* @param {{data: any[]}} data
*
* @returns {string} - A CSV string
*/
function exportCSV(page, _apiConfig, frame) {
function exportCSV(data) {
debug('exportCSV');
const members = page.data.map(model => serializeMember(model, frame.options));
return unparse(members);
return unparse(data.data);
}
/**

View file

@ -0,0 +1,88 @@
const models = require('../../../models');
const {knex} = require('../../../data/db');
const moment = require('moment');
module.exports = async function (options) {
const hasFilter = options.limit !== 'all' || options.filter || options.search;
let ids = null;
if (hasFilter) {
// do a very minimal query, only to fetch the ids of the filtered values
// should be quite fast
options.withRelated = [];
options.columns = ['id'];
const page = await models.Member.findPage(options);
ids = page.data.map(d => d.id);
/*
const filterOptions = _.pick(options, ['transacting', 'context']);
if (all !== true) {
// Include mongoTransformer to apply subscribed:{true|false} => newsletter relation mapping
Object.assign(filterOptions, _.pick(options, ['filter', 'search', 'mongoTransformer']));
}
const memberRows = await models.Member.getFilteredCollectionQuery(filterOptions)
.select('members.id')
.distinct();
ids = memberRows.map(row => row.id);
*/
}
const allProducts = await models.Product.fetchAll();
const allLabels = await models.Label.fetchAll();
let query = knex('members')
.select('id', 'email', 'name', 'note', 'status', 'created_at')
.select(knex.raw(`
(CASE WHEN EXISTS (SELECT 1 FROM members_newsletters n WHERE n.member_id = members.id)
THEN TRUE ELSE FALSE
END) as subscribed
`))
.select(knex.raw(`
(SELECT GROUP_CONCAT(product_id) FROM members_products f WHERE f.member_id = members.id) as tiers
`))
.select(knex.raw(`
(SELECT GROUP_CONCAT(label_id) FROM members_labels f WHERE f.member_id = members.id) as labels
`))
.select(knex.raw(`
(SELECT customer_id FROM members_stripe_customers f WHERE f.member_id = members.id limit 1) as stripe_customer_id
`));
if (hasFilter) {
query = query.whereIn('id', ids);
}
const rows = await query;
for (const row of rows) {
const tierIds = row.tiers ? row.tiers.split(',') : [];
const tiers = tierIds.map((id) => {
const tier = allProducts.find(p => p.id === id);
return {
name: tier.get('name')
};
});
row.tiers = tiers;
const labelIds = row.labels ? row.labels.split(',') : [];
const labels = labelIds.map((id) => {
const label = allLabels.find(l => l.id === id);
return {
name: label.get('name')
};
});
row.labels = labels;
}
for (const member of rows) {
// Note: we don't modify the array or change/duplicate objects
// to increase performance
member.subscribed = !!member.subscribed;
member.comped = member.status === 'comped';
member.created_at = moment(member.created_at).toISOString();
}
return rows;
};

View file

@ -175,7 +175,7 @@ module.exports = {
processImport: processImport,
stats: membersStats
stats: membersStats,
export: require('./exporter/query')
};
module.exports.middleware = require('./middleware');

View file

@ -0,0 +1,287 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Members API — exportCSV Can export a member without products 1: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export a member without products 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export a member without products 3: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export comped 1: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export comped 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export comped 3: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export comped 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export customer id 1: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export customer id 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export customer id 3: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export customer id 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export labels 1: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export labels 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export labels 3: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export labels 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export newsletters 1: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export newsletters 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export newsletters 3: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export products 1: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export products 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export products 3: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;
exports[`Members API — exportCSV Can export products 4: [headers] 1`] = `
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-disposition": Any<String>,
"content-length": Any<String>,
"content-type": "text/csv; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
"x-powered-by": "Express",
}
`;

View file

@ -0,0 +1,239 @@
const {agentProvider, mockManager, fixtureManager, matchers} = require('../../utils/e2e-framework');
const {anyEtag, anyString} = matchers;
const uuid = require('uuid');
const should = require('should');
const Papa = require('papaparse');
const models = require('../../../core/server/models');
const moment = require('moment');
async function createMember(data) {
const member = await models.Member.add({
email: uuid.v4() + '@example.com',
name: '',
...data
});
return member;
}
let agent;
let tiers, labels, newsletters;
function basicAsserts(member, row) {
// Basic checks
should(row.email).eql(member.get('email'));
should(row.name).eql(member.get('name'));
should(row.note).eql(member.get('note') || '');
should(row.deleted_at).eql('');
should(row.created_at).eql(moment(member.get('created_at')).toISOString());
}
/**
*
* @param {(row: any) => void} asserts
*/
async function testOutput(member, asserts, filters = []) {
// Add default filters that always should match
filters.push('limit=all');
filters.push(`filter=id:${member.id}`);
for (const filter of filters) {
// Test all
let res = await agent
.get(`/members/upload/?${filter}`)
.expectStatus(200)
.expectEmptyBody()
.matchHeaderSnapshot({
etag: anyEtag,
'content-length': anyString,
'content-disposition': anyString
});
res.text.should.match(/id,email,name,note,subscribed_to_emails,complimentary_plan,stripe_customer_id,created_at,deleted_at,labels,products/);
let csv = Papa.parse(res.text, {header: true});
let row = csv.data.find(r => r.id === member.id);
should.exist(row);
asserts(row);
if (filter === 'filter=id:${member.id}') {
csv.data.length.should.eql(1);
}
}
}
describe('Members API — exportCSV', function () {
before(async function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('newsletters', 'tiers:archived');
await agent.loginAsOwner();
await models.Product.add({
name: 'Extra Paid Product',
slug: 'extra-product',
type: 'paid',
active: true,
visibility: 'public'
});
tiers = (await models.Product.findAll()).models.filter(m => m.get('type') === 'paid');
tiers.length.should.be.greaterThan(1, 'These tests requires at least two paid tiers');
await models.Label.add({
name: 'Label A'
});
await models.Label.add({
name: 'Label B'
});
labels = (await models.Label.findAll()).models;
labels.length.should.be.greaterThan(1, 'These tests requires at least two labels');
newsletters = (await models.Newsletter.findAll()).models;
newsletters.length.should.be.greaterThan(1, 'These tests requires at least two newsletters');
});
beforeEach(function () {
mockManager.mockStripe();
mockManager.mockMail();
});
afterEach(function () {
mockManager.restore();
});
it('Can export products', async function () {
// Create a new member with a product
const member = await createMember({
name: 'Test member',
products: tiers
});
const tiersList = tiers.map(tier => tier.get('name')).sort().join(',');
await testOutput(member, (row) => {
basicAsserts(member, row);
should(row.subscribed_to_emails).eql('false');
should(row.complimentary_plan).eql('');
should(row.products.split(',').sort().join(',')).eql(tiersList);
}, [`filter=products:${tiers[0].get('slug')}`, 'filter=subscribed:false']);
});
it('Can export a member without products', async function () {
// Create a new member with a product
const member = await createMember({
name: 'Test member 2',
note: 'Just a note 2'
});
await testOutput(member, (row) => {
basicAsserts(member, row);
should(row.subscribed_to_emails).eql('false');
should(row.complimentary_plan).eql('');
should(row.products).eql('');
}, ['filter=subscribed:false']);
});
it('Can export labels', async function () {
// Create a new member with a product
const member = await createMember({
name: 'Test member',
note: 'Just a note',
labels: labels.map((l) => {
return {
name: l.get('name')
};
})
});
const labelsList = labels.map(label => label.get('name')).join(',');
await testOutput(member, (row) => {
basicAsserts(member, row);
should(row.subscribed_to_emails).eql('false');
should(row.complimentary_plan).eql('');
should(row.labels).eql(labelsList);
should(row.products).eql('');
}, [`filter=label:${labels[0].get('slug')}`, 'filter=subscribed:false']);
});
it('Can export comped', async function () {
// Create a new member with a product
const member = await createMember({
name: 'Test member',
note: 'Just a note',
status: 'comped'
});
await testOutput(member, (row) => {
basicAsserts(member, row);
should(row.subscribed_to_emails).eql('false');
should(row.complimentary_plan).eql('true');
should(row.labels).eql('');
should(row.products).eql('');
}, ['filter=status:comped', 'filter=subscribed:false']);
});
it('Can export newsletters', async function () {
// Create a new member with a product
const member = await createMember({
name: 'Test member',
note: 'Just a note',
newsletters: [{
id: newsletters[0].id
}]
});
await testOutput(member, (row) => {
basicAsserts(member, row);
should(row.subscribed_to_emails).eql('true');
should(row.complimentary_plan).eql('');
should(row.labels).eql('');
should(row.products).eql('');
}, ['filter=subscribed:true']);
});
it('Can export customer id', async function () {
// Create a new member with a product
const member = await createMember({
name: 'Test member',
note: 'Just a note'
});
const customer = await models.MemberStripeCustomer.add({
member_id: member.id,
customer_id: 'cus_12345',
name: 'Test member',
email: member.get('email')
});
// NOTE: we need to create a subscription here because of the way the customer id is currently fetched
const subscription = await models.StripeCustomerSubscription.add({
subscription_id: 'sub_123',
customer_id: customer.get('customer_id'),
stripe_price_id: 'price_123',
status: 'active',
cancel_at_period_end: false,
current_period_end: '2023-05-19 09:08:53',
start_date: '2020-05-19 09:08:53',
plan_id: 'price_1L15K4JQCtFaIJka01folNVK',
plan_nickname: 'Yearly',
plan_interval: 'year',
plan_amount: 5000,
plan_currency: 'USD'
});
await testOutput(member, (row) => {
basicAsserts(member, row);
should(row.subscribed_to_emails).eql('false');
should(row.complimentary_plan).eql('');
should(row.labels).eql('');
should(row.products).eql('');
should(row.stripe_customer_id).eql('cus_12345');
}, ['filter=subscribed:false', 'filter=subscriptions.subscription_id:sub_123']);
});
});