2575e10ec4
refs https://github.com/TryGhost/Team/issues/1505 refs https://github.com/TryGhost/Team/issues/1466 This gives us historic data for subscriptions broken down by tier and cadence * Cleaned up tests Fixed up usage of knex, making sure to destroy the connection after tests * Removed Node 12 from testing matrix Get outta my pub!
377 lines
12 KiB
JavaScript
377 lines
12 KiB
JavaScript
const MembersStatsService = require('../../lib/members');
|
|
const knex = require('knex').default;
|
|
const assert = require('assert');
|
|
const moment = require('moment');
|
|
const sinon = require('sinon');
|
|
|
|
describe('MembersStatsService', function () {
|
|
describe('getCountHistory', function () {
|
|
/** @type {MembersStatsService} */
|
|
let membersStatsService;
|
|
|
|
/**
|
|
* @type {MembersStatsService.TotalMembersByStatus}
|
|
*/
|
|
const currentCounts = {paid: 0, free: 0, comped: 0};
|
|
/**
|
|
* @type {MembersStatsService.MemberStatusDelta[]}
|
|
*/
|
|
let events = [];
|
|
|
|
const today = '2000-01-10';
|
|
const tomorrow = '2000-01-11';
|
|
const yesterday = '2000-01-09';
|
|
const dayBeforeYesterday = '2000-01-08';
|
|
const twoDaysBeforeYesterday = '2000-01-07';
|
|
|
|
/** @type {Date} */
|
|
let todayDate;
|
|
/** @type {Date} */
|
|
let tomorrowDate;
|
|
/** @type {Date} */
|
|
let yesterdayDate;
|
|
/** @type {Date} */
|
|
let dayBeforeYesterdayDate;
|
|
|
|
after(function () {
|
|
sinon.restore();
|
|
});
|
|
|
|
/** @type {import('knex').Knex} */
|
|
let db;
|
|
|
|
before(function () {
|
|
todayDate = moment(today).toDate();
|
|
tomorrowDate = moment(tomorrow).toDate();
|
|
yesterdayDate = moment(yesterday).toDate();
|
|
dayBeforeYesterdayDate = moment(dayBeforeYesterday).toDate();
|
|
sinon.useFakeTimers(todayDate.getTime());
|
|
});
|
|
|
|
beforeEach(async function () {
|
|
db = knex({client: 'sqlite3', connection: {filename: ':memory:'}, useNullAsDefault: true});
|
|
membersStatsService = new MembersStatsService({knex: db});
|
|
|
|
await db.schema.createTable('members_status_events', function (table) {
|
|
table.string('from_status');
|
|
table.string('to_status');
|
|
table.date('created_at');
|
|
});
|
|
|
|
await db.schema.createTable('members', function (table) {
|
|
table.string('id');
|
|
table.string('status');
|
|
});
|
|
});
|
|
|
|
afterEach(async function () {
|
|
await db.destroy();
|
|
});
|
|
|
|
async function setupDB() {
|
|
const paidMembers = Array.from({length: currentCounts.paid}).map(() => ({
|
|
id: 'id',
|
|
status: 'paid'
|
|
}));
|
|
const freeMembers = Array.from({length: currentCounts.free}).map(() => ({
|
|
id: 'id',
|
|
status: 'free'
|
|
}));
|
|
const compedMembers = Array.from({length: currentCounts.comped}).map(() => ({
|
|
id: 'id',
|
|
status: 'comped'
|
|
}));
|
|
|
|
await db('members').insert(paidMembers.concat(freeMembers, compedMembers));
|
|
|
|
/**
|
|
* @typedef {object} StatusEvent
|
|
* @prop {string} created_at
|
|
* @prop {string|null} from_status
|
|
* @prop {string|null} to_status
|
|
**/
|
|
|
|
/**
|
|
* @param {string} status
|
|
* @param {number} number
|
|
* @param {Date} date
|
|
* @returns {StatusEvent[]}
|
|
**/
|
|
function generateEvents(status, number, date) {
|
|
return Array.from({length: Math.abs(number)}).map(() => ({
|
|
created_at: date.toISOString(),
|
|
from_status: number > 0 ? null : status,
|
|
to_status: number < 0 ? null : status
|
|
}));
|
|
}
|
|
|
|
const toInsert = events.reduce((/** @type {StatusEvent[]} */memo, event) => {
|
|
const paidSubscribed = generateEvents('paid', event.paid_subscribed, event.date);
|
|
const paidCanceled = generateEvents('paid', -event.paid_canceled, event.date);
|
|
const freeSubscribed = generateEvents('free', event.free_delta, event.date);
|
|
const compedSubscribed = generateEvents('comped', event.comped_delta, event.date);
|
|
return memo.concat(paidSubscribed, paidCanceled, freeSubscribed, compedSubscribed);
|
|
}, []);
|
|
|
|
if (toInsert.length) {
|
|
await db('members_status_events').insert(toInsert);
|
|
}
|
|
}
|
|
|
|
it('Always returns at least one value', async function () {
|
|
// No status events
|
|
events = [];
|
|
currentCounts.paid = 1;
|
|
currentCounts.free = 2;
|
|
currentCounts.comped = 3;
|
|
|
|
await setupDB();
|
|
|
|
const {data: results, meta} = await membersStatsService.getCountHistory();
|
|
assert(results.length === 1, 'Should have one result');
|
|
assert.deepEqual(results[0], {
|
|
date: today,
|
|
paid: 1,
|
|
free: 2,
|
|
comped: 3,
|
|
paid_subscribed: 0,
|
|
paid_canceled: 0
|
|
});
|
|
assert.deepEqual(meta.totals, currentCounts);
|
|
});
|
|
|
|
it('Passes paid_subscribers and paid_canceled', async function () {
|
|
// Update faked status events
|
|
events = [
|
|
{
|
|
date: todayDate,
|
|
paid_subscribed: 4,
|
|
paid_canceled: 3,
|
|
free_delta: 2,
|
|
comped_delta: 3
|
|
}
|
|
];
|
|
|
|
// Update current faked counts
|
|
currentCounts.paid = 1;
|
|
currentCounts.free = 2;
|
|
currentCounts.comped = 3;
|
|
|
|
await setupDB();
|
|
|
|
const {data: results, meta} = await membersStatsService.getCountHistory();
|
|
assert.deepEqual(results, [
|
|
{
|
|
date: yesterday,
|
|
paid: 0,
|
|
free: 0,
|
|
comped: 0,
|
|
paid_subscribed: 0,
|
|
paid_canceled: 0
|
|
},
|
|
{
|
|
date: today,
|
|
paid: 1,
|
|
free: 2,
|
|
comped: 3,
|
|
paid_subscribed: 4,
|
|
paid_canceled: 3
|
|
}
|
|
]);
|
|
assert.deepEqual(meta.totals, currentCounts);
|
|
});
|
|
|
|
it('Correctly resolves deltas', async function () {
|
|
// Update faked status events
|
|
events = [
|
|
{
|
|
date: yesterdayDate,
|
|
paid_subscribed: 2,
|
|
paid_canceled: 1,
|
|
free_delta: 0,
|
|
comped_delta: 0
|
|
},
|
|
{
|
|
date: todayDate,
|
|
paid_subscribed: 4,
|
|
paid_canceled: 3,
|
|
free_delta: 2,
|
|
comped_delta: 3
|
|
}
|
|
];
|
|
|
|
// Update current faked counts
|
|
currentCounts.paid = 2;
|
|
currentCounts.free = 3;
|
|
currentCounts.comped = 4;
|
|
|
|
await setupDB();
|
|
|
|
const {data: results, meta} = await membersStatsService.getCountHistory();
|
|
assert.deepEqual(results, [
|
|
{
|
|
date: dayBeforeYesterday,
|
|
paid: 0,
|
|
free: 1,
|
|
comped: 1,
|
|
paid_subscribed: 0,
|
|
paid_canceled: 0
|
|
},
|
|
{
|
|
date: yesterday,
|
|
paid: 1,
|
|
free: 1,
|
|
comped: 1,
|
|
paid_subscribed: 2,
|
|
paid_canceled: 1
|
|
},
|
|
{
|
|
date: today,
|
|
paid: 2,
|
|
free: 3,
|
|
comped: 4,
|
|
paid_subscribed: 4,
|
|
paid_canceled: 3
|
|
}
|
|
]);
|
|
assert.deepEqual(meta.totals, currentCounts);
|
|
});
|
|
|
|
it('Correctly handles negative numbers', async function () {
|
|
// Update faked status events
|
|
events = [
|
|
{
|
|
date: dayBeforeYesterdayDate,
|
|
paid_subscribed: 2,
|
|
paid_canceled: 1,
|
|
free_delta: 2,
|
|
comped_delta: 10
|
|
},
|
|
{
|
|
date: yesterdayDate,
|
|
paid_subscribed: 2,
|
|
paid_canceled: 1,
|
|
free_delta: -100,
|
|
comped_delta: 0
|
|
},
|
|
{
|
|
date: todayDate,
|
|
paid_subscribed: 4,
|
|
paid_canceled: 3,
|
|
free_delta: 100,
|
|
comped_delta: 3
|
|
}
|
|
];
|
|
|
|
// Update current faked counts
|
|
currentCounts.paid = 2;
|
|
currentCounts.free = 3;
|
|
currentCounts.comped = 4;
|
|
|
|
await setupDB();
|
|
|
|
const {data: results, meta} = await membersStatsService.getCountHistory();
|
|
assert.deepEqual(results, [
|
|
{
|
|
date: twoDaysBeforeYesterday,
|
|
paid: 0,
|
|
free: 1,
|
|
comped: 0,
|
|
paid_subscribed: 0,
|
|
paid_canceled: 0
|
|
},
|
|
{
|
|
date: dayBeforeYesterday,
|
|
paid: 0,
|
|
// note that this shouldn't be 100 (which is also what we test here):
|
|
free: 3,
|
|
comped: 1,
|
|
paid_subscribed: 2,
|
|
paid_canceled: 1
|
|
},
|
|
{
|
|
date: yesterday,
|
|
paid: 1,
|
|
// never return negative numbers, this is in fact -997:
|
|
free: 0,
|
|
comped: 1,
|
|
paid_subscribed: 2,
|
|
paid_canceled: 1
|
|
},
|
|
{
|
|
date: today,
|
|
paid: 2,
|
|
free: 3,
|
|
comped: 4,
|
|
paid_subscribed: 4,
|
|
paid_canceled: 3
|
|
}
|
|
]);
|
|
assert.deepEqual(meta.totals, currentCounts);
|
|
});
|
|
|
|
it('Ignores events in the future', async function () {
|
|
// Update faked status events
|
|
events = [
|
|
{
|
|
date: yesterdayDate,
|
|
paid_subscribed: 1,
|
|
paid_canceled: 0,
|
|
free_delta: 1,
|
|
comped_delta: 0
|
|
},
|
|
{
|
|
date: todayDate,
|
|
paid_subscribed: 4,
|
|
paid_canceled: 3,
|
|
free_delta: 2,
|
|
comped_delta: 3
|
|
},
|
|
{
|
|
date: tomorrowDate,
|
|
paid_subscribed: 10,
|
|
paid_canceled: 5,
|
|
free_delta: 8,
|
|
comped_delta: 9
|
|
}
|
|
];
|
|
|
|
// Update current faked counts
|
|
currentCounts.paid = 1;
|
|
currentCounts.free = 2;
|
|
currentCounts.comped = 3;
|
|
|
|
await setupDB();
|
|
|
|
const {data: results, meta} = await membersStatsService.getCountHistory();
|
|
assert.deepEqual(results, [
|
|
{
|
|
date: dayBeforeYesterday,
|
|
paid: 0,
|
|
free: 0,
|
|
comped: 0,
|
|
paid_subscribed: 0,
|
|
paid_canceled: 0
|
|
},
|
|
{
|
|
date: yesterday,
|
|
paid: 0,
|
|
free: 0,
|
|
comped: 0,
|
|
paid_subscribed: 1,
|
|
paid_canceled: 0
|
|
},
|
|
{
|
|
date: today,
|
|
paid: 1,
|
|
free: 2,
|
|
comped: 3,
|
|
paid_subscribed: 4,
|
|
paid_canceled: 3
|
|
}
|
|
]);
|
|
assert.deepEqual(meta.totals, currentCounts);
|
|
});
|
|
});
|
|
});
|