Ghost/ghost/core/test/utils/stripe-mocker.js
Simon Backx ba8f082d41
Added awaiting jobs and events by default to all tests (#16505)
no issue

This change waits for domain events and jobs before continuing with the
next test. This prevents issues where background tasks in tests are
executed when the next test is running and the configurations have
changed, causing random error logs and test failures.

It also includes a change in Stripe mocking in one E2E test to make use
of the new StripeMocker instead of custom mocking in each test (also to
reduce error logs).
2023-04-06 09:05:16 +02:00

414 lines
12 KiB
JavaScript

const DomainEvents = require('@tryghost/domain-events');
const nock = require('nock');
let members = {};
let stripeService = {};
let tiers = {};
let models = {};
const crypto = require('crypto');
/**
* The Stripe Mocker mimics an in memory version of the Stripe API. We can use it to quickly create new subscriptions and get a close to real world test environment with working webhooks etc.
* If you create a new subscription, it will automatically send the customer.subscription.created webhook. So you can test what happens.
*/
class StripeMocker {
customers = [];
subscriptions = [];
paymentMethods = [];
setupIntents = [];
coupons = [];
prices = [];
products = [];
nockInterceptors = [];
constructor(data = {}) {
this.customers = data.customers ?? [];
this.subscriptions = data.subscriptions ?? [];
this.paymentMethods = data.paymentMethods ?? [];
this.setupIntents = data.setupIntents ?? [];
this.coupons = data.coupons ?? [];
this.prices = data.prices ?? [];
this.products = data.products ?? [];
}
reset() {
this.customers = [];
this.subscriptions = [];
this.paymentMethods = [];
this.setupIntents = [];
this.coupons = [];
this.prices = [];
this.products = [];
// Fix for now, because of importing order breaking some things when they are not initialized
members = require('../../core/server/services/members');
stripeService = require('../../core/server/services/stripe');
tiers = require('../../core/server/services/tiers');
models = require('../../core/server/models');
}
#generateRandomId() {
return crypto.randomBytes(8).toString('hex');
}
createCustomer(overrides = {}) {
const customerId = `cus_${this.#generateRandomId()}`;
const stripeCustomer = {
id: customerId,
object: 'customer',
name: 'Test User',
email: customerId + '@example.com',
subscriptions: {
type: 'list',
data: []
},
...overrides
};
this.customers.push(stripeCustomer);
return stripeCustomer;
}
/**
*
* @param {*} tierSlug
* @param {'month' | 'year'} cadence
* @returns
*/
async getPriceForTier(tierSlug, cadence) {
const product = await models.Product.findOne({slug: tierSlug});
const tier = await tiers.api.read(product.id);
const payments = members.api.paymentsService;
const {id} = await payments.createPriceForTierCadence(tier, cadence);
return this.#getData(this.prices, id)[1];
}
/**
*
* @param {object} data
* @param {string} [data.name]
* @param {string} data.currency
* @param {number} data.monthly_price
* @param {number} data.yearly_price
* @returns
*/
async createTier({name, currency, monthly_price, yearly_price}) {
return await models.Product.add({
name: name ?? ('Tier ' + this.#generateRandomId()),
type: 'paid',
currency: currency.toUpperCase(),
monthly_price,
yearly_price
});
}
async createTrialSubscription({customer, price, ...overrides}) {
return await this.createSubscription({
customer,
price,
status: 'trialing',
trial_start: Date.now() / 1000,
trial_end: (Date.now() + 1000 * 60 * 60 * 24 * 7) / 1000,
...overrides
});
}
async createIncompleteSubscription({customer, price, ...overrides}) {
return await this.createSubscription({
customer,
price,
status: 'incomplete',
...overrides
});
}
async updateSubscription({id, ...overrides}) {
const subscription = this.#postData(this.subscriptions, id, overrides, 'subscriptions')[1];
// Send update webhook
await this.sendWebhook({
type: 'customer.subscription.updated',
data: {
object: subscription
}
});
await DomainEvents.allSettled();
}
async createSubscription({customer, price, ...overrides}, options = {sendWebhook: true}) {
const subscriptionId = `sub_${this.#generateRandomId()}`;
const subscription = {
id: subscriptionId,
object: 'subscription',
cancel_at_period_end: false,
canceled_at: null,
current_period_end: (Date.now() + 1000 * 60 * 60 * 24 * 31) / 1000,
start_date: Math.floor(Date.now() / 1000),
status: 'active',
items: {
type: 'list',
data: [
{
price
}
]
},
customer: customer.id,
...overrides
};
this.subscriptions.push(subscription);
customer.subscriptions.data.push(subscription);
// Announce
if (options.sendWebhook) {
await this.sendWebhook({
type: 'checkout.session.completed',
data: {
object: {
mode: 'subscription',
customer: customer.id,
metadata: {
checkoutType: 'signup'
}
}
}
});
}
return subscription;
}
#getData(arr, id) {
const setupIntent = arr.find(c => c.id === id);
if (!setupIntent) {
return [404];
}
return [200, setupIntent];
}
#postData(arr, id, body, resource) {
const qs = require('qs');
let decoded = qs.parse(body, {
allowPrototypes: true,
decoder(value) {
// Convert numbers to numbers and bools to bools
if (/^(\d+|\d*\.\d+)$/.test(value)) {
return parseFloat(value);
}
let keywords = {
true: true,
false: false,
null: null
};
if (value in keywords) {
return keywords[value];
}
return decodeURIComponent(value);
}
});
if (resource === 'customers') {
if (!id) {
// Add default fields
decoded = {
object: 'customer',
subscriptions: {
type: 'list',
data: []
},
...decoded
};
}
}
if (resource === 'subscriptions') {
// Convert price to price object
if (Array.isArray(decoded.items)) {
const first = decoded.items[0];
if (first && typeof first.price === 'string') {
const price = this.#getData(this.prices, first.price)[1];
if (!price) {
return [400, {error: 'Invalid price ' + first.price}];
}
decoded.items = {
data: [
{
...first,
price
}
]
};
}
}
// Add default fields
if (!id) {
decoded = {
object: 'subscription',
cancel_at_period_end: false,
canceled_at: null,
current_period_end: (Date.now() + 1000 * 60 * 60 * 24 * 31) / 1000,
start_date: Math.floor(Date.now() / 1000),
status: 'active',
items: {
type: 'list',
data: []
},
...decoded
};
}
if (typeof decoded.customer === 'string') {
// Add customer to customer list
const customer = this.#getData(this.customers, decoded.customer)[1];
if (!customer) {
return [400, {error: 'Invalid customer ' + decoded.customer}];
}
customer.subscriptions.data.push(decoded);
}
}
if (!id) {
// create
decoded.id = `${resource.substr(0, 4)}_${this.#generateRandomId()}`;
arr.push(decoded);
return [200, decoded];
}
// Patch
const subscription = arr.find(c => c.id === id);
if (!subscription) {
return [404];
}
Object.assign(subscription, decoded);
return [200, subscription];
}
remove() {
for (const interceptor of this.nockInterceptors) {
nock.removeInterceptor(interceptor);
}
this.nockInterceptors = [];
}
stub() {
this.remove();
let interceptor = nock('https://api.stripe.com')
.persist()
.get(/v1\/.*/);
this.nockInterceptors.push(interceptor);
interceptor
.reply((uri) => {
const [match, resource, id] = uri.match(/\/?v1\/(\w+)\/?(\w+)/) || [null];
if (!match) {
return [500];
}
if (resource === 'setup_intents') {
return this.#getData(this.setupIntents, id);
}
if (resource === 'customers') {
return this.#getData(this.customers, id);
}
if (resource === 'subscriptions') {
return this.#getData(this.subscriptions, id);
}
if (resource === 'coupons') {
return this.#getData(this.coupons, id);
}
if (resource === 'payment_methods') {
return this.#getData(this.paymentMethods, id);
}
if (resource === 'prices') {
return this.#getData(this.prices, id);
}
if (resource === 'products') {
return this.#getData(this.products, id);
}
return [500];
});
interceptor = nock('https://api.stripe.com')
.persist()
.post(/v1\/.*/);
this.nockInterceptors.push(interceptor);
interceptor
.reply((uri, body) => {
const [match, resource, id] = uri.match(/\/?v1\/(\w+)(?:\/?(\w+)){0,2}/) || [null];
if (!match) {
return [500];
}
if (resource === 'payment_methods') {
return this.#postData(this.paymentMethods, id, body, resource);
}
if (resource === 'subscriptions') {
return this.#postData(this.subscriptions, id, body, resource);
}
if (resource === 'customers') {
return this.#postData(this.customers, id, body, resource);
}
if (resource === 'coupons') {
return this.#postData(this.coupons, id, body, resource);
}
if (resource === 'prices') {
return this.#postData(this.prices, id, body, resource);
}
if (resource === 'products') {
return this.#postData(this.products, id, body, resource);
}
return [500];
});
interceptor = nock('https://api.stripe.com')
.persist()
.delete(/v1\/.*/);
this.nockInterceptors.push(interceptor);
interceptor
.reply((uri) => {
const [match, resource, id] = uri.match(/\/?v1\/(\w+)(?:\/?(\w+)){0,2}/) || [null];
if (!match) {
return [500];
}
if (resource === 'subscriptions') {
return this.#postData(this.subscriptions, id, 'status=canceled', resource);
}
return [500];
});
}
async sendWebhook(event) {
/**
* @type {any}
*/
const webhookController = stripeService.webhookController;
await webhookController.handleEvent(event);
}
}
module.exports = StripeMocker;