diff --git a/src/plans.js b/src/plans.js index 5d7aad1..b329db2 100644 --- a/src/plans.js +++ b/src/plans.js @@ -1,6 +1,6 @@ 'use strict' -const { pick, uid, validateKey, assert } = require('./util') +const { pick, uid, validateKey, assert, assertMetadata } = require('./util') const PLAN_PREFIX = 'plan_' const PLAN_QUOTA_PERIODS = ['day', 'week', 'month'] @@ -22,7 +22,7 @@ module.exports = ({ serialize, deserialize, redis } = {}) => { * @param {number} [options.throttle.rateLimit] - The rate limit of the plan. * @param {Object} [options.metadata] - Any extra information can be attached here. * - * @returns {Object|null} The plan object, null if it doesn't exist. + * @returns {Object} The plan object. */ const create = async (opts = {}) => { assert(typeof opts.name === 'string' && opts.name.length > 0, 'The argument `name` is required.') @@ -31,6 +31,7 @@ module.exports = ({ serialize, deserialize, redis } = {}) => { `The argument \`quota.period\` must be ${PLAN_QUOTA_PERIODS.map(period => `\`${period}\``).join(' or ')}.` ) assert(opts.quota.limit > 0, 'The argument `quota.limit` must be a positive number.') + opts.metadata = assertMetadata(opts.metadata) const plan = pick(opts, PLAN_FIELDS.concat(PLAN_FIELDS_OBJECT)) plan.id = await uid({ redis, prefix: PLAN_PREFIX, size: 5 }) plan.createdAt = plan.updatedAt = Date.now() @@ -88,7 +89,7 @@ module.exports = ({ serialize, deserialize, redis } = {}) => { const update = async (planId, opts) => { const currentPlan = await retrieve(planId, { throwError: true }) const quota = Object.assign(currentPlan.quota, opts.quota) - const metadata = Object.assign({}, currentPlan.metadata, opts.metadata) + const metadata = Object.assign({}, currentPlan.metadata, assertMetadata(opts.metadata)) const plan = Object.assign(currentPlan, pick(opts, PLAN_FIELDS), { quota, updatedAt: Date.now() diff --git a/src/util.js b/src/util.js index 9ae601e..ea0b730 100644 --- a/src/util.js +++ b/src/util.js @@ -36,9 +36,30 @@ const validateKey = return id } +const assertMetadata = metadata => { + if (metadata) { + assert(isPlainObject(metadata), 'The metadata must be a flat object.') + Object.keys(metadata).forEach(key => { + assert(!isPlainObject(metadata[key]), `The metadata field '${key}' can't be an object.`) + if (metadata[key] === undefined) delete metadata[key] + }) + return Object.keys(metadata).length ? metadata : undefined + } +} + +const isPlainObject = value => { + if (!value || typeof value !== 'object' || value.toString() !== '[object Object]') { + return false + } + + const prototype = Object.getPrototypeOf(value) + return prototype === null || prototype === Object.prototype +} + module.exports = { - uid, - pick, assert, + assertMetadata, + pick, + uid, validateKey } diff --git a/test/plans.js b/test/plans.js index c62a359..f84b343 100644 --- a/test/plans.js +++ b/test/plans.js @@ -50,6 +50,51 @@ test('.create # `quota` is required', async t => { } }) +test('.create # `metadata` must be a flat object', async t => { + { + const error = await t.throwsAsync( + plans.create({ + name: 'free tier', + quota: { period: 'week', limit: 1000 }, + metadata: { tier: { type: 'new' } } + }) + ) + t.is(error.message, "The metadata field 'tier' can't be an object.") + t.is(error.name, 'TypeError') + } + { + const error = await t.throwsAsync( + plans.create({ + name: 'free tier', + quota: { period: 'week', limit: 1000 }, + metadata: 'foo' + }) + ) + t.is(error.message, 'The metadata must be a flat object.') + t.is(error.name, 'TypeError') + } +}) + +test('.create # `metadata` as undefined is omitted', async t => { + { + const plan = await plans.create({ + name: 'free tier', + quota: { period: 'week', limit: 1000 }, + metadata: { tier: undefined } + }) + t.is(plan.metadata, undefined) + } + { + const plan = await plans.create({ + name: 'free tier', + quota: { period: 'week', limit: 1000 }, + metadata: { tier: 'free', version: undefined } + }) + + t.deepEqual(Object.keys(plan.metadata), ['tier']) + } +}) + test('.create', async t => { const plan = await plans.create({ name: 'free tier', @@ -68,7 +113,11 @@ test('.create', async t => { t.deepEqual(plan.throttle, { burstLimit: 1000, rateLimit: 10 }) }) -test('.retrieve', async t => { +test('.retrieve # a plan not previosuly declared', async t => { + t.is(await plans.retrieve('plan_1'), null) +}) + +test('.retrieve # a plan previosuly declared', async t => { const { id } = await plans.create({ name: 'free tier', quota: { limit: 3000, period: 'day' } @@ -108,13 +157,59 @@ test('.update', async t => { }) test('.update # add metadata', async t => { + { + const { id } = await plans.create({ + name: 'free tier', + quota: { limit: 3000, period: 'day' } + }) + + const plan = await plans.update(id, { metadata: { tier: 'free' } }) + t.is(plan.metadata.tier, 'free') + } + { + const { id } = await plans.create({ + name: 'free tier', + quota: { limit: 3000, period: 'day' } + }) + + await plans.update(id, { metadata: { tier: 'free' } }) + const plan = await plans.update(id, { metadata: { tier: 'free', version: 2 } }) + t.is(plan.metadata.tier, 'free') + t.is(plan.metadata.version, 2) + } +}) + +test('.update # metadata must be a flat object', async t => { const { id } = await plans.create({ name: 'free tier', quota: { limit: 3000, period: 'day' } }) - const plan = await plans.update(id, { metadata: { tier: 'free' } }) - t.is(plan.metadata.tier, 'free') + const error = await t.throwsAsync(plans.update(id, { metadata: { tier: { type: 'new' } } })) + t.is(error.message, "The metadata field 'tier' can't be an object.") + t.is(error.name, 'TypeError') +}) + +test('.update # metadata as undefined is omitted', async t => { + { + const { id } = await plans.create({ + name: 'free tier', + quota: { limit: 3000, period: 'day' } + }) + + const plan = await plans.update(id, { metadata: { tier: undefined } }) + t.is(plan.metadata, undefined) + } + + { + const { id } = await plans.create({ + name: 'free tier', + quota: { limit: 3000, period: 'day' } + }) + + const plan = await plans.update(id, { metadata: { tier: 'free', version: undefined } }) + t.deepEqual(Object.keys(plan.metadata), ['tier']) + } }) test('.update # prevent to add random data', async t => { @@ -126,6 +221,17 @@ test('.update # prevent to add random data', async t => { t.is(plan.foo, undefined) }) +test('.update # prevent to modify the plan id', async t => { + const { id } = await plans.create({ + name: 'free tier', + quota: { limit: 3000, period: 'day' } + }) + + const plan = await plans.update(id, { id: 'foo' }) + + t.is(plan.id, id) +}) + test('.update # error if plan does not exist', async t => { { const error = await t.throwsAsync(plans.update('id', { foo: 'bar' }))