Refactored Tiers logic into separate package
refs https://github.com/TryGhost/Team/issues/2078 This pulls the current Tiers logic into its own package, the persistence part of the work has not been done yet, that will be handled in core, so all bookshelf model specific stuff is kept together.
This commit is contained in:
parent
6eaeaad5f2
commit
fbc23a624e
6
ghost/tiers/.eslintrc.js
Normal file
6
ghost/tiers/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/node'
|
||||
]
|
||||
};
|
23
ghost/tiers/README.md
Normal file
23
ghost/tiers/README.md
Normal file
@ -0,0 +1,23 @@
|
||||
# Tiers
|
||||
|
||||
'
|
||||
|
||||
|
||||
## 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
|
||||
|
5
ghost/tiers/index.js
Normal file
5
ghost/tiers/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
Tier: require('./lib/Tier'),
|
||||
TiersAPI: require('./lib/TiersAPI'),
|
||||
InMemoryTierRepository: require('./lib/InMemoryTierRepository')
|
||||
};
|
73
ghost/tiers/lib/InMemoryTierRepository.js
Normal file
73
ghost/tiers/lib/InMemoryTierRepository.js
Normal file
@ -0,0 +1,73 @@
|
||||
const nql = require('@tryghost/nql');
|
||||
|
||||
/**
|
||||
* @typedef {import('./Tier')} Tier
|
||||
*/
|
||||
|
||||
class InMemoryTierRepository {
|
||||
/** @type {Tier[]} */
|
||||
#store = [];
|
||||
/** @type {Object.<string, true>} */
|
||||
#ids = {};
|
||||
|
||||
/**
|
||||
* @param {Tier} tier
|
||||
* @returns {any}
|
||||
*/
|
||||
toPrimitive(tier) {
|
||||
return {
|
||||
...tier,
|
||||
id: tier.id.toHexString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Tier} linkClick
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async save(tier) {
|
||||
if (this.#ids[tier.id.toHexString()]) {
|
||||
const existing = this.#store.findIndex((item) => {
|
||||
return item.id.equals(tier.id);
|
||||
});
|
||||
this.#store.splice(existing, 1, tier);
|
||||
} else {
|
||||
this.#store.push(tier);
|
||||
this.#ids[tier.id.toHexString()] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('bson-objectid').default} id
|
||||
* @returns {Promise<Tier>}
|
||||
*/
|
||||
async getById(id) {
|
||||
return this.#store.find((item) => {
|
||||
return item.id.equals(id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} slug
|
||||
* @returns {Promise<Tier>}
|
||||
*/
|
||||
async getBySlug(slug) {
|
||||
return this.#store.find((item) => {
|
||||
return item.slug === slug;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.filter]
|
||||
* @returns {Promise<Tier[]>}
|
||||
*/
|
||||
async getAll(options = {}) {
|
||||
const filter = nql(options.filter, {});
|
||||
return this.#store.slice().filter((item) => {
|
||||
return filter.queryJSON(this.toPrimitive(item));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = InMemoryTierRepository;
|
430
ghost/tiers/lib/Tier.js
Normal file
430
ghost/tiers/lib/Tier.js
Normal file
@ -0,0 +1,430 @@
|
||||
const ObjectID = require('bson-objectid').default;
|
||||
const {ValidationError} = require('@tryghost/errors');
|
||||
|
||||
module.exports = class Tier {
|
||||
/** @type {ObjectID} */
|
||||
#id;
|
||||
get id() {
|
||||
return this.#id;
|
||||
}
|
||||
|
||||
/** @type {string} */
|
||||
#slug;
|
||||
get slug() {
|
||||
return this.#slug;
|
||||
}
|
||||
|
||||
/** @type {string} */
|
||||
#name;
|
||||
get name() {
|
||||
return this.#name;
|
||||
}
|
||||
set name(value) {
|
||||
this.#name = validateName(value);
|
||||
}
|
||||
|
||||
/** @type {string[]} */
|
||||
#benefits;
|
||||
get benefits() {
|
||||
return this.#benefits;
|
||||
}
|
||||
set benefits(value) {
|
||||
this.#benefits = validateBenefits(value);
|
||||
}
|
||||
|
||||
/** @type {string} */
|
||||
#description;
|
||||
get description() {
|
||||
return this.#description;
|
||||
}
|
||||
set description(value) {
|
||||
this.#description = validateDescription(value);
|
||||
}
|
||||
|
||||
/** @type {URL} */
|
||||
#welcomePageURL;
|
||||
get welcomePageURL() {
|
||||
return this.#welcomePageURL;
|
||||
}
|
||||
set welcomePageURL(value) {
|
||||
this.#welcomePageURL = validateWelcomePageURL(value);
|
||||
}
|
||||
|
||||
/** @type {'active'|'archived'} */
|
||||
#status;
|
||||
get status() {
|
||||
return this.#status;
|
||||
}
|
||||
set status(value) {
|
||||
this.#status = validateStatus(value);
|
||||
}
|
||||
|
||||
/** @type {'public'} */
|
||||
#visibility;
|
||||
get visibility() {
|
||||
return this.#visibility;
|
||||
}
|
||||
|
||||
/** @type {'paid'|'free'} */
|
||||
#type;
|
||||
get type() {
|
||||
return this.#type;
|
||||
}
|
||||
|
||||
/** @type {number|null} */
|
||||
#trialDays;
|
||||
get trialDays() {
|
||||
return this.#trialDays;
|
||||
}
|
||||
set trialDays(value) {
|
||||
this.#trialDays = validateTrialDays(value, this.#type);
|
||||
}
|
||||
|
||||
/** @type {string|null} */
|
||||
#currency;
|
||||
get currency() {
|
||||
return this.#currency;
|
||||
}
|
||||
set currency(value) {
|
||||
this.#currency = validateCurrency(value, this.#type);
|
||||
}
|
||||
|
||||
/** @type {number|null} */
|
||||
#monthlyPrice;
|
||||
get monthlyPrice() {
|
||||
return this.#monthlyPrice;
|
||||
}
|
||||
set monthlyPrice(value) {
|
||||
this.#monthlyPrice = validateMonthlyPrice(value, this.#type);
|
||||
}
|
||||
|
||||
/** @type {number|null} */
|
||||
#yearlyPrice;
|
||||
get yearlyPrice() {
|
||||
return this.#yearlyPrice;
|
||||
}
|
||||
set yearlyPrice(value) {
|
||||
this.#yearlyPrice = validateYearlyPrice(value, this.#type);
|
||||
}
|
||||
|
||||
/** @type {Date} */
|
||||
#createdAt;
|
||||
get createdAt() {
|
||||
return this.#createdAt;
|
||||
}
|
||||
|
||||
/** @type {Date|null} */
|
||||
#updatedAt;
|
||||
get updatedAt() {
|
||||
return this.#updatedAt;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.#id,
|
||||
slug: this.#slug,
|
||||
name: this.#name,
|
||||
description: this.#description,
|
||||
welcomePageURL: this.#welcomePageURL,
|
||||
status: this.#status,
|
||||
visibility: this.#visibility,
|
||||
type: this.#type,
|
||||
trialDays: this.#trialDays,
|
||||
currency: this.#currency,
|
||||
monthlyPrice: this.#monthlyPrice,
|
||||
yearlyPrice: this.#yearlyPrice,
|
||||
createdAt: this.#createdAt,
|
||||
updatedAt: this.#updatedAt,
|
||||
benefits: this.#benefits
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
constructor(data) {
|
||||
this.#id = data.id;
|
||||
this.#slug = data.slug;
|
||||
this.#name = data.name;
|
||||
this.#description = data.description;
|
||||
this.#welcomePageURL = data.welcome_page_url;
|
||||
this.#status = data.status;
|
||||
this.#visibility = data.visibility;
|
||||
this.#type = data.type;
|
||||
this.#trialDays = data.trial_days;
|
||||
this.#currency = data.currency;
|
||||
this.#monthlyPrice = data.monthly_price;
|
||||
this.#yearlyPrice = data.yearly_price;
|
||||
this.#createdAt = data.created_at;
|
||||
this.#updatedAt = data.updated_at;
|
||||
this.#benefits = data.benefits;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} data
|
||||
* @param {ISlugService} slugService
|
||||
* @returns {Promise<Tier>}
|
||||
*/
|
||||
static async create(data, slugService) {
|
||||
let id;
|
||||
if (!data.id) {
|
||||
id = new ObjectID();
|
||||
} else if (typeof data.id === 'string') {
|
||||
id = ObjectID.createFromHexString(data.id);
|
||||
} else if (data.id instanceof ObjectID) {
|
||||
id = data.id;
|
||||
} else {
|
||||
throw new ValidationError({
|
||||
message: 'Invalid ID provided for Tier'
|
||||
});
|
||||
}
|
||||
|
||||
let name = validateName(data.name);
|
||||
|
||||
let slug;
|
||||
if (data.slug) {
|
||||
slug = await slugService.validate(data.slug);
|
||||
} else {
|
||||
slug = await slugService.generate(name);
|
||||
}
|
||||
|
||||
let description = validateDescription(data.description);
|
||||
let welcomePageURL = validateWelcomePageURL(data.welcome_page_url);
|
||||
let status = validateStatus(data.status || 'active');
|
||||
let visibility = validateVisibility(data.visibility || 'public');
|
||||
let type = validateType(data.type || 'paid');
|
||||
let currency = validateCurrency(data.currency || null, type);
|
||||
let trialDays = validateTrialDays(data.trial_days || null, type);
|
||||
let monthlyPrice = validateMonthlyPrice(data.monthly_price || null, type);
|
||||
let yearlyPrice = validateYearlyPrice(data.yearly_price || null , type);
|
||||
let createdAt = validateCreatedAt(data.created_at);
|
||||
let updatedAt = validateUpdatedAt(data.updated_at);
|
||||
let benefits = validateBenefits(data.benefits);
|
||||
|
||||
return new Tier({
|
||||
id,
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
welcome_page_url: welcomePageURL,
|
||||
status,
|
||||
visibility,
|
||||
type,
|
||||
trial_days: trialDays,
|
||||
currency,
|
||||
monthly_price: monthlyPrice,
|
||||
yearly_price: yearlyPrice,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
benefits
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function validateName(value) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new ValidationError({
|
||||
message: 'Tier name must be a string with a maximum of 191 characters'
|
||||
});
|
||||
}
|
||||
|
||||
if (value.length > 191) {
|
||||
throw new ValidationError({
|
||||
message: 'Tier name must be a string with a maximum of 191 characters'
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateWelcomePageURL(value) {
|
||||
if (value instanceof URL) {
|
||||
return value;
|
||||
}
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(value);
|
||||
} catch (err) {
|
||||
throw new ValidationError({
|
||||
err,
|
||||
message: 'Tier Welcome Page URL must be a URL'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateDescription(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new ValidationError({
|
||||
message: 'Tier description must be a string with a maximum of 191 characters'
|
||||
});
|
||||
}
|
||||
if (value.length > 191) {
|
||||
throw new ValidationError({
|
||||
message: 'Tier description must be a string with a maximum of 191 characters'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validateStatus(value) {
|
||||
if (value !== 'active' && value !== 'archived') {
|
||||
throw new ValidationError({
|
||||
message: 'Tier status must be either "active" or "archived"'
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateVisibility(value) {
|
||||
if (value !== 'public' && value !== 'none') {
|
||||
throw new ValidationError({
|
||||
message: 'Tier visibility must be either "public" or "none"'
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateType(value) {
|
||||
if (value !== 'paid' && value !== 'free') {
|
||||
throw new ValidationError({
|
||||
message: 'Tier type must be either "paid" or "free"'
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateTrialDays(value, type) {
|
||||
if (type === 'free') {
|
||||
if (value !== null) {
|
||||
throw new ValidationError({
|
||||
message: 'Free Tiers cannot have a trial'
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new ValidationError({
|
||||
message: 'Tier trials must be an integer greater than 0'
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateCurrency(value, type) {
|
||||
if (type === 'free') {
|
||||
if (value !== null) {
|
||||
throw new ValidationError({
|
||||
message: 'Free Tiers cannot have a currency'
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new ValidationError({
|
||||
message: 'Tier currency must be a 3 letter ISO currency code'
|
||||
});
|
||||
}
|
||||
if (value.length !== 3) {
|
||||
throw new ValidationError({
|
||||
message: 'Tier currency must be a 3 letter ISO currency code'
|
||||
});
|
||||
}
|
||||
return value.toUpperCase();
|
||||
}
|
||||
|
||||
function validateMonthlyPrice(value, type) {
|
||||
if (type === 'free') {
|
||||
if (value !== null) {
|
||||
throw new ValidationError({
|
||||
message: 'Free Tiers cannot have a monthly price'
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new ValidationError({
|
||||
message: ''
|
||||
});
|
||||
}
|
||||
if (value < 0) {
|
||||
throw new ValidationError({
|
||||
message: ''
|
||||
});
|
||||
}
|
||||
if (value > 9999999999) {
|
||||
throw new ValidationError({
|
||||
message: ''
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateYearlyPrice(value, type) {
|
||||
if (type === 'free') {
|
||||
if (value !== null) {
|
||||
throw new ValidationError({
|
||||
message: 'Free Tiers cannot have a yearly price'
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new ValidationError({
|
||||
message: ''
|
||||
});
|
||||
}
|
||||
if (value < 0) {
|
||||
throw new ValidationError({
|
||||
message: ''
|
||||
});
|
||||
}
|
||||
if (value > 9999999999) {
|
||||
throw new ValidationError({
|
||||
message: ''
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateCreatedAt(value) {
|
||||
if (!value) {
|
||||
return new Date();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
throw new ValidationError({
|
||||
message: 'Tier created_at must be a date'
|
||||
});
|
||||
}
|
||||
|
||||
function validateUpdatedAt(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value;
|
||||
}
|
||||
throw new ValidationError({
|
||||
message: 'Tier created_at must be a date'
|
||||
});
|
||||
}
|
||||
|
||||
function validateBenefits(value) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) {
|
||||
throw new ValidationError({
|
||||
message: 'Tier benefits must be a list of strings'
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
33
ghost/tiers/lib/TierSlugService.js
Normal file
33
ghost/tiers/lib/TierSlugService.js
Normal file
@ -0,0 +1,33 @@
|
||||
const {ValidationError} = require('@tryghost/errors');
|
||||
const {slugify} = require('@tryghost/string');
|
||||
|
||||
module.exports = class TierSlugService {
|
||||
/** @type {import('./TiersAPI').ITierRepository} */
|
||||
#repository;
|
||||
|
||||
constructor(deps) {
|
||||
this.#repository = deps.repository;
|
||||
}
|
||||
|
||||
async validate(slug) {
|
||||
const exists = !!(await this.#repository.getBySlug(slug));
|
||||
|
||||
if (!exists) {
|
||||
return slug;
|
||||
}
|
||||
|
||||
throw new ValidationError({
|
||||
message: 'Slug already exists'
|
||||
});
|
||||
}
|
||||
|
||||
async generate(input, n = 0) {
|
||||
const slug = slugify(input + (n ? n : ''));
|
||||
|
||||
try {
|
||||
return await this.validate(slug);
|
||||
} catch (err) {
|
||||
return this.generate(input, n + 1);
|
||||
}
|
||||
}
|
||||
};
|
137
ghost/tiers/lib/TiersAPI.js
Normal file
137
ghost/tiers/lib/TiersAPI.js
Normal file
@ -0,0 +1,137 @@
|
||||
const ObjectID = require('bson-objectid').default;
|
||||
const {BadRequestError} = require('@tryghost/errors');
|
||||
const Tier = require('./Tier');
|
||||
const TierSlugService = require('./TierSlugService');
|
||||
|
||||
/**
|
||||
* @typedef {object} ITierRepository
|
||||
* @prop {(id: ObjectID) => Promise<Tier>} getById
|
||||
* @prop {(slug: string) => Promise<Tier>} getBySlug
|
||||
* @prop {(tier: Tier) => Promise<void>} save
|
||||
* @prop {(options?: {filter?: string}) => Promise<Tier[]>} getAll
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template {Model}
|
||||
* @typedef {object} Page<Model>
|
||||
* @prop {Model[]} data
|
||||
* @prop {object} meta
|
||||
* @prop {object} meta.pagination
|
||||
* @prop {number} meta.pagination.page - The current page
|
||||
* @prop {number} meta.pagination.pages - The total number of pages
|
||||
* @prop {number} meta.pagination.limit - The limit of models per page
|
||||
* @prop {number} meta.pagination.total - The totaL number of models across all pages
|
||||
* @prop {number|null} meta.pagination.prev - The number of the previous page, or null if there isn't one
|
||||
* @prop {number|null} meta.pagination.next - The number of the next page, or null if there isn't one
|
||||
*/
|
||||
|
||||
module.exports = class TiersAPI {
|
||||
/** @type {ITierRepository} */
|
||||
#repository;
|
||||
|
||||
/** @type {TierSlugService} */
|
||||
#slugService;
|
||||
|
||||
constructor(deps) {
|
||||
this.#repository = deps.repository;
|
||||
this.#slugService = new TierSlugService({
|
||||
repository: deps.repository
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.filter] - An NQL filter string
|
||||
*
|
||||
* @returns {Promise<Page<Tier>>}
|
||||
*/
|
||||
async browse(options = {}) {
|
||||
const tiers = await this.#repository.getAll(options);
|
||||
|
||||
return {
|
||||
data: tiers,
|
||||
meta: {
|
||||
pagination: {
|
||||
page: 1,
|
||||
pages: 1,
|
||||
limit: tiers.length,
|
||||
total: tiers.length,
|
||||
prev: null,
|
||||
next: null
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} idString
|
||||
*
|
||||
* @returns {Promise<Tier>}
|
||||
*/
|
||||
async read(idString) {
|
||||
const id = ObjectID.createFromHexString(idString);
|
||||
const tier = await this.#repository.getById(id);
|
||||
|
||||
return tier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {object} data
|
||||
* @returns {Promise<Tier>}
|
||||
*/
|
||||
async edit(id, data) {
|
||||
const tier = await this.#repository.getById(id);
|
||||
|
||||
const editableProperties = [
|
||||
'name',
|
||||
'benefits',
|
||||
'description',
|
||||
'visibility',
|
||||
'active',
|
||||
'trial_days',
|
||||
'currency',
|
||||
'monthly_price',
|
||||
'yearly_price'
|
||||
];
|
||||
|
||||
for (const editableProperty of editableProperties) {
|
||||
if (Reflect.has(data, editableProperty)) {
|
||||
tier[editableProperty] = data[editableProperty];
|
||||
}
|
||||
}
|
||||
|
||||
await this.#repository.save(tier);
|
||||
|
||||
return tier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} data
|
||||
* @returns {Promise<Tier>}
|
||||
*/
|
||||
async add(data) {
|
||||
if (data.type !== 'paid') {
|
||||
throw new BadRequestError({
|
||||
message: 'Cannot create free Tier'
|
||||
});
|
||||
}
|
||||
const tier = await Tier.create({
|
||||
type: 'paid',
|
||||
status: 'active',
|
||||
visibility: data.visibility,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
benefits: data.benefits,
|
||||
welcome_page_url: data.welcome_page_url,
|
||||
monthly_price: data.monthly_price,
|
||||
yearly_price: data.yearly_price,
|
||||
currency: data.currency,
|
||||
trial_days: data.trial_days
|
||||
}, this.#slugService);
|
||||
|
||||
await this.#repository.save(tier);
|
||||
|
||||
return tier;
|
||||
}
|
||||
};
|
30
ghost/tiers/package.json
Normal file
30
ghost/tiers/package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@tryghost/tiers",
|
||||
"version": "0.0.0",
|
||||
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/tiers",
|
||||
"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",
|
||||
"mocha": "10.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tryghost/errors": "1.2.18",
|
||||
"@tryghost/string": "0.2.1",
|
||||
"@tryghost/tpl": "0.1.19",
|
||||
"bson-objectid": "2.0.3"
|
||||
}
|
||||
}
|
6
ghost/tiers/test/.eslintrc.js
Normal file
6
ghost/tiers/test/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
163
ghost/tiers/test/Tier.test.js
Normal file
163
ghost/tiers/test/Tier.test.js
Normal file
@ -0,0 +1,163 @@
|
||||
const assert = require('assert');
|
||||
const ObjectID = require('bson-objectid');
|
||||
const Tier = require('../lib/Tier');
|
||||
|
||||
async function assertError(fn, checkError) {
|
||||
let error;
|
||||
try {
|
||||
await fn();
|
||||
error = false;
|
||||
} catch (err) {
|
||||
error = err;
|
||||
} finally {
|
||||
assert(error);
|
||||
if (checkError) {
|
||||
checkError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
const validInput = {
|
||||
name: 'Tier Name',
|
||||
slug: 'tier-name',
|
||||
description: 'My First Tier',
|
||||
welcome_page_url: null,
|
||||
status: 'active',
|
||||
visibility: 'public',
|
||||
type: 'paid',
|
||||
trial_days: 10,
|
||||
currency: 'usd',
|
||||
monthly_price: 5000,
|
||||
yearly_price: 50000,
|
||||
benefits: []
|
||||
};
|
||||
|
||||
const invalidInputs = [
|
||||
{id: [100]},
|
||||
{name: 100},
|
||||
{name: ('a').repeat(200)},
|
||||
{description: ['whatever?']},
|
||||
{description: ('b').repeat(200)},
|
||||
{welcome_page_url: 'hello world'},
|
||||
{status: 'something random'},
|
||||
{visibility: 'highly visible'},
|
||||
{type: 'comped'},
|
||||
{trial_days: -10},
|
||||
{trial_days: 10, type: 'free', currency: null, monthly_price: null, yearly_price: null},
|
||||
{currency: 'dollar bills'},
|
||||
{currency: 25},
|
||||
{currency: 'USD', type: 'free'},
|
||||
{monthly_price: 2000, type: 'free', trial_days: null, currency: null, yearly_price: null},
|
||||
{monthly_price: null},
|
||||
{monthly_price: -20},
|
||||
{monthly_price: 10000000000},
|
||||
{yearly_price: 2000, type: 'free', trial_days: null, monthly_price: null, currency: null},
|
||||
{yearly_price: null},
|
||||
{yearly_price: -20},
|
||||
{yearly_price: 10000000000},
|
||||
{created_at: 'Today'},
|
||||
{updated_at: 'Tomorrow'}
|
||||
];
|
||||
|
||||
const validInputs = [
|
||||
{welcome_page_url: new URL('https://google.com')},
|
||||
{id: (new ObjectID()).toHexString()},
|
||||
{id: new ObjectID()},
|
||||
{type: 'free', currency: null, monthly_price: null, yearly_price: null, trial_days: null},
|
||||
{created_at: new Date()},
|
||||
{updated_at: new Date()},
|
||||
{status: undefined},
|
||||
{type: undefined},
|
||||
{visibility: undefined}
|
||||
];
|
||||
|
||||
describe('Tier', function () {
|
||||
describe('create', function () {
|
||||
it('Errors if passed an invalid input', async function () {
|
||||
for (const invalidInput of invalidInputs) {
|
||||
let input = {};
|
||||
Object.assign(input, validInput, invalidInput);
|
||||
await assertError(async function () {
|
||||
await Tier.create(input, {validate: x => x, generate: x => x});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('Does not error for valid inputs', async function () {
|
||||
for (const validInputItem of validInputs) {
|
||||
let input = {};
|
||||
Object.assign(input, validInput, validInputItem);
|
||||
await Tier.create(input, {validate: x => x, generate: x => x});
|
||||
}
|
||||
});
|
||||
|
||||
it('Can create a Tier with valid input', async function () {
|
||||
const tier = await Tier.create(validInput, {validate: x => x, generate: x => x});
|
||||
|
||||
const expectedProps = [
|
||||
'id',
|
||||
'slug',
|
||||
'name',
|
||||
'description',
|
||||
'welcome_page_url',
|
||||
'status',
|
||||
'visibility',
|
||||
'type',
|
||||
'trial_days',
|
||||
'currency',
|
||||
'monthly_price',
|
||||
'yearly_price',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'benefits'
|
||||
];
|
||||
|
||||
for (const prop of expectedProps) {
|
||||
assert(tier[prop] === tier.toJSON()[prop]);
|
||||
}
|
||||
});
|
||||
|
||||
it('Errors when attempting to set invalid properties', async function () {
|
||||
const tier = await Tier.create(validInput, {validate: x => x, generate: x => x});
|
||||
|
||||
assertError(() => {
|
||||
tier.name = 20;
|
||||
});
|
||||
|
||||
assertError(() => {
|
||||
tier.benefits = 20;
|
||||
});
|
||||
|
||||
assertError(() => {
|
||||
tier.description = 20;
|
||||
});
|
||||
|
||||
assertError(() => {
|
||||
tier.welcome_page_url = 20;
|
||||
});
|
||||
|
||||
assertError(() => {
|
||||
tier.status = 20;
|
||||
});
|
||||
|
||||
assertError(() => {
|
||||
tier.visibility = 20;
|
||||
});
|
||||
|
||||
assertError(() => {
|
||||
tier.trial_days = 'one hundred';
|
||||
});
|
||||
|
||||
assertError(() => {
|
||||
tier.currency = 'one hundred';
|
||||
});
|
||||
|
||||
assertError(() => {
|
||||
tier.monthly_price = 'one hundred';
|
||||
});
|
||||
|
||||
assertError(() => {
|
||||
tier.yearly_price = 'one hundred';
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
70
ghost/tiers/test/TiersAPI.test.js
Normal file
70
ghost/tiers/test/TiersAPI.test.js
Normal file
@ -0,0 +1,70 @@
|
||||
const assert = require('assert');
|
||||
const TiersAPI = require('../lib/TiersAPI');
|
||||
const InMemoryTierRepository = require('../lib/InMemoryTierRepository');
|
||||
|
||||
describe('TiersAPI', function () {
|
||||
/** @type {TiersAPI.ITierRepository} */
|
||||
let repository;
|
||||
|
||||
/** @type {TiersAPI} */
|
||||
let api;
|
||||
|
||||
before(function () {
|
||||
repository = new InMemoryTierRepository();
|
||||
api = new TiersAPI({
|
||||
repository
|
||||
});
|
||||
});
|
||||
|
||||
it('Can not create new free Tiers', async function () {
|
||||
let error;
|
||||
try {
|
||||
await api.add({
|
||||
name: 'My testing Tier',
|
||||
type: 'free'
|
||||
});
|
||||
error = null;
|
||||
} catch (err) {
|
||||
error = err;
|
||||
} finally {
|
||||
assert(error, 'An error should have been thrown');
|
||||
}
|
||||
});
|
||||
|
||||
it('Can create new paid Tiers and find them again', async function () {
|
||||
const tier = await api.add({
|
||||
name: 'My testing Tier',
|
||||
type: 'paid',
|
||||
monthly_price: 5000,
|
||||
yearly_price: 50000,
|
||||
currency: 'usd'
|
||||
});
|
||||
|
||||
const found = await api.read(tier.id.toHexString());
|
||||
|
||||
assert(found);
|
||||
});
|
||||
|
||||
it('Can edit a tier', async function () {
|
||||
const tier = await api.add({
|
||||
name: 'My testing Tier',
|
||||
type: 'paid',
|
||||
monthly_price: 5000,
|
||||
yearly_price: 50000,
|
||||
currency: 'usd'
|
||||
});
|
||||
|
||||
const updated = await api.edit(tier.id.toHexString(), {
|
||||
name: 'Updated'
|
||||
});
|
||||
|
||||
assert(updated.name === 'Updated');
|
||||
});
|
||||
|
||||
it('Can browse tiers', async function () {
|
||||
const page = await api.browse();
|
||||
|
||||
assert(page.data.length === 2);
|
||||
assert(page.meta.pagination.total === 2);
|
||||
});
|
||||
});
|
18
ghost/tiers/test/index.test.js
Normal file
18
ghost/tiers/test/index.test.js
Normal file
@ -0,0 +1,18 @@
|
||||
const assert = require('assert');
|
||||
const {
|
||||
Tier,
|
||||
TiersAPI,
|
||||
InMemoryTierRepository
|
||||
} = require('../index');
|
||||
|
||||
describe('index.js', function () {
|
||||
it('Exports Tier', function () {
|
||||
assert(Tier === require('../lib/Tier'));
|
||||
});
|
||||
it('Exports TiersAPI', function () {
|
||||
assert(TiersAPI === require('../lib/TiersAPI'));
|
||||
});
|
||||
it('Exports InMemoryTierRepository', function () {
|
||||
assert(InMemoryTierRepository === require('../lib/InMemoryTierRepository'));
|
||||
});
|
||||
});
|
35
yarn.lock
35
yarn.lock
@ -4653,7 +4653,7 @@
|
||||
dependencies:
|
||||
lodash.template "^4.5.0"
|
||||
|
||||
"@tryghost/tpl@^0.1.18", "@tryghost/tpl@^0.1.19":
|
||||
"@tryghost/tpl@0.1.19", "@tryghost/tpl@^0.1.18", "@tryghost/tpl@^0.1.19":
|
||||
version "0.1.19"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/tpl/-/tpl-0.1.19.tgz#2f3883554d23d83e31631625c67c91a7eb9d03fb"
|
||||
integrity sha512-0ZOSDx/L75Sai/pea6k9TKnDz8GyyorGR/dtCIKlwd8qJ7oP90XbmTN4PBN+i4oGcZFchf8il9PbzVCoLnG0zA==
|
||||
@ -5363,6 +5363,11 @@
|
||||
"@typescript-eslint/types" "5.39.0"
|
||||
eslint-visitor-keys "^3.3.0"
|
||||
|
||||
"@ungap/promise-all-settled@1.1.2":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44"
|
||||
integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==
|
||||
|
||||
"@webassemblyjs/ast@1.11.1":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7"
|
||||
@ -19306,6 +19311,34 @@ mocha-slow-test-reporter@0.1.2:
|
||||
text-table "^0.2.0"
|
||||
wordwrap "^1.0.0"
|
||||
|
||||
mocha@10.0.0:
|
||||
version "10.0.0"
|
||||
resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.0.0.tgz#205447d8993ec755335c4b13deba3d3a13c4def9"
|
||||
integrity sha512-0Wl+elVUD43Y0BqPZBzZt8Tnkw9CMUdNYnUsTfOM1vuhJVZL+kiesFYsqwBkEEuEixaiPe5ZQdqDgX2jddhmoA==
|
||||
dependencies:
|
||||
"@ungap/promise-all-settled" "1.1.2"
|
||||
ansi-colors "4.1.1"
|
||||
browser-stdout "1.3.1"
|
||||
chokidar "3.5.3"
|
||||
debug "4.3.4"
|
||||
diff "5.0.0"
|
||||
escape-string-regexp "4.0.0"
|
||||
find-up "5.0.0"
|
||||
glob "7.2.0"
|
||||
he "1.2.0"
|
||||
js-yaml "4.1.0"
|
||||
log-symbols "4.1.0"
|
||||
minimatch "5.0.1"
|
||||
ms "2.1.3"
|
||||
nanoid "3.3.3"
|
||||
serialize-javascript "6.0.0"
|
||||
strip-json-comments "3.1.1"
|
||||
supports-color "8.1.1"
|
||||
workerpool "6.2.1"
|
||||
yargs "16.2.0"
|
||||
yargs-parser "20.2.4"
|
||||
yargs-unparser "2.0.0"
|
||||
|
||||
mocha@10.1.0:
|
||||
version "10.1.0"
|
||||
resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.1.0.tgz#dbf1114b7c3f9d0ca5de3133906aea3dfc89ef7a"
|
||||
|
Loading…
Reference in New Issue
Block a user