Added command to generate demo data (#15691)

refs: https://github.com/TryGhost/Toolbox/issues/440

New command to generate demo data, creates data for over 20 tables in
Ghost, suitable for testing most features of the dashboard, as well as
making guided product tours using newsletters, tiers, many posts and
tags.

Usage: `yarn start generate-data`

Optionally, keep your existing posts / tags with: `yarn start generate-data --use-existing-tags --use-existing-posts`
This commit is contained in:
Sam Lord 2022-10-26 17:55:08 +01:00 committed by GitHub
parent 8d9b8cf79c
commit 28b11e6fed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 1826 additions and 5 deletions

View File

@ -0,0 +1,50 @@
const Command = require('./command');
const DataGenerator = require('@tryghost/data-generator');
module.exports = class REPL extends Command {
setup() {
this.help('Generates random data to populate the database for development & testing');
this.argument('--events-only', {type: 'boolean', defaultValue: false, desc: 'Only generate events, skip other datatypes'});
this.argument('--use-existing-posts', {type: 'boolean', defaultValue: false, desc: 'Generate data referencing the set of existing posts'});
this.argument('--use-existing-tags', {type: 'boolean', defaultValue: false, desc: 'Generate data referencing the set of existing tags'});
}
initializeContext(context) {
const models = require('../server/models');
const knex = require('../server/data/db/connection');
models.init();
context.models = models;
context.m = models;
context.knex = knex;
context.k = knex;
}
async handle(argv = {}) {
const knex = require('../server/data/db/connection');
const {tables: schema} = require('../server/data/schema/index');
const dataGenerator = new DataGenerator({
useExistingPosts: argv['use-existing-posts'],
useExistingTags: argv['use-existing-tags'],
knex,
schema,
logger: {
log: this.log,
ok: this.ok,
info: this.info,
warn: this.warn,
error: this.error,
fatal: this.fatal,
debug: this.debug
},
modelQuantities: {}
});
try {
await dataGenerator.importData();
} catch (error) {
this.fatal('Failed while generating data: ', error);
}
knex.destroy();
}
};

View File

@ -18,6 +18,7 @@ const command = require('./core/cli/command');
switch (mode) {
case 'repl':
case 'timetravel':
case 'generate-data':
command.run(mode);
break;
default:

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View File

@ -0,0 +1,23 @@
# Data Generator
Generate fake data for testing Ghost
## Usage
## Develop
This is a monorepo package.
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests

View File

@ -0,0 +1 @@
module.exports = require('./lib/data-generator');

View File

@ -0,0 +1,236 @@
const {
PostsImporter,
NewslettersImporter,
UsersImporter,
PostsAuthorsImporter,
TagsImporter,
PostsTagsImporter,
ProductsImporter,
MembersImporter,
BenefitsImporter,
ProductsBenefitsImporter,
MembersProductsImporter,
PostsProductsImporter,
MembersNewslettersImporter,
MembersCreatedEventsImporter,
MembersLoginEventsImporter,
MembersStatusEventsImporter,
StripeProductsImporter,
StripePricesImporter,
SubscriptionsImporter,
MembersStripeCustomersImporter,
MembersStripeCustomersSubscriptionsImporter,
MembersPaidSubscriptionEventsImporter,
MembersSubscriptionCreatedEventsImporter,
MembersSubscribeEventsImporter
} = require('./tables');
const {faker} = require('@faker-js/faker');
/**
* @typedef {Object} DataGeneratorOptions
* @property {boolean} useExistingPosts
* @property {boolean} useExistingTags
* @property {import('knex/types').Knex} knex
* @property {Object} schema
* @property {Object} logger
* @property {Object} modelQuantities
*/
const defaultQuantities = {
members: () => faker.datatype.number({
min: 7000,
max: 8000
}),
membersLoginEvents: 100,
posts: () => faker.datatype.number({
min: 80,
max: 120
})
};
class DataGenerator {
/**
*
* @param {DataGeneratorOptions} options
*/
constructor({
useExistingPosts = false,
useExistingTags = false,
knex,
schema,
logger,
modelQuantities = {}
}) {
this.useExistingPosts = useExistingPosts;
this.useExistingTags = useExistingTags;
this.knex = knex;
this.schema = schema;
this.logger = logger;
this.modelQuantities = Object.assign({}, defaultQuantities, modelQuantities);
}
async importData() {
const transaction = await this.knex.transaction();
const newslettersImporter = new NewslettersImporter(transaction);
// First newsletter is free, second is paid
const newsletters = await newslettersImporter.import({amount: 2});
let posts = [];
const postsImporter = new PostsImporter(transaction, {
newsletters
});
if (this.useExistingPosts) {
posts = await transaction.select('id').from('posts');
postsImporter.addNewsletters({posts});
posts = await transaction.select('id', 'newsletter_id').from('posts');
} else {
posts = await postsImporter.import({
amount: this.modelQuantities.posts,
rows: ['newsletter_id']
});
}
const usersImporter = new UsersImporter(transaction);
const users = await usersImporter.import({amount: 8});
const postsAuthorsImporter = new PostsAuthorsImporter(transaction, {
users
});
await postsAuthorsImporter.importForEach(posts, {amount: 1});
let tags = [];
if (this.useExistingTags) {
posts = await transaction.select('id').from('tags');
} else {
const tagsImporter = new TagsImporter(transaction, {
users
});
tags = await tagsImporter.import({amount: faker.datatype.number({
min: 16,
max: 24
})});
}
const postsTagsImporter = new PostsTagsImporter(transaction, {
tags
});
await postsTagsImporter.importForEach(posts, {
amount: () => faker.datatype.number({
min: 0,
max: 3
})
});
const productsImporter = new ProductsImporter(transaction);
const products = await productsImporter.import({amount: 4, rows: ['name', 'monthly_price', 'yearly_price']});
const membersImporter = new MembersImporter(transaction);
const members = await membersImporter.import({amount: this.modelQuantities.members, rows: ['status', 'created_at', 'name', 'email']});
const benefitsImporter = new BenefitsImporter(transaction);
const benefits = await benefitsImporter.import({amount: 5});
const productsBenefitsImporter = new ProductsBenefitsImporter(transaction, {benefits});
// Up to 5 benefits for each product
await productsBenefitsImporter.importForEach(products, {amount: 5});
// TODO: Use subscriptions to generate members_products table?
const membersProductsImporter = new MembersProductsImporter(transaction, {products: products.slice(1)});
const membersProducts = await membersProductsImporter.importForEach(members.filter(member => member.status !== 'free'), {
amount: 1,
rows: ['product_id', 'member_id']
});
const membersFreeProductsImporter = new MembersProductsImporter(transaction, {products: [products[0]]});
await membersFreeProductsImporter.importForEach(members.filter(member => member.status === 'free'), {
amount: 1,
rows: ['product_id', 'member_id']
});
const postsProductsImporter = new PostsProductsImporter(transaction, {products});
// Paid newsletters
await postsProductsImporter.importForEach(posts.filter(post => newsletters.findIndex(newsletter => newsletter.id === post.newsletter_id) === 1), {
// Each post is available on all 3 products
amount: 3
});
const membersCreatedEventsImporter = new MembersCreatedEventsImporter(transaction);
await membersCreatedEventsImporter.importForEach(members, {amount: 1});
const membersLoginEventsImporter = new MembersLoginEventsImporter(transaction);
// Will create roughly 1 login event for every 3 days, up to a maximum of 100.
await membersLoginEventsImporter.importForEach(members, {amount: this.modelQuantities.membersLoginEvents});
const membersStatusEventsImporter = new MembersStatusEventsImporter(transaction);
// Up to 2 events per member - 1 from null -> free, 1 from free -> {paid, comped}
await membersStatusEventsImporter.importForEach(members, {amount: 2});
const stripeProductsImporter = new StripeProductsImporter(transaction);
const stripeProducts = await stripeProductsImporter.importForEach(products, {
amount: 1,
rows: ['product_id', 'stripe_product_id']
});
const stripePricesImporter = new StripePricesImporter(transaction, {products});
const stripePrices = await stripePricesImporter.importForEach(stripeProducts, {
amount: 2,
rows: ['stripe_price_id', 'interval', 'stripe_product_id', 'currency', 'amount', 'nickname']
});
await productsImporter.addStripePrices({
products,
stripeProducts,
stripePrices
});
const subscriptionsImporter = new SubscriptionsImporter(transaction, {members, stripeProducts, stripePrices});
const subscriptions = await subscriptionsImporter.importForEach(membersProducts, {
amount: 1,
rows: ['cadence', 'tier_id', 'expires_at', 'created_at', 'member_id', 'currency']
});
const membersStripeCustomersImporter = new MembersStripeCustomersImporter(transaction);
const membersStripeCustomers = await membersStripeCustomersImporter.importForEach(members, {
amount: 1,
rows: ['customer_id', 'member_id']
});
const membersStripeCustomersSubscriptionsImporter = new MembersStripeCustomersSubscriptionsImporter(transaction, {
membersStripeCustomers,
products,
stripeProducts,
stripePrices
});
const membersStripeCustomersSubscriptions = await membersStripeCustomersSubscriptionsImporter.importForEach(subscriptions, {
amount: 1,
rows: ['mrr', 'plan_id', 'subscription_id']
});
const membersSubscribeEventsImporter = new MembersSubscribeEventsImporter(transaction, {newsletters, subscriptions});
const membersSubscribeEvents = await membersSubscribeEventsImporter.importForEach(members, {
amount: 2,
rows: ['member_id', 'newsletter_id']
});
const membersNewslettersImporter = new MembersNewslettersImporter(transaction);
await membersNewslettersImporter.importForEach(membersSubscribeEvents, {amount: 1});
const membersPaidSubscriptionEventsImporter = new MembersPaidSubscriptionEventsImporter(transaction, {
membersStripeCustomersSubscriptions
});
await membersPaidSubscriptionEventsImporter.importForEach(subscriptions, {amount: 1});
const membersSubscriptionCreatedEventsImporter = new MembersSubscriptionCreatedEventsImporter(transaction, {subscriptions});
await membersSubscriptionCreatedEventsImporter.importForEach(membersStripeCustomersSubscriptions, {amount: 1});
// TODO: Emails! (relies on posts & newsletters)
// TODO: Email clicks - redirect, members_click_events (relies on emails)
// TODO: Feedback - members_feedback (relies on members and posts)
await transaction.commit();
}
}
module.exports = DataGenerator;

View File

@ -0,0 +1,88 @@
class TableImporter {
/**
* @param {string} name Name of the table to be generated
* @param {import('knex/types').Knex} knex Database connection
*/
constructor(name, knex) {
this.name = name;
this.knex = knex;
}
/**
* @typedef {Function} AmountFunction
* @returns {number}
*/
/**
* @typedef {Object.<string,any>} ImportOptions
* @property {number|AmountFunction} amount Number of events to generate
* @property {Object} [model] Used to reference another object during creation
*/
/**
* @param {Array<Object>} models List of models to reference
* @param {ImportOptions} [options] Import options
* @returns {Promise<Array<Object>>}
*/
async importForEach(models = [], options) {
const results = [];
for (const model of models) {
results.push(...await this.import(Object.assign({}, options, {model})));
}
return results;
}
/**
* @param {ImportOptions} options Import options
* @returns {Promise<Array<Object>>}
*/
async import(options) {
if (options.amount === 0) {
return;
}
// Use dynamic amount if faker function given
const amount = (typeof options.amount === 'function') ? options.amount() : options.amount;
this.setImportOptions(Object.assign({}, options, {amount}));
const data = [];
for (let i = 0; i < amount; i++) {
const model = this.generate();
if (model) {
// Only push models when one is generated successfully
data.push(model);
} else {
// After first null assume that there is no more data
break;
}
}
const rows = ['id'];
if (options && options.rows) {
rows.push(...options.rows);
}
return await this.knex.batchInsert(this.name, data, 500).returning(rows);
}
/**
*
* @param {ImportOptions} options
* @returns {void}
*/
// eslint-disable-next-line no-unused-vars
setImportOptions(options) {
return;
}
/**
* Generates the data for a single model to be imported
* @returns {Object|null} Data to import, optional
*/
generate() {
// Should never be called
return false;
}
}
module.exports = TableImporter;

View File

@ -0,0 +1,24 @@
const TableImporter = require('./base');
const {faker} = require('@faker-js/faker');
const {slugify} = require('@tryghost/string');
const {blogStartDate} = require('../utils/blog-info');
class BenefitsImporter extends TableImporter {
constructor(knex) {
super('benefits', knex);
}
generate() {
const name = faker.company.catchPhrase();
const sixMonthsLater = new Date(blogStartDate);
sixMonthsLater.setMonth(sixMonthsLater.getMonth() + 6);
return {
id: faker.database.mongodbObjectId(),
name: name,
slug: `${slugify(name)}-${faker.random.numeric(3)}`,
created_at: faker.date.between(blogStartDate, sixMonthsLater)
};
}
}
module.exports = BenefitsImporter;

View File

@ -0,0 +1,51 @@
const PostsImporter = require('./posts');
const NewslettersImporter = require('./newsletters');
const UsersImporter = require('./users');
const PostsAuthorsImporter = require('./posts-authors');
const TagsImporter = require('./tags');
const PostsTagsImporter = require('./posts-tags');
const ProductsImporter = require('./products');
const MembersImporter = require('./members');
const BenefitsImporter = require('./benefits');
const ProductsBenefitsImporter = require('./products-benefits');
const MembersProductsImporter = require('./members-products');
const PostsProductsImporter = require('./posts-products');
const MembersNewslettersImporter = require('./members-newsletters');
const MembersCreatedEventsImporter = require('./members-created-events');
const MembersLoginEventsImporter = require('./members-login-events');
const MembersStatusEventsImporter = require('./members-status-events');
const StripeProductsImporter = require('./stripe-products');
const StripePricesImporter = require('./stripe-prices');
const SubscriptionsImporter = require('./subscriptions');
const MembersStripeCustomersImporter = require('./members-stripe-customers');
const MembersStripeCustomersSubscriptionsImporter = require('./members-stripe-customers-subscriptions');
const MembersPaidSubscriptionEventsImporter = require('./members-paid-subscription-events');
const MembersSubscriptionCreatedEventsImporter = require('./members-subscription-created-events');
const MembersSubscribeEventsImporter = require('./members-subscribe-events');
module.exports = {
PostsImporter,
NewslettersImporter,
UsersImporter,
PostsAuthorsImporter,
TagsImporter,
PostsTagsImporter,
ProductsImporter,
MembersImporter,
BenefitsImporter,
ProductsBenefitsImporter,
MembersProductsImporter,
PostsProductsImporter,
MembersNewslettersImporter,
MembersCreatedEventsImporter,
MembersLoginEventsImporter,
MembersStatusEventsImporter,
StripeProductsImporter,
StripePricesImporter,
SubscriptionsImporter,
MembersStripeCustomersImporter,
MembersStripeCustomersSubscriptionsImporter,
MembersPaidSubscriptionEventsImporter,
MembersSubscriptionCreatedEventsImporter,
MembersSubscribeEventsImporter
};

View File

@ -0,0 +1,35 @@
const TableImporter = require('./base');
const {faker} = require('@faker-js/faker');
const {luck} = require('../utils/random');
class MembersCreatedEventsImporter extends TableImporter {
constructor(knex) {
super('members_created_events', knex);
}
setImportOptions({model}) {
this.model = model;
}
generateSource() {
let source = 'member';
if (luck(10)) {
source = 'admin';
} else if (luck(5)) {
source = 'api';
}
return source;
}
generate() {
// TODO: Add attribution
return {
id: faker.database.mongodbObjectId(),
created_at: this.model.created_at,
member_id: this.model.id,
source: this.generateSource()
};
}
}
module.exports = MembersCreatedEventsImporter;

View File

@ -0,0 +1,43 @@
const TableImporter = require('./base');
const {faker} = require('@faker-js/faker');
const {luck} = require('../utils/random');
const generateEvents = require('../utils/event-generator');
class MembersLoginEventsImporter extends TableImporter {
constructor(knex) {
super('members_login_events', knex);
}
setImportOptions({model}) {
this.model = model;
const endDate = new Date();
const daysBetween = Math.ceil((endDate.valueOf() - new Date(model.created_at).valueOf()) / (1000 * 60 * 60 * 24));
// Assuming most people either subscribe and lose interest, or maintain steady readership
const shape = luck(40) ? 'ease-out' : 'flat';
this.timestamps = generateEvents({
shape,
trend: 'negative',
// Steady readers login more, readers who lose interest read less overall.
// ceil because members will all have logged in at least once
total: shape === 'flat' ? Math.ceil(daysBetween / 3) : Math.ceil(daysBetween / 7),
startTime: new Date(model.created_at),
endTime: endDate
});
}
generate() {
const timestamp = this.timestamps.shift();
if (!timestamp) {
// Out of events for this user
return null;
}
return {
id: faker.database.mongodbObjectId(),
created_at: timestamp.toISOString(),
member_id: this.model.id
};
}
}
module.exports = MembersLoginEventsImporter;

View File

@ -0,0 +1,22 @@
const {faker} = require('@faker-js/faker');
const TableImporter = require('./base');
class MembersNewslettersImporter extends TableImporter {
constructor(knex) {
super('members_newsletters', knex);
}
setImportOptions({model}) {
this.model = model;
}
generate() {
return {
id: faker.database.mongodbObjectId(),
member_id: this.model.member_id,
newsletter_id: this.model.newsletter_id
};
}
}
module.exports = MembersNewslettersImporter;

View File

@ -0,0 +1,39 @@
const TableImporter = require('./base');
const {faker} = require('@faker-js/faker');
class MembersPaidSubscriptionEventsImporter extends TableImporter {
constructor(knex, {membersStripeCustomersSubscriptions}) {
super('members_paid_subscription_events', knex);
this.membersStripeCustomersSubscriptions = membersStripeCustomersSubscriptions;
}
setImportOptions({model}) {
this.model = model;
}
generate() {
if (!this.model.currency) {
// Not a paid subscription
return null;
}
// TODO: Implement upgrades
const membersStripeCustomersSubscription = this.membersStripeCustomersSubscriptions.find((m) => {
return m.subscription_id === this.model.id;
});
return {
id: faker.database.mongodbObjectId(),
// TODO: Support expired / updated / cancelled events too
type: 'created',
member_id: this.model.member_id,
subscription_id: this.model.id,
from_plan: null,
to_plan: membersStripeCustomersSubscription.plan_id,
currency: this.model.currency,
source: 'stripe',
mrr_delta: membersStripeCustomersSubscription.mrr,
created_at: this.model.created_at
};
}
}
module.exports = MembersPaidSubscriptionEventsImporter;

View File

@ -0,0 +1,35 @@
const {faker} = require('@faker-js/faker');
const TableImporter = require('./base');
const {luck} = require('../utils/random');
class MembersProductsImporter extends TableImporter {
constructor(knex, {products}) {
super('members_products', knex);
this.products = products;
}
setImportOptions({model}) {
this.model = model;
}
getProduct() {
if (this.products.length > 1) {
return luck(10) ? this.products[2]
: luck(50) ? this.products[1]
: this.products[0];
} else {
return this.products[0];
}
}
generate() {
return {
id: faker.database.mongodbObjectId(),
member_id: this.model.id,
product_id: this.getProduct().id,
sort_order: 0
};
}
}
module.exports = MembersProductsImporter;

View File

@ -0,0 +1,37 @@
const TableImporter = require('./base');
const {faker} = require('@faker-js/faker');
class MembersStatusEventsImporter extends TableImporter {
constructor(knex) {
super('members_status_events', knex);
}
setImportOptions({model}) {
this.events = [{
id: faker.database.mongodbObjectId(),
member_id: model.id,
from_status: null,
to_status: 'free',
created_at: model.created_at
}];
if (model.status !== 'free') {
this.events.push({
id: faker.database.mongodbObjectId(),
member_id: model.id,
from_status: 'free',
to_status: model.status,
created_at: faker.date.between(new Date(model.created_at), new Date()).toISOString()
});
}
}
generate() {
const event = this.events.shift();
if (!event) {
return null;
}
return event;
}
}
module.exports = MembersStatusEventsImporter;

View File

@ -0,0 +1,48 @@
const {faker} = require('@faker-js/faker');
const TableImporter = require('./base');
class MembersStripeCustomersSubscriptionsImporter extends TableImporter {
constructor(knex, {membersStripeCustomers, products, stripeProducts, stripePrices}) {
super('members_stripe_customers_subscriptions', knex);
this.membersStripeCustomers = membersStripeCustomers;
this.products = products;
this.stripeProducts = stripeProducts;
this.stripePrices = stripePrices;
}
setImportOptions({model}) {
this.model = model;
}
generate() {
const customer = this.membersStripeCustomers.find(c => this.model.member_id === c.member_id);
const isMonthly = this.model.cadence === 'month';
const ghostProduct = this.products.find(product => product.id === this.model.tier_id);
const stripeProduct = this.stripeProducts.find(product => product.product_id === this.model.tier_id);
const stripePrice = this.stripePrices.find((price) => {
return price.stripe_product_id === stripeProduct.stripe_product_id &&
(isMonthly ? price.interval === 'month' : price.interval === 'year');
});
const mrr = isMonthly ? stripePrice.amount : Math.floor(stripePrice.amount / 12);
return {
id: faker.database.mongodbObjectId(),
customer_id: customer.customer_id,
subscription_id: this.model.id,
stripe_price_id: stripePrice.stripe_price_id,
status: 'active',
cancel_at_period_end: false,
current_period_end: this.model.expires_at,
start_date: this.model.created_at,
created_at: this.model.created_at,
created_by: 'unused',
mrr,
plan_id: stripeProduct.stripe_product_id,
plan_nickname: `${ghostProduct.name} - ${stripePrice.nickname}`,
plan_interval: stripePrice.interval,
plan_amount: stripePrice.amount,
plan_currency: stripePrice.currency
};
}
}
module.exports = MembersStripeCustomersSubscriptionsImporter;

View File

@ -0,0 +1,28 @@
const {faker} = require('@faker-js/faker');
const TableImporter = require('./base');
class MembersStripeCustomersImporter extends TableImporter {
constructor(knex) {
super('members_stripe_customers', knex);
}
setImportOptions({model}) {
this.model = model;
}
generate() {
return {
id: faker.database.mongodbObjectId(),
member_id: this.model.id,
customer_id: `cus_${faker.random.alphaNumeric(14, {
casing: 'mixed'
})}`,
name: this.model.name,
email: this.model.email,
created_at: this.model.created_at,
created_by: 'unused'
};
}
}
module.exports = MembersStripeCustomersImporter;

View File

@ -0,0 +1,52 @@
const TableImporter = require('./base');
const {faker} = require('@faker-js/faker');
const {luck} = require('../utils/random');
class MembersSubscribeEventsImporter extends TableImporter {
constructor(knex, {newsletters, subscriptions}) {
super('members_subscribe_events', knex);
this.newsletters = newsletters;
this.subscriptions = subscriptions;
}
setImportOptions({model}) {
this.model = model;
this.count = 0;
}
generate() {
const count = this.count;
this.count = this.count + 1;
if (count === 1 && this.model.status === 'free') {
return null;
}
let createdAt = faker.date.between(new Date(this.model.created_at), new Date()).toISOString();
let subscribed = luck(80);
// Free newsletter by default
let newsletterId = this.newsletters[0].id;
if (this.model.status === 'paid' && count === 0) {
// Paid newsletter
newsletterId = this.newsletters[1].id;
createdAt = this.subscriptions.find(s => s.member_id === this.model.id).created_at;
subscribed = luck(98);
}
if (!subscribed) {
return null;
}
return {
id: faker.database.mongodbObjectId(),
member_id: this.model.id,
newsletter_id: newsletterId,
subscribed: true,
created_at: createdAt,
source: 'member'
};
}
}
module.exports = MembersSubscribeEventsImporter;

View File

@ -0,0 +1,33 @@
const TableImporter = require('./base');
const {faker} = require('@faker-js/faker');
class MembersSubscriptionCreatedEventsImporter extends TableImporter {
constructor(knex, {subscriptions}) {
super('members_subscription_created_events', knex);
this.subscriptions = subscriptions;
}
setImportOptions({model}) {
this.model = model;
}
generate() {
const subscription = this.subscriptions.find(s => s.id === this.model.subscription_id);
return {
id: faker.database.mongodbObjectId(),
created_at: subscription.created_at,
member_id: subscription.member_id,
subscription_id: this.model.id,
// TODO: Implement attributions
attribution_id: null,
attribution_type: null,
attribution_url: null,
// TODO: Implement referrers
referrer_source: null,
referrer_medium: null,
referrer_url: null
};
}
}
module.exports = MembersSubscriptionCreatedEventsImporter;

View File

@ -0,0 +1,65 @@
const TableImporter = require('./base');
const {faker} = require('@faker-js/faker');
const {faker: americanFaker} = require('@faker-js/faker/locale/en_US');
const {blogStartDate: startTime} = require('../utils/blog-info');
const generateEvents = require('../utils/event-generator');
const {luck} = require('../utils/random');
class MembersImporter extends TableImporter {
constructor(knex) {
super('members', knex);
}
setImportOptions({amount}) {
this.timestamps = generateEvents({
shape: 'ease-in',
trend: 'positive',
total: amount,
startTime,
endTime: new Date()
}).sort();
}
generate() {
const id = faker.database.mongodbObjectId();
// Use name from American locale to reflect an English-speaking audience
const name = `${americanFaker.name.firstName()} ${americanFaker.name.lastName()}`;
const timestamp = this.timestamps.shift();
return {
id,
uuid: faker.datatype.uuid(),
email: faker.internet.email(name, faker.date.birthdate().getFullYear().toString(), 'examplemail.com').toLowerCase(),
status: luck(5) ? 'comped' : luck(25) ? 'paid' : 'free',
name: name,
expertise: luck(30) ? faker.name.jobTitle() : undefined,
geolocation: JSON.stringify({
organization_name: faker.company.name(),
region: faker.address.state(),
accuracy: 50,
asn: parseInt(faker.random.numeric(4)),
organization: `${faker.random.alpha({count: 2, casing: 'upper'})}${faker.random.numeric(4)} ${faker.company.name()}`,
timezone: faker.address.timeZone(),
longitude: faker.address.longitude(),
country_code3: faker.address.countryCode('alpha-3'),
area_code: '0',
ip: faker.internet.ipv4(),
city: faker.address.cityName(),
country: faker.address.country(),
continent_code: 'EU',
country_code: faker.address.countryCode('alpha-2'),
latitude: faker.address.latitude()
}),
email_count: 0, // Depends on number of emails sent since created_at, the newsletter they're a part of and subscription status
email_opened_count: 0,
email_open_rate: null,
// 40% of users logged in within a week, 60% sometime since registering
last_seen_at: luck(40) ? faker.date.recent(7).toISOString() : faker.date.between(timestamp, new Date()).toISOString(),
created_at: timestamp.toISOString(),
created_by: id,
updated_at: timestamp.toISOString()
};
}
}
module.exports = MembersImporter;

View File

@ -0,0 +1,33 @@
const TableImporter = require('./base');
const {blogStartDate} = require('../utils/blog-info');
const {faker} = require('@faker-js/faker');
const {slugify} = require('@tryghost/string');
class NewslettersImporter extends TableImporter {
constructor(knex) {
super('newsletters', knex);
this.sortOrder = 0;
this.names = ['Occasional freebie', 'Regular premium'];
}
generate() {
const name = this.names.shift();
const sortOrder = this.sortOrder;
this.sortOrder = this.sortOrder + 1;
const weekAfter = new Date(blogStartDate);
weekAfter.setDate(weekAfter.getDate() + 7);
return {
id: faker.database.mongodbObjectId(),
uuid: faker.datatype.uuid(),
name: name,
slug: `${slugify(name)}-${faker.random.numeric(3)}`,
sender_reply_to: 'hello@example.com',
status: 'active',
subscribe_on_signup: faker.datatype.boolean(),
sort_order: sortOrder,
created_at: faker.date.between(blogStartDate, weekAfter)
};
}
}
module.exports = NewslettersImporter;

View File

@ -0,0 +1,27 @@
const {faker} = require('@faker-js/faker');
const TableImporter = require('./base');
class PostsAuthorsImporter extends TableImporter {
constructor(knex, {users}) {
super('posts_authors', knex);
this.users = users;
this.sortOrder = 0;
}
setImportOptions({model}) {
this.model = model;
}
generate() {
const sortOrder = this.sortOrder;
this.sortOrder = this.sortOrder + 1;
return {
id: faker.database.mongodbObjectId(),
post_id: this.model.id,
author_id: this.users[faker.datatype.number(this.users.length - 1)].id,
sort_order: sortOrder
};
}
}
module.exports = PostsAuthorsImporter;

View File

@ -0,0 +1,27 @@
const {faker} = require('@faker-js/faker');
const TableImporter = require('./base');
class PostsProductsImporter extends TableImporter {
constructor(knex, {products}) {
super('posts_products', knex);
this.products = products;
}
setImportOptions({model}) {
this.sortOrder = 0;
this.model = model;
}
generate() {
const sortOrder = this.sortOrder;
this.sortOrder = this.sortOrder + 1;
return {
id: faker.database.mongodbObjectId(),
post_id: this.model.id,
product_id: this.products[sortOrder].id,
sort_order: this.sortOrder
};
}
}
module.exports = PostsProductsImporter;

View File

@ -0,0 +1,35 @@
const {faker} = require('@faker-js/faker');
const TableImporter = require('./base');
class PostsTagsImporter extends TableImporter {
constructor(knex, {tags}) {
super('posts_tags', knex);
this.tags = tags;
this.sortOrder = 0;
}
setImportOptions({model}) {
this.notIndex = [];
this.sortOrder = 0;
this.model = model;
}
generate() {
const sortOrder = this.sortOrder;
this.sortOrder = this.sortOrder + 1;
let tagIndex = 0;
do {
tagIndex = faker.datatype.number(this.tags.length - 1);
} while (this.notIndex.includes(tagIndex));
this.notIndex.push(tagIndex);
return {
id: faker.database.mongodbObjectId(),
post_id: this.model.id,
tag_id: this.tags[tagIndex].id,
sort_order: sortOrder
};
}
}
module.exports = PostsTagsImporter;

View File

@ -0,0 +1,66 @@
const {faker} = require('@faker-js/faker');
const {slugify} = require('@tryghost/string');
const {luck} = require('../utils/random');
const TableImporter = require('./base');
class PostsImporter extends TableImporter {
constructor(knex, {newsletters}) {
super('posts', knex);
this.newsletters = newsletters;
}
async addNewsletters({posts}) {
for (const {id} of posts) {
await this.knex('posts').update({
newsletter_id: luck(10) ? this.newsletters[0].id : this.newsletters[1].id
}).where({id});
}
}
generate() {
const title = faker.lorem.sentence();
const content = faker.lorem.paragraphs(faker.datatype.number({
min: 3,
max: 10
})).split('\n');
const twoYearsAgo = new Date();
twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2);
const twoWeeksAgo = new Date();
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14);
const timestamp = faker.date.between(twoYearsAgo, twoWeeksAgo);
return {
id: faker.database.mongodbObjectId(),
created_at: timestamp.toISOString(),
created_by: 'unused',
updated_at: timestamp.toISOString(),
published_at: faker.date.soon(5, timestamp).toISOString(),
uuid: faker.datatype.uuid(),
title: title,
slug: `${slugify(title)}-${faker.random.numeric(3)}`,
status: 'published',
mobiledoc: JSON.stringify({
version: '0.3.1',
atoms: [],
cards: [],
markups: [['em']],
sections: content.map(paragraph => [
1,
'p',
[
[
0,
[],
0,
paragraph
]
]
])
}),
html: content.map(paragraph => `<p>${paragraph}</p>`).join(''),
email_recipient_filter: 'all',
newsletter_id: luck(10) ? this.newsletters[0].id : this.newsletters[1].id
};
}
}
module.exports = PostsImporter;

View File

@ -0,0 +1,46 @@
const {faker} = require('@faker-js/faker');
const TableImporter = require('./base');
class ProductsBenefitsImporter extends TableImporter {
constructor(knex, {benefits}) {
super('products_benefits', knex);
this.benefits = benefits;
this.sortOrder = 0;
}
setImportOptions({model}) {
this.model = model;
this.sortOrder = 0;
switch (this.model.name) {
case 'Bronze':
this.benefitCount = 1;
break;
case 'Silver':
this.benefitCount = 3;
break;
case 'Gold':
this.benefitCount = 5;
break;
case 'Free Preview':
this.benefitCount = 0;
break;
}
}
generate() {
if (this.sortOrder >= this.benefitCount) {
// No more benefits than benefitCount
return null;
}
const sortOrder = this.sortOrder;
this.sortOrder = this.sortOrder + 1;
return {
id: faker.database.mongodbObjectId(),
product_id: this.model.id,
benefit_id: this.benefits[sortOrder].id,
sort_order: sortOrder
};
}
}
module.exports = ProductsBenefitsImporter;

View File

@ -0,0 +1,71 @@
const TableImporter = require('./base');
const {faker} = require('@faker-js/faker');
const {slugify} = require('@tryghost/string');
const {blogStartDate} = require('../utils/blog-info');
class ProductsImporter extends TableImporter {
constructor(knex) {
super('products', knex);
}
setImportOptions() {
this.names = ['Free Preview', 'Bronze', 'Silver', 'Gold'];
this.count = 0;
}
async addStripePrices({products, stripeProducts, stripePrices}) {
for (const {id} of products) {
const stripeProduct = stripeProducts.find(p => id === p.product_id);
const monthlyPrice = stripePrices.find((p) => {
return p.stripe_product_id === stripeProduct.stripe_product_id &&
p.interval === 'monthly';
});
const yearlyPrice = stripePrices.find((p) => {
return p.stripe_product_id === stripeProduct.stripe_product_id &&
p.interval === 'yearly';
});
const update = {};
if (monthlyPrice) {
update.monthly_price_id = monthlyPrice.id;
}
if (yearlyPrice) {
update.yearly_price_id = yearlyPrice.id;
}
if (Object.keys(update).length > 0) {
await this.knex('products').update(update).where({
id
});
}
}
}
generate() {
const name = this.names.shift();
const count = this.count;
this.count = this.count + 1;
const sixMonthsLater = new Date(blogStartDate);
sixMonthsLater.setMonth(sixMonthsLater.getMonth() + 6);
const tierInfo = {
type: 'free',
description: 'A free sample of content'
};
if (count !== 0) {
tierInfo.type = 'paid';
tierInfo.description = `${name} star member`;
tierInfo.currency = 'USD';
tierInfo.monthly_price = count * 500;
tierInfo.yearly_price = count * 5000;
}
return Object.assign({}, {
id: faker.database.mongodbObjectId(),
name: name,
slug: `${slugify(name)}-${faker.random.numeric(3)}`,
visibility: 'public',
created_at: faker.date.between(blogStartDate, sixMonthsLater)
}, tierInfo);
}
}
module.exports = ProductsImporter;

View File

@ -0,0 +1,61 @@
const {faker} = require('@faker-js/faker');
const TableImporter = require('./base');
const {blogStartDate} = require('../utils/blog-info');
class StripePricesImporter extends TableImporter {
constructor(knex, {products}) {
super('stripe_prices', knex);
this.products = products;
}
setImportOptions({model}) {
this.model = model;
this.count = 0;
}
generate() {
const sixWeeksLater = new Date(blogStartDate);
sixWeeksLater.setDate(sixWeeksLater.getDate() + (7 * 6));
const count = this.count;
this.count = this.count + 1;
const relatedProduct = this.products.find(product => product.id === this.model.product_id);
if (count === 1 && relatedProduct.monthly_price === null) {
// Only single complimentary price (yearly)
return null;
}
const billingCycle = {
nickname: 'Monthly',
interval: 'month',
type: 'recurring',
currency: 'usd',
amount: relatedProduct.monthly_price
};
if (count === 1) {
billingCycle.nickname = 'Yearly';
billingCycle.interval = 'year';
billingCycle.amount = relatedProduct.yearly_price;
} else if (relatedProduct.monthly_price === null) {
billingCycle.nickname = 'Complimentary';
billingCycle.interval = 'year';
billingCycle.amount = 0;
}
return Object.assign({}, {
id: faker.database.mongodbObjectId(),
stripe_price_id: faker.datatype.hexadecimal({
length: 64,
prefix: ''
}),
stripe_product_id: this.model.stripe_product_id,
active: true,
created_at: faker.date.between(blogStartDate, sixWeeksLater)
}, billingCycle);
}
}
module.exports = StripePricesImporter;

View File

@ -0,0 +1,29 @@
const {faker} = require('@faker-js/faker');
const TableImporter = require('./base');
const {blogStartDate} = require('../utils/blog-info');
class StripeProductsImporter extends TableImporter {
constructor(knex) {
super('stripe_products', knex);
}
setImportOptions({model}) {
this.model = model;
}
generate() {
const sixWeeksLater = new Date(blogStartDate);
sixWeeksLater.setDate(sixWeeksLater.getDate() + (7 * 6));
return {
id: faker.database.mongodbObjectId(),
product_id: this.model.id,
stripe_product_id: faker.datatype.hexadecimal({
length: 64,
prefix: ''
}),
created_at: faker.date.between(blogStartDate, sixWeeksLater)
};
}
}
module.exports = StripeProductsImporter;

View File

@ -0,0 +1,64 @@
const {faker} = require('@faker-js/faker');
const generateEvents = require('../utils/event-generator');
const TableImporter = require('./base');
class SubscriptionsImporter extends TableImporter {
constructor(knex, {members, stripeProducts, stripePrices}) {
super('subscriptions', knex);
this.members = members;
this.stripeProducts = stripeProducts;
this.stripePrices = stripePrices;
}
setImportOptions({model}) {
this.model = model;
}
generate() {
const member = this.members.find(m => m.id === this.model.member_id);
const status = member.status;
const billingInfo = {};
const isMonthly = faker.datatype.boolean();
if (status === 'paid') {
const stripeProduct = this.stripeProducts.find(product => product.product_id === this.model.product_id);
const stripePrice = this.stripePrices.find((price) => {
return price.stripe_product_id === stripeProduct.stripe_product_id &&
(isMonthly ? price.interval === 'month' : price.interval === 'year');
});
billingInfo.cadence = isMonthly ? 'month' : 'year';
billingInfo.currency = stripePrice.currency;
billingInfo.amount = stripePrice.amount;
}
const [startDate] = generateEvents({
total: 1,
trend: 'negative',
startTime: new Date(member.created_at),
endTime: new Date(),
shape: 'ease-out'
});
const endDate = new Date(startDate);
if (isMonthly) {
endDate.setMonth(new Date().getMonth());
if (endDate < new Date()) {
endDate.setMonth(endDate.getMonth() + 1);
}
} else {
endDate.setFullYear(new Date().getFullYear());
if (endDate < new Date()) {
endDate.setFullYear(endDate.getFullYear() + 1);
}
}
return Object.assign({}, {
id: faker.database.mongodbObjectId(),
type: status,
status: 'active',
member_id: this.model.member_id,
tier_id: this.model.product_id,
payment_provider: 'stripe',
expires_at: endDate.toISOString(),
created_at: startDate.toISOString()
}, billingInfo);
}
}
module.exports = SubscriptionsImporter;

View File

@ -0,0 +1,29 @@
const {faker} = require('@faker-js/faker');
const {slugify} = require('@tryghost/string');
const TableImporter = require('./base');
class TagsImporter extends TableImporter {
constructor(knex, {users}) {
super('tags', knex);
this.users = users;
}
generate() {
let name = `${faker.color.human()} ${faker.name.jobType()}`;
name = `${name[0].toUpperCase()}${name.slice(1)}`;
const threeYearsAgo = new Date();
threeYearsAgo.setFullYear(threeYearsAgo.getFullYear() - 3);
const twoYearsAgo = new Date();
twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2);
return {
id: faker.database.mongodbObjectId(),
name: name,
slug: `${slugify(name)}-${faker.random.numeric(3)}`,
description: faker.lorem.sentence(),
created_at: faker.date.between(threeYearsAgo, twoYearsAgo).toISOString(),
created_by: this.users[faker.datatype.number(this.users.length - 1)].id
};
}
}
module.exports = TagsImporter;

View File

@ -0,0 +1,26 @@
const TableImporter = require('./base');
const {faker} = require('@faker-js/faker');
const {slugify} = require('@tryghost/string');
const security = require('@tryghost/security');
class UsersImporter extends TableImporter {
constructor(knex) {
super('users', knex);
}
generate() {
const name = `${faker.name.firstName()} ${faker.name.lastName()}`;
return {
id: faker.database.mongodbObjectId(),
name: name,
slug: slugify(name),
password: security.password.hash(faker.color.human()),
email: faker.internet.email(name),
profile_image: faker.internet.avatar(),
created_at: faker.date.between(new Date(2016, 0), new Date()).toISOString(),
created_by: 'unused'
};
}
}
module.exports = UsersImporter;

View File

@ -0,0 +1,3 @@
module.exports = {
blogStartDate: new Date(2018, 5, 4)
};

View File

@ -0,0 +1,43 @@
const probabilityDistributions = require('probability-distributions');
const generateEvents = ({
shape = 'flat',
trend = 'positive',
total = 0,
startTime = new Date(),
endTime = new Date()
} = {}) => {
let alpha = 0;
let beta = 0;
let positiveTrend = trend === 'positive';
switch (shape) {
case 'linear':
alpha = 2;
beta = 1;
break;
case 'ease-in':
alpha = 4;
beta = 1;
break;
case 'ease-out':
alpha = 1;
beta = 4;
positiveTrend = !positiveTrend;
break;
case 'flat':
alpha = 1;
beta = 1;
break;
}
const data = probabilityDistributions.rbeta(total, alpha, beta, 0);
const startTimeValue = startTime.valueOf();
const timeDifference = endTime.valueOf() - startTimeValue;
return data.map((x) => {
if (!positiveTrend) {
x = 1 - x;
}
return new Date(startTimeValue + timeDifference * x);
});
};
module.exports = generateEvents;

View File

@ -0,0 +1,13 @@
const {faker} = require('@faker-js/faker');
/**
* Adds another degree of randomness into some decisions
* @param {number} lowerThan Only this % of people will achieve this luck
* @returns {boolean} Whether this person is lucky enough for the condition
*/
const luck = lowerThan => faker.datatype.number({
min: 1,
max: 100
}) <= lowerThan;
module.exports = {luck};

View File

@ -0,0 +1,32 @@
{
"name": "@tryghost/data-generator",
"version": "0.0.0",
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/data-generator",
"author": "Ghost Foundation",
"private": true,
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test:unit": "NODE_ENV=testing c8 --all --check-coverage --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"test": "yarn test:unit",
"lint:code": "eslint *.js lib/ --ext .js --cache",
"lint": "yarn lint:code && yarn lint:test",
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache"
},
"files": [
"index.js",
"lib"
],
"devDependencies": {
"c8": "7.12.0",
"knex": "2.3.0",
"mocha": "10.1.0",
"should": "13.2.3",
"sinon": "14.0.1"
},
"dependencies": {
"@faker-js/faker": "7.6.0",
"@tryghost/string": "0.2.1",
"probability-distributions": "0.9.1"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View File

@ -0,0 +1,249 @@
// Switch these lines once there are useful utils
// const testUtils = require('./utils');
require('./utils');
const knex = require('knex');
const {
ProductsImporter,
StripeProductsImporter,
StripePricesImporter
} = require('../lib/tables');
const generateEvents = require('../lib/utils/event-generator');
const DataGenerator = require('../index');
const schema = require('../../core/core/server/data/schema');
describe('Data Generator', function () {
let db;
beforeEach(async function () {
db = knex({
client: 'sqlite3',
useNullAsDefault: true,
connection: {
filename: ':memory:'
}
});
for (const tableName of Object.keys(schema.tables)) {
await db.schema.createTable(tableName, function (table) {
for (const rowName of Object.keys(schema.tables[tableName])) {
const row = schema.tables[tableName][rowName];
if (rowName === '@@UNIQUE_CONSTRAINTS@@') {
for (const constraints of row) {
table.unique(constraints);
}
break;
} else if (rowName === '@@INDEXES@@') {
for (const indexes of row) {
table.index(indexes);
}
break;
}
let rowChain = table[row.type.toLowerCase()](rowName);
if ('nullable' in row) {
if (row.nullable) {
rowChain = rowChain.nullable();
} else {
rowChain = rowChain.notNullable();
}
}
if ('defaultTo' in row) {
rowChain = rowChain.defaultTo(row.defaultTo);
}
if ('references' in row) {
const [foreignTable, foreignRow] = row.references.split('.');
rowChain = rowChain.references(foreignRow).inTable(foreignTable);
}
if (row.unique) {
table.unique([rowName]);
}
if (row.primary) {
table.primary(rowName);
}
}
});
}
});
afterEach(async function () {
await db.destroy();
});
it('Can import the whole dataset without error', async function () {
const dataGenerator = new DataGenerator({
eventsOnly: false,
knex: db,
schema: schema,
logger: {},
modelQuantities: {
members: 10,
membersLoginEvents: 5,
posts: 2
}
});
try {
return await dataGenerator.importData();
} catch (err) {
(false).should.eql(true, err.message);
}
});
});
describe('Importer', function () {
let db;
beforeEach(async function () {
db = knex({
client: 'sqlite3',
useNullAsDefault: true,
connection: {
filename: ':memory:'
}
});
await db.schema.createTable('products', function (table) {
table.string('id');
table.string('name');
table.string('slug');
table.string('visibility');
table.date('created_at');
table.string('type');
table.string('description');
table.string('currency');
table.integer('monthly_price');
table.integer('yearly_price');
table.string('monthly_price_id');
table.string('yearly_price_id');
});
await db.schema.createTable('stripe_products', function (table) {
table.string('id');
table.string('product_id');
table.string('stripe_product_id');
table.date('created_at');
table.date('updated_at');
});
await db.schema.createTable('stripe_prices', function (table) {
table.string('id');
table.string('stripe_price_id');
table.string('stripe_product_id');
table.boolean('active');
table.string('nickname');
table.string('currency');
table.integer('amount');
table.string('type');
table.string('interval');
table.string('description');
table.date('created_at');
table.date('updated_at');
});
});
afterEach(async function () {
await db.destroy();
});
it('Should import a single item', async function () {
const productsImporter = new ProductsImporter(db);
const products = await productsImporter.import({amount: 1, rows: ['name', 'monthly_price', 'yearly_price']});
products.length.should.eql(1);
products[0].name.should.eql('Free Preview');
const results = await db.select('id', 'name').from('products');
results.length.should.eql(1);
results[0].name.should.eql('Free Preview');
});
it('Should import an item for each entry in an array', async function () {
const productsImporter = new ProductsImporter(db);
const products = await productsImporter.import({amount: 4, rows: ['name', 'monthly_price', 'yearly_price']});
const stripeProductsImporter = new StripeProductsImporter(db);
await stripeProductsImporter.importForEach(products, {
amount: 1,
rows: ['product_id', 'stripe_product_id']
});
const results = await db.select('id').from('stripe_products');
results.length.should.eql(4);
});
it('Should update products to reference price ids', async function () {
const productsImporter = new ProductsImporter(db);
const products = await productsImporter.import({amount: 4, rows: ['name', 'monthly_price', 'yearly_price']});
const stripeProductsImporter = new StripeProductsImporter(db);
const stripeProducts = await stripeProductsImporter.importForEach(products, {
amount: 1,
rows: ['product_id', 'stripe_product_id']
});
const stripePricesImporter = new StripePricesImporter(db, {products});
const stripePrices = await stripePricesImporter.importForEach(stripeProducts, {
amount: 2,
rows: ['stripe_price_id', 'interval', 'stripe_product_id', 'currency', 'amount', 'nickname']
});
await productsImporter.addStripePrices({
products,
stripeProducts,
stripePrices
});
const results = await db.select('id', 'name', 'monthly_price_id', 'yearly_price_id').from('products');
results.length.should.eql(4);
results[0].name.should.eql('Free Preview');
});
});
describe('Events Generator', function () {
it('Generates a set of timestamps which meet the criteria', function () {
const startTime = new Date();
startTime.setDate(startTime.getDate() - 30);
const endTime = new Date();
const timestamps = generateEvents({
shape: 'flat',
total: 100,
trend: 'positive',
startTime,
endTime
});
for (const timestamp of timestamps) {
timestamp.valueOf().should.be.lessThanOrEqual(endTime.valueOf());
timestamp.valueOf().should.be.greaterThanOrEqual(startTime.valueOf());
}
});
it('Works for a set of shapes', function () {
const startTime = new Date();
startTime.setDate(startTime.getDate() - 30);
const endTime = new Date();
const options = {
startTime,
endTime,
total: 100,
trend: 'positive'
};
const shapes = ['linear', 'flat', 'ease-in', 'ease-out'];
for (const shape of shapes) {
try {
generateEvents(Object.assign({}, options, {shape}));
} catch (err) {
(false).should.eql(true, err.message);
}
}
});
});

View File

@ -0,0 +1,11 @@
/**
* Custom Should Assertions
*
* Add any custom assertions to this file.
*/
// Example Assertion
// should.Assertion.add('ExampleAssertion', function () {
// this.params = {operator: 'to be a valid Example Assertion'};
// this.obj.should.be.an.Object;
// });

View File

@ -0,0 +1,11 @@
/**
* Test Utilities
*
* Shared utils for writing tests
*/
// Require overrides - these add globals for tests
require('./overrides');
// Require assertions - adds custom should assertions
require('./assertions');

View File

@ -0,0 +1,10 @@
// This file is required before any test is run
// Taken from the should wiki, this is how to make should global
// Should is a global in our eslint test config
global.should = require('should').noConflict();
should.extend();
// Sinon is a simple case
// Sinon is a global in our eslint test config
global.sinon = require('sinon');

View File

@ -2447,6 +2447,11 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@faker-js/faker@7.6.0":
version "7.6.0"
resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-7.6.0.tgz#9ea331766084288634a9247fcd8b84f16ff4ba07"
integrity sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw==
"@fortawesome/fontawesome-common-types@^0.2.36":
version "0.2.36"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz#b44e52db3b6b20523e0c57ef8c42d315532cb903"
@ -9849,6 +9854,11 @@ crypto-random-string@^2.0.0:
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
crypto@0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/crypto/-/crypto-0.0.3.tgz#470a81b86be4c5ee17acc8207a1f5315ae20dbb0"
integrity sha512-Q6Ka98WcvWXXg+9cnqd3jHpTSIOaH6/q0m/bESMfQo/0uFxy6e/7EqVS4JdaWx9qLdqV56tDufy2b12dj7BHJg==
css-blank-pseudo@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz#36523b01c12a25d812df343a32c322d2a2324561"
@ -12147,7 +12157,6 @@ ember-power-calendar@^0.16.3:
ember-power-datepicker@cibernox/ember-power-datepicker:
version "0.8.1"
uid da580474a2c449b715444934ddb626b7c07f46a7
resolved "https://codeload.github.com/cibernox/ember-power-datepicker/tar.gz/da580474a2c449b715444934ddb626b7c07f46a7"
dependencies:
ember-basic-dropdown "^3.0.11"
@ -14723,7 +14732,6 @@ globrex@^0.1.2:
"google-caja-bower@https://github.com/acburdine/google-caja-bower#ghost":
version "6011.0.0"
uid "275cb75249f038492094a499756a73719ae071fd"
resolved "https://github.com/acburdine/google-caja-bower#275cb75249f038492094a499756a73719ae071fd"
got@9.6.0:
@ -17520,7 +17528,6 @@ keygrip@~1.1.0:
"keymaster@https://github.com/madrobby/keymaster.git":
version "1.6.3"
uid f8f43ddafad663b505dc0908e72853bcf8daea49
resolved "https://github.com/madrobby/keymaster.git#f8f43ddafad663b505dc0908e72853bcf8daea49"
keypair@1.0.4:
@ -19368,7 +19375,6 @@ mocha@^2.5.3:
mock-knex@TryGhost/mock-knex#8ecb8c227bf463c991c3d820d33f59efc3ab9682:
version "0.4.9"
uid "8ecb8c227bf463c991c3d820d33f59efc3ab9682"
resolved "https://codeload.github.com/TryGhost/mock-knex/tar.gz/8ecb8c227bf463c991c3d820d33f59efc3ab9682"
dependencies:
bluebird "^3.4.1"
@ -21905,6 +21911,13 @@ private@^0.1.6, private@^0.1.8:
resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==
probability-distributions@0.9.1:
version "0.9.1"
resolved "https://registry.yarnpkg.com/probability-distributions/-/probability-distributions-0.9.1.tgz#c689c8d62173bb281a0999e98263acc76a628e55"
integrity sha512-guES77A321GzcK6swYmxhBeYQ10UNyiG0Gf4Ks+3239BuS27EFH/rrlfBW/oYlPyiufdey/3OlVqKvlXFB9LTQ==
dependencies:
crypto "0.0.3"
probe-image-size@7.2.3:
version "7.2.3"
resolved "https://registry.yarnpkg.com/probe-image-size/-/probe-image-size-7.2.3.tgz#d49c64be540ec8edea538f6f585f65a9b3ab4309"
@ -23795,7 +23808,6 @@ simple-update-notifier@^1.0.7:
"simplemde@https://github.com/kevinansfield/simplemde-markdown-editor.git#ghost":
version "1.11.2"
uid "4c39702de7d97f9b32d5c101f39237b6dab7c3ee"
resolved "https://github.com/kevinansfield/simplemde-markdown-editor.git#4c39702de7d97f9b32d5c101f39237b6dab7c3ee"
sinon@14.0.1: