Ghost/ghost/stats-service/test/lib/members.test.js
Fabien 'egg' O'Carroll 2575e10ec4 Added subscription stats service (#2)
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!
2022-04-21 15:42:02 +01:00

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);
});
});
});