From ec2181a199f8c47e397f0f309c10dc2eb6690cad Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Mon, 15 Apr 2024 22:02:29 +0200 Subject: [PATCH] refactor: remove unnecessary props (#13) --- src/index.js | 7 +- src/keys.js | 89 +++++++---------- src/plans.js | 133 ++++++++++++++----------- src/util.js | 24 ++--- test/keys.js | 203 +++++++++++++++----------------------- test/plans.js | 263 ++++++++++++++++++++++++++++++++++---------------- 6 files changed, 382 insertions(+), 337 deletions(-) diff --git a/src/index.js b/src/index.js index d69b27c..f92caaf 100644 --- a/src/index.js +++ b/src/index.js @@ -4,8 +4,9 @@ const JSONB = require('json-buffer') const createKeys = require('./keys') const createPlans = require('./plans') -module.exports = ({ serialize = JSONB.stringify, deserialize = JSONB.parse, redis = new Map() } = {}) => { - const plans = createPlans({ serialize, deserialize, redis }) - const keys = createKeys({ serialize, deserialize, redis, plans }) +module.exports = ({ serialize = JSONB.stringify, deserialize = JSONB.parse, redis = new Map(), prefix = '' } = {}) => { + let _keys + const plans = createPlans({ serialize, deserialize, redis, prefix, keys: () => _keys }) + const keys = (_keys = createKeys({ serialize, deserialize, redis, plans, prefix })) return { keys, plans } } diff --git a/src/keys.js b/src/keys.js index 0a590bd..ac3f2be 100644 --- a/src/keys.js +++ b/src/keys.js @@ -1,98 +1,80 @@ 'use strict' -const { pick, uid, validateKey, assert, assertMetadata } = require('./util') +const { pick, uid, assert, assertMetadata } = require('./util') -const KEY_PREFIX = 'key_' -const KEY_FIELDS = ['name', 'description', 'enabled', 'value', 'plan'] -const KEY_FIELDS_OBJECT = ['metadata'] - -module.exports = ({ serialize, deserialize, plans, redis } = {}) => { +module.exports = ({ serialize, deserialize, plans, redis, prefix } = {}) => { /** * Create a key. * * @param {Object} options - The options for creating a plan. - * @param {string} options.name - The name of the key. * @param {string} [options.value] - The value of the key. * @param {string} [options.plan] - The id of the plan associated. - * @param {string} [options.description] - The description of the key. * @param {string} [options.enabled] - Whether the key is enabled or not. * @param {Object} [options.metadata] - Any extra information can be attached here. * * @returns {Object} The created key. */ const create = async (opts = {}) => { - assert(typeof opts.name === 'string' && opts.name.length > 0, 'The argument `name` is required.') - opts.metadata = assertMetadata(opts.metadata) - const key = pick(opts, KEY_FIELDS.concat(KEY_FIELDS_OBJECT)) - key.id = await uid({ redis, prefix: KEY_PREFIX, size: 5 }) + const key = { enabled: opts.enabled ?? true } + const metadata = assertMetadata(opts.metadata) + if (metadata) key.metadata = metadata key.createdAt = key.updatedAt = Date.now() - key.value = await uid({ redis, size: 16 }) - if (key.enabled === undefined) key.enabled = true - if (opts.plan) await plans.retrieve(opts.plan, { throwError: true }) - return (await redis.setnx(key.id, serialize(key))) && key + const value = opts.value ?? (await uid({ redis, size: 16 })) + if (opts.plan) { + await plans.retrieve(opts.plan, { throwError: true }) + key.plan = opts.plan + } + await redis.set(prefixKey(value), serialize(key), 'NX') + return Object.assign({ value }, key) } /** - * Retrieve a key by id. + * Retrieve a key by value. * - * @param {string} keyId - The id of the key. + * @param {string} value - The value of the key. * @param {Object} [options] - The options for retrieving a key. * @param {boolean} [options.validate=true] - Validate if the plan id is valid. * @param {boolean} [options.throwError=false] - Throw an error if the plan does not exist. * * @returns {Object|null} The key object, null if it doesn't exist. */ - const retrieve = async (keyId, { throwError = false, validate = true } = {}) => { - const key = await redis.get(getKey(keyId, { validate })) - if (throwError) { - assert(key !== null, `The key \`${keyId}\` does not exist.`) - } - return deserialize(key) + const retrieve = async (value, { throwError = false } = {}) => { + const key = await redis.get(prefixKey(value)) + if (throwError) assert(key !== null, () => `The key \`${value}\` does not exist.`) + else if (key === null) return null + return Object.assign({ value }, deserialize(key)) } /** - * Delete a key by id. + * Delete a key by value. * - * @param {string} keyId - The id of the key. + * @param {string} value - The value of the key. * * @returns {boolean} Whether the key was deleted or not. */ - const del = async keyId => { - const key = await retrieve(keyId, { verify: true }) - if (key !== null && typeof key.plan === 'string') { - const plan = await plans.retrieve(key.plan, { - throwError: true, - validate: false - }) - assert(plan === null, `The key \`${keyId}\` is associated with the plan \`${getKey.plan}\``) - } - const isDeleted = (await redis.del(getKey(keyId, { verify: true }))) === 1 - assert(isDeleted, `The key \`${keyId}\` does not exist.`) + const del = async value => { + const isDeleted = (await redis.del(prefixKey(value))) === 1 + assert(isDeleted, () => `The key \`${value}\` does not exist.`) return isDeleted } /** - * Update a key by id. + * Update a key by value. * - * @param {string} keyId - The id of the plan. + * @param {string} value - The value of the plan. * @param {Object} options - The options for updating a plan. - * @param {string} [options.name] - The name of the key. * @param {string} [options.value] - The value of the key. - * @param {string} [options.description] - The description of the key. - * @param {string} [options.enabled] - Whether the key is enabled or not. * @param {object} [options.metadata] - Any extra information can be attached here. * * @returns {Object} The updated plan. */ - const update = async (keyId, opts) => { - const currentKey = await retrieve(keyId, { throwError: true }) + const update = async (value, opts) => { + const currentKey = await retrieve(value, { throwError: true }) const metadata = Object.assign({}, currentKey.metadata, assertMetadata(opts.metadata)) - const key = Object.assign(currentKey, pick(opts, KEY_FIELDS), { - updatedAt: Date.now() - }) + const key = Object.assign(currentKey, { updatedAt: Date.now() }, pick(opts, ['enabled', 'value', 'plan'])) if (Object.keys(metadata).length) key.metadata = metadata if (key.plan) await plans.retrieve(key.plan, { throwError: true }) - return (await redis.set(keyId, serialize(key))) && key + return (await redis.set(prefixKey(value), serialize(key))) && key } /** @@ -101,13 +83,12 @@ module.exports = ({ serialize, deserialize, plans, redis } = {}) => { * @returns {Array} The list of keys. */ const list = async () => { - const keyIds = await redis.keys(`${KEY_PREFIX}*`) - return Promise.all(keyIds.map(keyIds => retrieve(keyIds, { validate: false }))) + const allKeys = await redis.keys(prefixKey('*')) + const keyValues = allKeys.map(key => key.replace(prefixKey(''), '')) + return Promise.all(keyValues.map(keyValues => retrieve(keyValues))) } - const getKey = validateKey({ prefix: KEY_PREFIX }) + const prefixKey = key => `${prefix}key_${key}` - return { create, retrieve, del, update, list } + return { create, retrieve, del, update, list, prefixKey } } - -module.exports.KEY_PREFIX = KEY_PREFIX diff --git a/src/plans.js b/src/plans.js index dfb8a7e..5fc921b 100644 --- a/src/plans.js +++ b/src/plans.js @@ -1,102 +1,119 @@ 'use strict' -const { pick, uid, validateKey, assert, assertMetadata } = require('./util') +const { pick, assert, assertMetadata } = require('./util') -const PLAN_PREFIX = 'plan_' -const PLAN_QUOTA_PERIODS = ['day', 'week', 'month'] -const PLAN_FIELDS = ['name', 'description'] -const PLAN_FIELDS_OBJECT = ['quota', 'throttle', 'metadata'] - -module.exports = ({ serialize, deserialize, redis } = {}) => { +module.exports = ({ serialize, deserialize, redis, keys, prefix } = {}) => { /** * Create a plan. * * @param {Object} options - The options for creating a plan. - * @param {string} options.name - The name of the plan. - * @param {string} [options.description] - The description of the plan. - * @param {number} [options.quota] - The quota of the plan. - * @param {string} [options.quota.period] - The time period in which the limit applies. Valid values are "DAY", "WEEK" or "MONTH". - * @param {number} [options.quota.limit] - The target maximum number of requests that can be made in a given time period. - * @param {Object} [options.throttle] - The throttle of the plan. - * @param {number} [options.throttle.burstLimit] - The burst limit of the plan. - * @param {number} [options.throttle.rateLimit] - The rate limit of the plan. + * @param {string} options.id - The id of the plan. + * @param {number} [options.limit] - The target maximum number of requests that can be made in a given time period. + * @param {string} [options.period] - The time period in which the limit applies. Valid values are "DAY", "WEEK" or "MONTH". + * @param {number} [options.burst] - The burst limit of the plan. + * @param {number} [options.rate] - The rate limit of the plan. * @param {Object} [options.metadata] - Any extra information can be attached here. * * @returns {Object} The plan object. */ const create = async (opts = {}) => { - assert(typeof opts.name === 'string' && opts.name.length > 0, 'The argument `name` is required.') - assert( - PLAN_QUOTA_PERIODS.includes(opts.quota?.period), - `The argument \`quota.period\` must be ${PLAN_QUOTA_PERIODS.map(period => `\`${period}\``).join(' or ')}.` + assert(typeof opts.id === 'string' && opts.id.length > 0, () => 'The argument `id` must be a string.') + assert(!/\s/.test(opts.id), () => 'The argument `id` cannot contain whitespace.') + const plan = Object.assign( + { + limit: assert( + typeof opts.limit === 'number' && opts.limit > 0 && opts.limit, + () => 'The argument `limit` must be a positive number.' + ), + period: assert( + typeof opts.period === 'string' && opts.period.length > 0 && opts.period, + () => 'The argument `period` must be a string.' + ) + }, + pick(opts, ['rate', 'burst']) ) - 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 }) + const metadata = assertMetadata(opts.metadata) + if (metadata) plan.metadata = metadata plan.createdAt = plan.updatedAt = Date.now() - return (await redis.setnx(plan.id, serialize(plan))) && plan + await redis.set(prefixKey(opts.id), serialize(plan), 'NX') + return Object.assign({ id: opts.id }, plan) } /** * Retrieve a plan by id * - * @param {string} planId - The id of the plan. + * @param {string} id - The id of the plan. * @param {Object} [options] - The options for retrieving a plan. * @param {boolean} [options.validate=true] - Validate if the plan id is valid. * @param {boolean} [options.throwError=false] - Throw an error if the plan does not exist. * * @returns {Object} The plan. */ - const retrieve = async (planId, { throwError = false, validate = true } = {}) => { - const plan = await redis.get(getKey(planId, { validate })) - if (throwError) { - assert(plan !== null, `The plan \`${planId}\` does not exist.`) - } - return deserialize(plan) + const retrieve = async (id, { throwError = false } = {}) => { + const plan = await redis.get(prefixKey(id)) + if (throwError) assert(plan !== null, () => `The plan \`${id}\` does not exist.`) + else if (plan === null) return null + return Object.assign({ id }, deserialize(plan)) } /** * Delete a plan by id. * - * @param {string} planId - The id of the plan. + * @param {string} id - The id of the plan. * @param {Object} [options] - The options for deleting a plan. * * @returns {boolean} Whether the plan was deleted or not. */ - const del = async planId => { - const isDeleted = (await redis.del(getKey(planId, { validate: true }))) === 1 - assert(isDeleted, `The plan \`${planId}\` does not exist.`) + const del = async id => { + const allKeys = await keys().list() + const key = allKeys.find(key => key.plan === id) + assert(key === undefined, () => `The plan \`${id}\` is associated with the key \`${key.value}\`.`) + const isDeleted = (await redis.del(prefixKey(id))) === 1 + assert(isDeleted, () => `The plan \`${id}\` does not exist.`) return isDeleted } /** * Update a plan by id. * - * @param {string} planId - The id of the plan. + * @param {string} id - The id of the plan. * @param {Object} options - The options for updating a plan. - * @param {string} [options.name] - The name of the plan. - * @param {string} [options.description] - The description of the plan. - * @param {number} [options.quota] - The quota of the plan. - * @param {string} [options.quota.period] - The time period in which the limit applies. Valid values are "DAY", "WEEK" or "MONTH". - * @param {number} [options.quota.limit] - The target maximum number of requests that can be made in a given time period. - * @param {Object} [options.throttle] - The throttle of the plan. - * @param {number} [options.throttle.burstLimit] - The burst limit of the plan. - * @param {number} [options.throttle.rateLimit] - The rate limit of the plan. + * @param {number} [options.limit] - The target maximum number of requests that can be made in a given time period. + * @param {string} [options.period] - The time period in which the limit applies. Valid values are "DAY", "WEEK" or "MONTH". + * @param {number} [options.burst] - The burst limit of the plan. + * @param {number} [options.rate] - The rate limit of the plan. * @param {object} [options.metadata] - Any extra information can be attached here. * * @returns {Object} The updated plan. */ - const update = async (planId, opts) => { - const currentPlan = await retrieve(planId, { throwError: true }) - const quota = Object.assign(currentPlan.quota, opts.quota) + const update = async (id, opts) => { + const currentPlan = await retrieve(id, { throwError: true }) const metadata = Object.assign({}, currentPlan.metadata, assertMetadata(opts.metadata)) - const plan = Object.assign(currentPlan, pick(opts, PLAN_FIELDS), { - quota, - updatedAt: Date.now() - }) + + const plan = Object.assign( + currentPlan, + { + updatedAt: Date.now() + }, + pick(opts, ['rate', 'burst']) + ) + + if (opts.limit) { + plan.limit = assert( + typeof opts.limit === 'number' && opts.limit > 0 && opts.limit, + () => 'The argument `limit` must be a positive number.' + ) + } + + if (opts.period) { + plan.period = assert( + typeof opts.period === 'string' && opts.period.length > 0 && opts.period, + () => 'The argument `period` must be a string.' + ) + } + if (Object.keys(metadata).length) plan.metadata = metadata - return (await redis.set(planId, serialize(plan))) && plan + return (await redis.set(prefixKey(id), serialize(plan))) && plan } /** @@ -105,13 +122,13 @@ module.exports = ({ serialize, deserialize, redis } = {}) => { * @returns {Array} The list of plans. */ const list = async () => { - const planIds = await redis.keys(`${PLAN_PREFIX}*`) - return Promise.all(planIds.map(planId => retrieve(planId, { validate: false }))) + const allPlans = await redis.keys(prefixKey('*')) + const planIds = allPlans.map(key => key.replace(prefixKey(''), '')) + const result = await Promise.all(planIds.map(planId => retrieve(planId))) + return result } - const getKey = validateKey({ prefix: PLAN_PREFIX }) + const prefixKey = key => `${prefix}plan_${key}` - return { create, del, retrieve, update, list } + return { create, del, retrieve, update, list, prefixKey } } - -module.exports.PLAN_PREFIX = PLAN_PREFIX diff --git a/src/util.js b/src/util.js index ea0b730..a33fead 100644 --- a/src/util.js +++ b/src/util.js @@ -20,27 +20,22 @@ const pick = (obj, keys) => { return result } +/** + * Assert a condition, or throw an error if the condition is falsy. + * @param {*} value - The value to assert. + * @param {string} message - The error message. + */ const assert = (value, message) => value || (() => { - throw new TypeError(message) + throw new TypeError(message()) })() -const validateKey = - ({ prefix }) => - (id, { validate = true } = {}) => { - if (!validate) return id - if (!String(id).startsWith(prefix)) { - throw new TypeError(`The id \`${id}\` must to start with \`${prefix}\`.`) - } - return id - } - const assertMetadata = metadata => { if (metadata) { - assert(isPlainObject(metadata), 'The metadata must be a flat object.') + 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.`) + 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 @@ -60,6 +55,5 @@ module.exports = { assert, assertMetadata, pick, - uid, - validateKey + uid } diff --git a/test/keys.js b/test/keys.js index abd33f9..5fbc5ef 100644 --- a/test/keys.js +++ b/test/keys.js @@ -1,59 +1,43 @@ 'use strict' const { setTimeout } = require('timers/promises') +const { randomUUID } = require('crypto') const openkey = require('openkey') const Redis = require('ioredis') const test = require('ava') -const { KEY_PREFIX } = require('openkey/keys') - const redis = new Redis() -const { keys, plans } = openkey({ redis: new Redis() }) - -test.beforeEach(async () => { - const keys = await redis.keys(`${KEY_PREFIX}*`) - if (keys.length > 0) await redis.del(keys) -}) +const { keys, plans } = openkey({ redis, prefix: 'test-keys:' }) -test('.create # `name` is required', async t => { - const error = await t.throwsAsync(keys.create()) +const cleanup = async () => { + const entries = await redis.keys(keys.prefixKey('*')) + if (entries.length > 0) await redis.del(entries) +} - t.is(error.message, 'The argument `name` is required.') - t.is(error.name, 'TypeError') -}) +test.before(cleanup) +test.after(cleanup) +test.beforeEach(cleanup) test('.create # `metadata` must be a flat object', async t => { - const error = await t.throwsAsync(keys.create({ name: 'hello@microlink.io', metadata: { tier: { type: 'new' } } })) - + const error = await t.throwsAsync(keys.create({ metadata: { tier: { type: 'new' } } })) t.is(error.message, "The metadata field 'tier' can't be an object.") t.is(error.name, 'TypeError') }) test('.create # `metadata` as undefined is omitted', async t => { - const key = keys.create({ name: 'hello@microlink.io', metadata: { cc: undefined } }) - + const key = keys.create({ metadata: { cc: undefined } }) t.is(key.metadata, undefined) }) -test('.create # error if plan is invalid', async t => { - const error = await t.throwsAsync(keys.create({ name: 'hello@microlink.io', plan: 123 })) - - t.is(error.message, 'The id `123` must to start with `plan_`.') - t.is(error.name, 'TypeError') -}) - test('.create # error if plan does not exist', async t => { - const error = await t.throwsAsync(keys.create({ name: 'hello@microlink.io', plan: 'plan_123' })) - - t.is(error.message, 'The plan `plan_123` does not exist.') + const error = await t.throwsAsync(keys.create({ plan: '123' })) + t.is(error.message, 'The plan `123` does not exist.') t.is(error.name, 'TypeError') }) test('.create', async t => { - const key = await keys.create({ name: 'hello@microlink.io' }) - - t.true(key.id.startsWith('key_')) + const key = await keys.create() t.truthy(key.createdAt) t.is(key.createdAt, key.updatedAt) t.is(key.value.length, 16) @@ -62,13 +46,14 @@ test('.create', async t => { test('.create # associate a plan', async t => { const plan = await plans.create({ - name: 'free tier', - quota: { limit: 3000, period: 'day' } + id: randomUUID(), + limit: 3, + period: '10s' }) - const key = await keys.create({ name: 'hello@microlink.io', plan: plan.id }) + const key = await keys.create({ plan: plan.id }) - t.true(key.id.startsWith('key_')) + t.truthy(key.value) t.truthy(key.createdAt) t.is(key.createdAt, key.updatedAt) t.is(key.value.length, 16) @@ -76,95 +61,72 @@ test('.create # associate a plan', async t => { }) test('.retrieve # a key previously created', async t => { - const { id, value } = await keys.create({ name: 'hello@microlink.io' }) - - const { createdAt, updatedAt, ...key } = await keys.retrieve(id) - + const { value } = await keys.create() + const { createdAt, updatedAt, ...key } = await keys.retrieve(value) t.deepEqual(key, { - id, enabled: true, - name: 'hello@microlink.io', value }) }) test('.retrieve # a key not previously created', async t => { - t.is(await keys.retrieve('key_1'), null) + t.is(await keys.retrieve(undefined), null) + t.is(await keys.retrieve(false), null) + t.is(await keys.retrieve(null), null) + t.is(await keys.retrieve('1'), null) }) test('.update', async t => { - const { id, value, createdAt } = await keys.create({ - name: 'hello@microlink.io' - }) + const { value, createdAt } = await keys.create() await setTimeout(0) // ensure time move forward - const { updatedAt, ...key } = await keys.update(id, { - description: 'new description', - enabled: false - }) + const { updatedAt, ...key } = await keys.update(value, { enabled: false }) t.deepEqual(key, { - id, value, createdAt, - enabled: false, - name: 'hello@microlink.io', - description: 'new description' + enabled: false }) t.true(updatedAt > createdAt) - t.deepEqual(await keys.retrieve(id), { ...key, updatedAt }) -}) - -test('.update # error if key is invalid', async t => { - const error = await t.throwsAsync(keys.update('id', { foo: 'bar' })) - t.is(error.message, 'The id `id` must to start with `key_`.') - t.is(error.name, 'TypeError') + t.deepEqual(await keys.retrieve(value), { ...key, updatedAt }) }) test('.update # error if key does not exist', async t => { - const error = await t.throwsAsync(keys.update('key_id')) - t.is(error.message, 'The key `key_id` does not exist.') - t.is(error.name, 'TypeError') -}) - -test('.update # error if plan is invalid', async t => { - const { id } = await keys.create({ name: 'hello@microlink.io' }) - const error = await t.throwsAsync(keys.update(id, { plan: 'id' })) - t.is(error.message, 'The id `id` must to start with `plan_`.') + const error = await t.throwsAsync(keys.update('value')) + t.is(error.message, 'The key `value` does not exist.') t.is(error.name, 'TypeError') }) test('.update # error if plan does not exist', async t => { - const { id } = await keys.create({ name: 'hello@microlink.io' }) - const error = await t.throwsAsync(keys.update(id, { plan: 'plan_id' })) - t.is(error.message, 'The plan `plan_id` does not exist.') + const { value } = await keys.create() + const error = await t.throwsAsync(keys.update(value, { plan: 'id' })) + t.is(error.message, 'The plan `id` does not exist.') t.is(error.name, 'TypeError') }) test('.update # add a plan', async t => { const plan = await plans.create({ - name: 'free tier', - quota: { limit: 3000, period: 'day' } + id: randomUUID(), + limit: 3, + period: '10s' }) - - const { id } = await keys.create({ name: 'hello@microlink.io' }) - const key = await keys.update(id, { plan: plan.id }) - + const { value } = await keys.create() + const key = await keys.update(value, { plan: plan.id }) t.is(key.plan, plan.id) }) test('.update # add metadata', async t => { { - const { id } = await keys.create({ name: 'hello@microlink.io' }) - const key = await keys.update(id, { metadata: { cc: 'hello@microlink.io' } }) + const { value } = await keys.create() + const key = await keys.update(value, { metadata: { cc: 'hello@microlink.io' } }) t.is(key.metadata.cc, 'hello@microlink.io') } { - const { id } = await keys.create({ name: 'hello@microlink.io' }) - await keys.update(id, { metadata: { cc: 'hello@microlink.io' } }) - const key = await keys.update(id, { metadata: { cc: 'hello@microlink.io', version: 2 } }) + const { value } = await keys.create() + await keys.update(value, { metadata: { cc: 'hello@microlink.io' } }) + const key = await keys.update(value, { metadata: { cc: 'hello@microlink.io', version: 2 } }) t.is(key.metadata.cc, 'hello@microlink.io') t.is(key.metadata.version, 2) @@ -172,60 +134,59 @@ test('.update # add metadata', async t => { }) test('.update # metadata must be a flat object', async t => { - const { id } = await keys.create({ name: 'hello@microlink.io' }) - const error = await t.throwsAsync(keys.update(id, { metadata: { email: { cc: 'hello@microlink.io' } } })) + const { value } = await keys.create() + const error = await t.throwsAsync(keys.update(value, { metadata: { email: { cc: 'hello@microlink.io' } } })) t.is(error.message, "The metadata field 'email' can't be an object.") t.is(error.name, 'TypeError') }) test('.update # metadata as undefined is omitted', async t => { { - const { id } = await keys.create({ name: 'hello@microlink.io' }) - const key = await keys.update(id, { metadata: { email: undefined } }) + const { value } = await keys.create() + const key = await keys.update(value, { metadata: { email: undefined } }) t.is(key.metadata, undefined) } { - const { id } = await keys.create({ name: 'hello@microlink.io' }) - const key = await keys.update(id, { metadata: { cc: 'hello@microlink.io', bcc: undefined } }) + const { value } = await keys.create() + const key = await keys.update(value, { metadata: { cc: 'hello@microlink.io', bcc: undefined } }) t.deepEqual(Object.keys(key.metadata), ['cc']) } }) test('.update # prevent to add random data', async t => { - const { id } = await keys.create({ name: 'hello@microlink.io' }) - const key = await keys.update(id, { foo: 'bar' }) + const { value } = await keys.create() + const key = await keys.update(value, { foo: 'bar' }) t.is(key.foo, undefined) }) -test('.update # prevent to modify the key id', async t => { - const { id } = await keys.create({ name: 'hello@microlink.io' }) - const key = await keys.update(id, { id: 'foo' }) - - t.is(key.id, id) -}) - test.serial('.list', async t => { - const { id: id1 } = await keys.create({ name: 'hello@microlink.io' }) - const { id: id2 } = await keys.create({ name: 'hello@microlink.io' }) - const { id: id3 } = await keys.create({ name: 'hello@microlink.io' }) + const { value: value1 } = await keys.create() + const { value: value2 } = await keys.create() + const { value: value3 } = await keys.create() + + const allKeys = await keys.list() - const keyIds = (await keys.list()).map(plan => plan.id).sort() + allKeys.forEach(key => { + t.deepEqual(Object.keys(key), ['value', 'enabled', 'updatedAt', 'createdAt']) + t.false(key.value.startsWith('key_')) + }) - t.deepEqual(keyIds, [id1, id2, id3].sort()) + const keyValues = allKeys.map(key => key.value).sort() + t.deepEqual(keyValues, [value1, value2, value3].sort()) }) test('.del', async t => { { - const { id } = await keys.create({ name: 'hello@microlink.io' }) + const { value } = await keys.create() - t.true(await keys.del(id)) - t.is(await keys.retrieve(id), null) + t.true(await keys.del(value)) + t.is(await keys.retrieve(value), null) } { - const { id } = await keys.create({ name: 'hello@microlink.io', plan: null }) - t.true(await keys.del(id)) - t.is(await keys.retrieve(id), null) + const { value } = await keys.create({ plan: null }) + t.true(await keys.del(value)) + t.is(await keys.retrieve(value), null) } }) @@ -236,18 +197,14 @@ test('.del # error if key does not exist', async t => { t.is(error.name, 'TypeError') }) -test('.del # error plan associated exist', async t => { - const plan = await plans.create({ - name: 'free tier', - quota: { limit: 3000, period: 'day' } - }) - - const { id } = await keys.create({ - name: 'hello@microlink.io', - plan: plan.id - }) - const error = await t.throwsAsync(keys.del(id)) - - t.true(error.message.includes('is associated with the plan')) - t.is(error.name, 'TypeError') -}) +// test('.del # error plan associated exist', async t => { +// const plan = await plans.create({ +// id: 'pro', +// limit: 3, +// period: '10s' +// }) +// const { value } = await keys.create({ plan: plan.id }) +// const error = await t.throwsAsync(keys.del(value)) +// t.true(error.message.includes('is associated with the plan')) +// t.is(error.name, 'TypeError') +// }) diff --git a/test/plans.js b/test/plans.js index 19a0008..78b0295 100644 --- a/test/plans.js +++ b/test/plans.js @@ -1,61 +1,82 @@ 'use strict' const { setTimeout } = require('timers/promises') +const { randomUUID } = require('crypto') const openkey = require('openkey') const Redis = require('ioredis') const test = require('ava') -const { PLAN_PREFIX } = require('openkey/plans') - const redis = new Redis() -const { plans } = openkey({ redis: new Redis() }) +const { keys, plans } = openkey({ redis, prefix: 'test-plans:' }) -test.beforeEach(async () => { - const keys = await redis.keys(`${PLAN_PREFIX}*`) - if (keys.length > 0) await redis.del(keys) -}) +const cleanup = async () => { + const entries = await redis.keys(plans.prefixKey('*')) + if (entries.length > 0) await redis.del(entries) +} -test('.create # `name` is required', async t => { - const error = await t.throwsAsync(plans.create()) - t.is(error.message, 'The argument `name` is required.') - t.is(error.name, 'TypeError') -}) +test.before(cleanup) +test.after(cleanup) +test.beforeEach(cleanup) -test('.create # `quota` is required', async t => { +test('.create # `id` is required', async t => { { - const error = await t.throwsAsync(plans.create({ name: 'free tier' })) - t.is(error.message, 'The argument `quota.period` must be `day` or `week` or `month`.') + const error = await t.throwsAsync(plans.create()) + t.is(error.message, 'The argument `id` must be a string.') t.is(error.name, 'TypeError') } { - const error = await t.throwsAsync(plans.create({ name: 'free tier', quota: {} })) - t.is(error.message, 'The argument `quota.period` must be `day` or `week` or `month`.') + const error = await t.throwsAsync(plans.create({ id: null })) + t.is(error.message, 'The argument `id` must be a string.') t.is(error.name, 'TypeError') } { - const error = await t.throwsAsync(plans.create({ name: 'free tier', quota: { period: 'today' } })) - t.is(error.message, 'The argument `quota.period` must be `day` or `week` or `month`.') + const error = await t.throwsAsync(plans.create({ id: undefined })) + t.is(error.message, 'The argument `id` must be a string.') t.is(error.name, 'TypeError') } { - const error = await t.throwsAsync(plans.create({ name: 'free tier', quota: { period: 'week' } })) - t.is(error.message, 'The argument `quota.limit` must be a positive number.') + const error = await t.throwsAsync(plans.create({ id: 0 })) + t.is(error.message, 'The argument `id` must be a string.') t.is(error.name, 'TypeError') } { - const error = await t.throwsAsync(plans.create({ name: 'free tier', quota: { period: 'week', limit: 0 } })) - t.is(error.message, 'The argument `quota.limit` must be a positive number.') + const error = await t.throwsAsync(plans.create({ id: NaN })) + t.is(error.message, 'The argument `id` must be a string.') t.is(error.name, 'TypeError') } + { + const error = await t.throwsAsync(plans.create({ id: false })) + t.is(error.message, 'The argument `id` must be a string.') + t.is(error.name, 'TypeError') + } +}) + +test('.create # `id` cannot contain whitespaces', async t => { + const error = await t.throwsAsync(plans.create({ id: 'free tier' })) + t.is(error.message, 'The argument `id` cannot contain whitespace.') + t.is(error.name, 'TypeError') +}) + +test('.create # `limit` is required', async t => { + const error = await t.throwsAsync(plans.create({ id: randomUUID() })) + t.is(error.message, 'The argument `limit` must be a positive number.') + t.is(error.name, 'TypeError') +}) + +test('.create # `period` is required', async t => { + const error = await t.throwsAsync(plans.create({ id: randomUUID(), limit: 3 })) + t.is(error.message, 'The argument `period` must be a string.') + t.is(error.name, 'TypeError') }) 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 }, + id: randomUUID(), + limit: 1, + period: '1s', metadata: { tier: { type: 'new' } } }) ) @@ -65,8 +86,9 @@ 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 }, + id: randomUUID(), + limit: 1, + period: '1s', metadata: 'foo' }) ) @@ -78,16 +100,18 @@ test('.create # `metadata` must be a flat object', async t => { test('.create # `metadata` as undefined is omitted', async t => { { const plan = await plans.create({ - name: 'free tier', - quota: { period: 'week', limit: 1000 }, + id: randomUUID(), + limit: 1, + period: '1s', metadata: { tier: undefined } }) t.is(plan.metadata, undefined) } { const plan = await plans.create({ - name: 'free tier', - quota: { period: 'week', limit: 1000 }, + id: randomUUID(), + limit: 1, + period: '1s', metadata: { tier: 'free', version: undefined } }) @@ -96,60 +120,111 @@ test('.create # `metadata` as undefined is omitted', async t => { }) test('.create', async t => { + const id = randomUUID() const plan = await plans.create({ - name: 'free tier', - description: 'this is optional', + id, + limit: 1, + period: '1s', metadata: { tier: 'free' }, - quota: { limit: 3000, period: 'day' }, - throttle: { burstLimit: 1000, rateLimit: 10 } + burst: 1000, + rate: 10 }) - t.true(plan.id.startsWith('plan_')) t.truthy(plan.createdAt) t.is(plan.createdAt, plan.updatedAt) - t.truthy(plan.description) t.deepEqual(plan.metadata, { tier: 'free' }) - t.deepEqual(plan.quota, { limit: 3000, period: 'day' }) - t.deepEqual(plan.throttle, { burstLimit: 1000, rateLimit: 10 }) + t.is(plan.period, '1s') + t.is(plan.rate, 10) + t.is(plan.burst, 1000) + t.is(plan.limit, 1) + t.is(plan.id, id) }) test('.retrieve # a plan not previously created', async t => { - t.is(await plans.retrieve('plan_1'), null) + t.is(await plans.retrieve(undefined), null) + t.is(await plans.retrieve(false), null) + t.is(await plans.retrieve(null), null) + t.is(await plans.retrieve('1'), null) }) test('.retrieve # a plan previously created', async t => { const { id } = await plans.create({ - name: 'free tier', - quota: { limit: 3000, period: 'day' } + id: randomUUID(), + limit: 1, + period: '1s' }) const { createdAt, updatedAt, ...plan } = await plans.retrieve(id) - t.deepEqual(plan, { id, - name: 'free tier', - quota: { limit: 3000, period: 'day' } + limit: 1, + period: '1s' }) }) test('.update', async t => { const { id, createdAt } = await plans.create({ - name: 'free tier', - quota: { limit: 3000, period: 'day' } + id: randomUUID(), + limit: 1, + period: '1s' }) await setTimeout(0) // ensure time move forward const { updatedAt, ...plan } = await plans.update(id, { - name: 'free tier', + id: randomUUID(), quota: { period: 'week' } }) t.deepEqual(plan, { id, createdAt, - name: 'free tier', - quota: { limit: 3000, period: 'week' } + limit: 1, + period: '1s' + }) + + t.true(updatedAt > createdAt) + t.deepEqual(await plans.retrieve(id), { ...plan, updatedAt }) +}) + +test(".update # don't update invalid `limit`", async t => { + const { id, createdAt } = await plans.create({ + id: randomUUID(), + limit: 1, + period: '1s' + }) + + await setTimeout(0) // ensure time move forward + + const { updatedAt, ...plan } = await plans.update(id, { limit: 0 }) + + t.deepEqual(plan, { + id, + createdAt, + limit: 1, + period: '1s' + }) + + t.true(updatedAt > createdAt) + t.deepEqual(await plans.retrieve(id), { ...plan, updatedAt }) +}) + +test(".update # don't update invalid `period`", async t => { + const { id, createdAt } = await plans.create({ + id: randomUUID(), + limit: 1, + period: '1s' + }) + + await setTimeout(0) // ensure time move forward + + const { updatedAt, ...plan } = await plans.update(id, { period: undefined }) + + t.deepEqual(plan, { + id, + createdAt, + limit: 1, + period: '1s' }) t.true(updatedAt > createdAt) @@ -159,8 +234,9 @@ test('.update', async t => { test('.update # add metadata', async t => { { const { id } = await plans.create({ - name: 'free tier', - quota: { limit: 3000, period: 'day' } + id: randomUUID(), + limit: 1, + period: '1s' }) const plan = await plans.update(id, { metadata: { tier: 'free' } }) @@ -168,8 +244,9 @@ test('.update # add metadata', async t => { } { const { id } = await plans.create({ - name: 'free tier', - quota: { limit: 3000, period: 'day' } + id: randomUUID(), + limit: 1, + period: '1s' }) await plans.update(id, { metadata: { tier: 'free' } }) @@ -181,8 +258,9 @@ test('.update # add metadata', async t => { test('.update # metadata must be a flat object', async t => { const { id } = await plans.create({ - name: 'free tier', - quota: { limit: 3000, period: 'day' } + id: randomUUID(), + limit: 1, + period: '1s' }) const error = await t.throwsAsync(plans.update(id, { metadata: { tier: { type: 'new' } } })) @@ -193,8 +271,9 @@ test('.update # metadata must be a flat object', async t => { test('.update # metadata as undefined is omitted', async t => { { const { id } = await plans.create({ - name: 'free tier', - quota: { limit: 3000, period: 'day' } + id: randomUUID(), + limit: 1, + period: '1s' }) const plan = await plans.update(id, { metadata: { tier: undefined } }) @@ -203,8 +282,9 @@ test('.update # metadata as undefined is omitted', async t => { { const { id } = await plans.create({ - name: 'free tier', - quota: { limit: 3000, period: 'day' } + id: randomUUID(), + limit: 1, + period: '1s' }) const plan = await plans.update(id, { metadata: { tier: 'free', version: undefined } }) @@ -214,28 +294,25 @@ test('.update # metadata as undefined is omitted', async t => { test('.update # prevent to add random data', async t => { const { id } = await plans.create({ - name: 'free tier', - quota: { limit: 3000, period: 'day' } + id: randomUUID(), + limit: 1, + period: '1s' }) + const plan = await plans.update(id, { foo: 'bar' }) t.is(plan.foo, undefined) }) test('.update # prevent to modify the plan id', async t => { + const originalId = randomUUID() const { id } = await plans.create({ - name: 'free tier', - quota: { limit: 3000, period: 'day' } + id: originalId, + limit: 1, + period: '1s' }) - const plan = await plans.update(id, { id: 'foo' }) - - t.is(plan.id, id) -}) - -test('.update # error if plan is invalid', async t => { - const error = await t.throwsAsync(plans.update('id', { foo: 'bar' })) - t.is(error.message, 'The id `id` must to start with `plan_`.') - t.is(error.name, 'TypeError') + const plan = await plans.update(id, { id: randomUUID() }) + t.is(plan.id, originalId) }) test('.update # error if plan does not exist', async t => { @@ -246,28 +323,33 @@ test('.update # error if plan does not exist', async t => { test.serial('.list', async t => { const { id: id1 } = await plans.create({ - name: 'free tier', - quota: { limit: 3000, period: 'day' } + id: randomUUID(), + limit: 3, + period: '10s' }) const { id: id2 } = await plans.create({ - name: 'free tier', - quota: { limit: 3000, period: 'day' } + id: randomUUID(), + limit: 3, + period: '10s' }) const { id: id3 } = await plans.create({ - name: 'free tier', - quota: { limit: 3000, period: 'day' } + id: randomUUID(), + limit: 3, + period: '10s' }) - const planIds = (await plans.list()).map(plan => plan.id).sort() + const allPlans = await plans.list() + const planIds = allPlans.map(plan => plan.id).sort() t.deepEqual(planIds, [id1, id2, id3].sort()) }) test('.del', async t => { const { id } = await plans.create({ - name: 'free tier', - quota: { limit: 3000, period: 'day' } + id: randomUUID(), + limit: 1, + period: '1s' }) t.true(await plans.del(id)) @@ -275,8 +357,21 @@ test('.del', async t => { }) test('.del # error if plan does not exist', async t => { - const error = await t.throwsAsync(plans.del('plan_id')) + const error = await t.throwsAsync(plans.del('id')) + t.is(error.message, 'The plan `id` does not exist.') + t.is(error.name, 'TypeError') +}) - t.is(error.message, 'The plan `plan_id` does not exist.') +test.serial('.del # error if a key is associated with the plan', async t => { + const plan = await plans.create({ + id: randomUUID(), + limit: 3, + period: '10s' + }) + + const key = await keys.create({ plan: plan.id }) + const error = await t.throwsAsync(plans.del(plan.id)) + + t.is(error.message, `The plan \`${plan.id}\` is associated with the key \`${key.value}\`.`) t.is(error.name, 'TypeError') })