const MembersStatsService = require('../../lib/MembersStatsService'); const knex = require('knex').default; const assert = require('assert/strict'); 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.utc(today).toDate(); tomorrowDate = moment.utc(tomorrow).toDate(); yesterdayDate = moment.utc(yesterday).toDate(); dayBeforeYesterdayDate = moment.utc(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); }); }); });