diff --git a/package.json b/package.json index 8f31e73..7890b7d 100644 --- a/package.json +++ b/package.json @@ -43,12 +43,14 @@ "ava": "5", "c8": "latest", "ci-publish": "latest", + "date-fns": "latest", "finepack": "latest", "git-authors-cli": "latest", "github-generate-release": "latest", "ioredis": "latest", "nano-staged": "latest", "npm-check-updates": "latest", + "set-now": "latest", "simple-git-hooks": "latest", "standard": "latest", "standard-markdown": "latest", @@ -77,6 +79,12 @@ }, "preferGlobal": true, "license": "MIT", + "ava": { + "files": [ + "test/**/*.js", + "!test/helpers.js" + ] + }, "commitlint": { "extends": [ "@commitlint/config-conventional" diff --git a/src/index.js b/src/index.js index f92caaf..9891bf1 100644 --- a/src/index.js +++ b/src/index.js @@ -1,12 +1,17 @@ 'use strict' const JSONB = require('json-buffer') -const createKeys = require('./keys') + +const createStats = require('./stats') +const createUsage = require('./usage') const createPlans = require('./plans') +const createKeys = require('./keys') module.exports = ({ serialize = JSONB.stringify, deserialize = JSONB.parse, redis = new Map(), prefix = '' } = {}) => { let _keys + const stats = createStats({ redis, prefix }) const plans = createPlans({ serialize, deserialize, redis, prefix, keys: () => _keys }) const keys = (_keys = createKeys({ serialize, deserialize, redis, plans, prefix })) - return { keys, plans } + const usage = createUsage({ serialize, deserialize, redis, keys, plans, prefix, stats }) + return { keys, plans, usage, stats } } diff --git a/src/keys.js b/src/keys.js index ac3f2be..4da6323 100644 --- a/src/keys.js +++ b/src/keys.js @@ -88,7 +88,7 @@ module.exports = ({ serialize, deserialize, plans, redis, prefix } = {}) => { return Promise.all(keyValues.map(keyValues => retrieve(keyValues))) } - const prefixKey = key => `${prefix}key_${key}` + const prefixKey = key => `${prefix}key:${key}` return { create, retrieve, del, update, list, prefixKey } } diff --git a/src/plans.js b/src/plans.js index 687b0a0..8e52890 100644 --- a/src/plans.js +++ b/src/plans.js @@ -129,7 +129,7 @@ module.exports = ({ serialize, deserialize, redis, keys, prefix } = {}) => { return result } - const prefixKey = key => `${prefix}plan_${key}` + const prefixKey = key => `${prefix}plan:${key}` return { create, del, retrieve, update, list, prefixKey } } diff --git a/src/stats.js b/src/stats.js new file mode 100644 index 0000000..083a1b2 --- /dev/null +++ b/src/stats.js @@ -0,0 +1,50 @@ +'use strict' + +const formatYYYMMDDDate = (now = new Date()) => { + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +module.exports = ({ redis, prefix }) => { + const prefixKey = key => `${prefix}stats:${key}` + + const increment = async keyValue => { + redis.incr(`${prefixKey(keyValue)}:${formatYYYMMDDDate()}`) + } + + /** + * Get stats for a given key. + * + * @param {string} keyValue + * + * @returns {Promise} + * + * @example + * const stats = await openkey.stats('key-value') + * // stats = [ + * // { date: '2021-01-01', count: 1 }, + * // { date: '2021-01-02', count: 10 }, + * // { date: '2021-01-03', count: 5 } + * // ] + */ + const stats = async keyValue => { + const keys = await redis.keys(prefixKey(`${keyValue}*`)) + const stats = [] + + for (const key of keys) { + const date = key.replace(`${prefixKey(keyValue)}:`, '') + const count = Number(await redis.get(key)) + stats.push({ date, count }) + } + + return stats.sort((a, b) => a.date.localeCompare(b.date)) + } + + stats.increment = increment + stats.prefixKey = prefixKey + stats.formatYYYMMDDDate = formatYYYMMDDDate + + return stats +} diff --git a/src/usage.js b/src/usage.js new file mode 100644 index 0000000..918ece7 --- /dev/null +++ b/src/usage.js @@ -0,0 +1,80 @@ +'use strict' + +const ms = require('ms') + +module.exports = ({ plans, keys, redis, stats, prefix, serialize, deserialize }) => { + const prefixKey = key => `${prefix}usage:${key}` + + /** + * Increment the usage for a given key. + * + * @param {string} keyValue + * @param {number} [quantity=1] + * + * @returns {Promise<{limit: number, remaining: number, reset: number, pending: Promise}>} + * + * @example + * const usage = await openkey.usage('key-value') + * // usage = { + * // limit: 1000, + * // remaining: 999, + * // reset: 1612137600000, + * // pending: Promise + * // } + * + */ + const increment = async (keyValue, quantity = 1) => { + const key = await keys.retrieve(keyValue) + const plan = await plans.retrieve(key.plan) + + let usage = deserialize(await redis.get(prefixKey(keyValue))) + + // TODO: move into lua script + if (usage === null) { + usage = { + count: quantity, + reset: Date.now() + ms(plan.period) + } + } else if (Date.now() > usage.reset) { + usage.count = quantity + usage.reset = Date.now() + ms(plan.period) + } else { + if (usage.count < plan.limit) { + usage.count = usage.count + quantity + } + } + + const pending = + quantity > 0 && Promise.all([redis.set(prefixKey(keyValue), serialize(usage)), stats.increment(keyValue)]) + + return { + limit: plan.limit, + remaining: plan.limit - usage.count, + reset: usage.reset, + pending + } + } + + /** + * Get usage for a given key. + * + * @param {string} keyValue + * + * @returns {Promise<{limit: number, remaining: number, reset: number, pending: Promise} + * + * @example + * const usage = await openkey.usage('key-value') + * // usage = { + * // limit: 1000, + * // remaining: 999, + * // reset: 1612137600000, + * // pending: Promise + * // } + * + */ + const get = async keyValue => increment(keyValue, 0) + get.increment = increment + get.prefixKey = prefixKey + + return get +} diff --git a/test/helpers.js b/test/helpers.js new file mode 100644 index 0000000..86f5e8b --- /dev/null +++ b/test/helpers.js @@ -0,0 +1,18 @@ +'use strict' + +const testCleanup = async ({ test, redis, keys }) => { + const cleanup = async () => { + const entries = await keys() + if (entries.length > 0) { + const pipeline = redis.pipeline() + entries.forEach(entry => pipeline.del(entry)) + await pipeline.exec() + } + } + + test.before(cleanup) + test.after(cleanup) + test.beforeEach(cleanup) +} + +module.exports = { testCleanup } diff --git a/test/keys.js b/test/keys.js index 5fbc5ef..ec71127 100644 --- a/test/keys.js +++ b/test/keys.js @@ -2,42 +2,40 @@ const { setTimeout } = require('timers/promises') const { randomUUID } = require('crypto') -const openkey = require('openkey') const Redis = require('ioredis') const test = require('ava') -const redis = new Redis() +const { testCleanup } = require('./helpers') -const { keys, plans } = openkey({ redis, prefix: 'test-keys:' }) +const redis = new Redis() -const cleanup = async () => { - const entries = await redis.keys(keys.prefixKey('*')) - if (entries.length > 0) await redis.del(entries) -} +const openkey = require('openkey')({ redis, prefix: 'test-keys:' }) -test.before(cleanup) -test.after(cleanup) -test.beforeEach(cleanup) +testCleanup({ + test, + redis, + keys: () => Promise.all([redis.keys(openkey.keys.prefixKey('*'))]) +}) test('.create # `metadata` must be a flat object', async t => { - const error = await t.throwsAsync(keys.create({ metadata: { tier: { type: 'new' } } })) + const error = await t.throwsAsync(openkey.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({ metadata: { cc: undefined } }) + const key = openkey.keys.create({ metadata: { cc: undefined } }) t.is(key.metadata, undefined) }) test('.create # error if plan does not exist', async t => { - const error = await t.throwsAsync(keys.create({ plan: '123' })) + const error = await t.throwsAsync(openkey.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() + const key = await openkey.keys.create() t.truthy(key.createdAt) t.is(key.createdAt, key.updatedAt) t.is(key.value.length, 16) @@ -45,13 +43,13 @@ test('.create', async t => { }) test('.create # associate a plan', async t => { - const plan = await plans.create({ + const plan = await openkey.plans.create({ id: randomUUID(), limit: 3, period: '10s' }) - const key = await keys.create({ plan: plan.id }) + const key = await openkey.keys.create({ plan: plan.id }) t.truthy(key.value) t.truthy(key.createdAt) @@ -61,8 +59,8 @@ test('.create # associate a plan', async t => { }) test('.retrieve # a key previously created', async t => { - const { value } = await keys.create() - const { createdAt, updatedAt, ...key } = await keys.retrieve(value) + const { value } = await openkey.keys.create() + const { createdAt, updatedAt, ...key } = await openkey.keys.retrieve(value) t.deepEqual(key, { enabled: true, value @@ -70,18 +68,18 @@ test('.retrieve # a key previously created', async t => { }) test('.retrieve # a key not previously created', async t => { - 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) + t.is(await openkey.keys.retrieve(undefined), null) + t.is(await openkey.keys.retrieve(false), null) + t.is(await openkey.keys.retrieve(null), null) + t.is(await openkey.keys.retrieve('1'), null) }) test('.update', async t => { - const { value, createdAt } = await keys.create() + const { value, createdAt } = await openkey.keys.create() await setTimeout(0) // ensure time move forward - const { updatedAt, ...key } = await keys.update(value, { enabled: false }) + const { updatedAt, ...key } = await openkey.keys.update(value, { enabled: false }) t.deepEqual(key, { value, @@ -90,43 +88,43 @@ test('.update', async t => { }) t.true(updatedAt > createdAt) - t.deepEqual(await keys.retrieve(value), { ...key, updatedAt }) + t.deepEqual(await openkey.keys.retrieve(value), { ...key, updatedAt }) }) test('.update # error if key does not exist', async t => { - const error = await t.throwsAsync(keys.update('value')) + const error = await t.throwsAsync(openkey.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 { value } = await keys.create() - const error = await t.throwsAsync(keys.update(value, { plan: 'id' })) + const { value } = await openkey.keys.create() + const error = await t.throwsAsync(openkey.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({ + const plan = await openkey.plans.create({ id: randomUUID(), limit: 3, period: '10s' }) - const { value } = await keys.create() - const key = await keys.update(value, { plan: plan.id }) + const { value } = await openkey.keys.create() + const key = await openkey.keys.update(value, { plan: plan.id }) t.is(key.plan, plan.id) }) test('.update # add metadata', async t => { { - const { value } = await keys.create() - const key = await keys.update(value, { metadata: { cc: 'hello@microlink.io' } }) + const { value } = await openkey.keys.create() + const key = await openkey.keys.update(value, { metadata: { cc: 'hello@microlink.io' } }) t.is(key.metadata.cc, 'hello@microlink.io') } { - 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 } }) + const { value } = await openkey.keys.create() + await openkey.keys.update(value, { metadata: { cc: 'hello@microlink.io' } }) + const key = await openkey.keys.update(value, { metadata: { cc: 'hello@microlink.io', version: 2 } }) t.is(key.metadata.cc, 'hello@microlink.io') t.is(key.metadata.version, 2) @@ -134,38 +132,38 @@ test('.update # add metadata', async t => { }) test('.update # metadata must be a flat object', async t => { - const { value } = await keys.create() - const error = await t.throwsAsync(keys.update(value, { metadata: { email: { cc: 'hello@microlink.io' } } })) + const { value } = await openkey.keys.create() + const error = await t.throwsAsync(openkey.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 { value } = await keys.create() - const key = await keys.update(value, { metadata: { email: undefined } }) + const { value } = await openkey.keys.create() + const key = await openkey.keys.update(value, { metadata: { email: undefined } }) t.is(key.metadata, undefined) } { - const { value } = await keys.create() - const key = await keys.update(value, { metadata: { cc: 'hello@microlink.io', bcc: undefined } }) + const { value } = await openkey.keys.create() + const key = await openkey.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 { value } = await keys.create() - const key = await keys.update(value, { foo: 'bar' }) + const { value } = await openkey.keys.create() + const key = await openkey.keys.update(value, { foo: 'bar' }) t.is(key.foo, undefined) }) test.serial('.list', async t => { - const { value: value1 } = await keys.create() - const { value: value2 } = await keys.create() - const { value: value3 } = await keys.create() + const { value: value1 } = await openkey.keys.create() + const { value: value2 } = await openkey.keys.create() + const { value: value3 } = await openkey.keys.create() - const allKeys = await keys.list() + const allKeys = await openkey.keys.list() allKeys.forEach(key => { t.deepEqual(Object.keys(key), ['value', 'enabled', 'updatedAt', 'createdAt']) @@ -178,33 +176,21 @@ test.serial('.list', async t => { test('.del', async t => { { - const { value } = await keys.create() + const { value } = await openkey.keys.create() - t.true(await keys.del(value)) - t.is(await keys.retrieve(value), null) + t.true(await openkey.keys.del(value)) + t.is(await openkey.keys.retrieve(value), null) } { - const { value } = await keys.create({ plan: null }) - t.true(await keys.del(value)) - t.is(await keys.retrieve(value), null) + const { value } = await openkey.keys.create({ plan: null }) + t.true(await openkey.keys.del(value)) + t.is(await openkey.keys.retrieve(value), null) } }) test('.del # error if key does not exist', async t => { - const error = await t.throwsAsync(keys.del('key_id')) + const error = await t.throwsAsync(openkey.keys.del('key_id')) t.is(error.message, 'The key `key_id` does not exist.') 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 efe69c9..570d560 100644 --- a/test/plans.js +++ b/test/plans.js @@ -2,51 +2,49 @@ const { setTimeout } = require('timers/promises') const { randomUUID } = require('crypto') -const openkey = require('openkey') const Redis = require('ioredis') const test = require('ava') -const redis = new Redis() +const { testCleanup } = require('./helpers') -const { keys, plans } = openkey({ redis, prefix: 'test-plans:' }) +const redis = new Redis() -const cleanup = async () => { - const entries = await redis.keys(plans.prefixKey('*')) - if (entries.length > 0) await redis.del(entries) -} +const openkey = require('openkey')({ redis, prefix: 'test-plans:' }) -test.before(cleanup) -test.after(cleanup) -test.beforeEach(cleanup) +testCleanup({ + test, + redis, + keys: () => Promise.all([redis.keys(openkey.plans.prefixKey('*'))]) +}) test('.create # `id` is required', async t => { { - const error = await t.throwsAsync(plans.create()) + const error = await t.throwsAsync(openkey.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({ id: null })) + const error = await t.throwsAsync(openkey.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({ id: undefined })) + const error = await t.throwsAsync(openkey.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({ id: 0 })) + const error = await t.throwsAsync(openkey.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({ id: NaN })) + const error = await t.throwsAsync(openkey.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 })) + const error = await t.throwsAsync(openkey.plans.create({ id: false })) t.is(error.message, 'The argument `id` must be a string.') t.is(error.name, 'TypeError') } @@ -55,27 +53,27 @@ test('.create # `id` is required', async t => { test('.create # the `id` already exist', async t => { const id = randomUUID() const props = { id, limit: 1, period: '1s', metadata: { tier: undefined } } - const plan = await plans.create(props) + const plan = await openkey.plans.create(props) t.is(typeof plan, 'object') - const error = await t.throwsAsync(plans.create(props)) + const error = await t.throwsAsync(openkey.plans.create(props)) t.is(error.message, `The plan \`${id}\` already exists.`) t.is(error.name, 'TypeError') }) test('.create # `id` cannot contain whitespaces', async t => { - const error = await t.throwsAsync(plans.create({ id: 'free tier' })) + const error = await t.throwsAsync(openkey.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() })) + const error = await t.throwsAsync(openkey.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 })) + const error = await t.throwsAsync(openkey.plans.create({ id: randomUUID(), limit: 3 })) t.is(error.message, 'The argument `period` must be a string.') t.is(error.name, 'TypeError') }) @@ -83,7 +81,7 @@ test('.create # `period` is required', async t => { test('.create # `metadata` must be a flat object', async t => { { const error = await t.throwsAsync( - plans.create({ + openkey.plans.create({ id: randomUUID(), limit: 1, period: '1s', @@ -95,7 +93,7 @@ test('.create # `metadata` must be a flat object', async t => { } { const error = await t.throwsAsync( - plans.create({ + openkey.plans.create({ id: randomUUID(), limit: 1, period: '1s', @@ -109,7 +107,7 @@ test('.create # `metadata` must be a flat object', async t => { test('.create # `metadata` as undefined is omitted', async t => { { - const plan = await plans.create({ + const plan = await openkey.plans.create({ id: randomUUID(), limit: 1, period: '1s', @@ -118,7 +116,7 @@ test('.create # `metadata` as undefined is omitted', async t => { t.is(plan.metadata, undefined) } { - const plan = await plans.create({ + const plan = await openkey.plans.create({ id: randomUUID(), limit: 1, period: '1s', @@ -131,7 +129,7 @@ test('.create # `metadata` as undefined is omitted', async t => { test('.create', async t => { const id = randomUUID() - const plan = await plans.create({ + const plan = await openkey.plans.create({ id, limit: 1, period: '1s', @@ -151,20 +149,20 @@ test('.create', async t => { }) test('.retrieve # a plan not previously created', async t => { - 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) + t.is(await openkey.plans.retrieve(undefined), null) + t.is(await openkey.plans.retrieve(false), null) + t.is(await openkey.plans.retrieve(null), null) + t.is(await openkey.plans.retrieve('1'), null) }) test('.retrieve # a plan previously created', async t => { - const { id } = await plans.create({ + const { id } = await openkey.plans.create({ id: randomUUID(), limit: 1, period: '1s' }) - const { createdAt, updatedAt, ...plan } = await plans.retrieve(id) + const { createdAt, updatedAt, ...plan } = await openkey.plans.retrieve(id) t.deepEqual(plan, { id, limit: 1, @@ -173,7 +171,7 @@ test('.retrieve # a plan previously created', async t => { }) test('.update', async t => { - const { id, createdAt } = await plans.create({ + const { id, createdAt } = await openkey.plans.create({ id: randomUUID(), limit: 1, period: '1s' @@ -181,7 +179,7 @@ test('.update', async t => { await setTimeout(0) // ensure time move forward - const { updatedAt, ...plan } = await plans.update(id, { + const { updatedAt, ...plan } = await openkey.plans.update(id, { id: randomUUID(), quota: { period: 'week' } }) @@ -194,11 +192,11 @@ test('.update', async t => { }) t.true(updatedAt > createdAt) - t.deepEqual(await plans.retrieve(id), { ...plan, updatedAt }) + t.deepEqual(await openkey.plans.retrieve(id), { ...plan, updatedAt }) }) test(".update # don't update invalid `limit`", async t => { - const { id, createdAt } = await plans.create({ + const { id, createdAt } = await openkey.plans.create({ id: randomUUID(), limit: 1, period: '1s' @@ -206,7 +204,7 @@ test(".update # don't update invalid `limit`", async t => { await setTimeout(0) // ensure time move forward - const { updatedAt, ...plan } = await plans.update(id, { limit: 0 }) + const { updatedAt, ...plan } = await openkey.plans.update(id, { limit: 0 }) t.deepEqual(plan, { id, @@ -216,11 +214,11 @@ test(".update # don't update invalid `limit`", async t => { }) t.true(updatedAt > createdAt) - t.deepEqual(await plans.retrieve(id), { ...plan, updatedAt }) + t.deepEqual(await openkey.plans.retrieve(id), { ...plan, updatedAt }) }) test(".update # don't update invalid `period`", async t => { - const { id, createdAt } = await plans.create({ + const { id, createdAt } = await openkey.plans.create({ id: randomUUID(), limit: 1, period: '1s' @@ -228,7 +226,7 @@ test(".update # don't update invalid `period`", async t => { await setTimeout(0) // ensure time move forward - const { updatedAt, ...plan } = await plans.update(id, { period: undefined }) + const { updatedAt, ...plan } = await openkey.plans.update(id, { period: undefined }) t.deepEqual(plan, { id, @@ -238,149 +236,149 @@ test(".update # don't update invalid `period`", async t => { }) t.true(updatedAt > createdAt) - t.deepEqual(await plans.retrieve(id), { ...plan, updatedAt }) + t.deepEqual(await openkey.plans.retrieve(id), { ...plan, updatedAt }) }) test('.update # add metadata', async t => { { - const { id } = await plans.create({ + const { id } = await openkey.plans.create({ id: randomUUID(), limit: 1, period: '1s' }) - const plan = await plans.update(id, { metadata: { tier: 'free' } }) + const plan = await openkey.plans.update(id, { metadata: { tier: 'free' } }) t.is(plan.metadata.tier, 'free') } { - const { id } = await plans.create({ + const { id } = await openkey.plans.create({ id: randomUUID(), limit: 1, period: '1s' }) - await plans.update(id, { metadata: { tier: 'free' } }) - const plan = await plans.update(id, { metadata: { tier: 'free', version: 2 } }) + await openkey.plans.update(id, { metadata: { tier: 'free' } }) + const plan = await openkey.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({ + const { id } = await openkey.plans.create({ id: randomUUID(), limit: 1, period: '1s' }) - const error = await t.throwsAsync(plans.update(id, { metadata: { tier: { type: 'new' } } })) + const error = await t.throwsAsync(openkey.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({ + const { id } = await openkey.plans.create({ id: randomUUID(), limit: 1, period: '1s' }) - const plan = await plans.update(id, { metadata: { tier: undefined } }) + const plan = await openkey.plans.update(id, { metadata: { tier: undefined } }) t.is(plan.metadata, undefined) } { - const { id } = await plans.create({ + const { id } = await openkey.plans.create({ id: randomUUID(), limit: 1, period: '1s' }) - const plan = await plans.update(id, { metadata: { tier: 'free', version: undefined } }) + const plan = await openkey.plans.update(id, { metadata: { tier: 'free', version: undefined } }) t.deepEqual(Object.keys(plan.metadata), ['tier']) } }) test('.update # prevent to add random data', async t => { - const { id } = await plans.create({ + const { id } = await openkey.plans.create({ id: randomUUID(), limit: 1, period: '1s' }) - const plan = await plans.update(id, { foo: 'bar' }) + const plan = await openkey.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({ + const { id } = await openkey.plans.create({ id: originalId, limit: 1, period: '1s' }) - const plan = await plans.update(id, { id: randomUUID() }) + const plan = await openkey.plans.update(id, { id: randomUUID() }) t.is(plan.id, originalId) }) test('.update # error if plan does not exist', async t => { - const error = await t.throwsAsync(plans.update('plan_id', { foo: 'bar' })) + const error = await t.throwsAsync(openkey.plans.update('plan_id', { foo: 'bar' })) t.is(error.message, 'The plan `plan_id` does not exist.') t.is(error.name, 'TypeError') }) test.serial('.list', async t => { - const { id: id1 } = await plans.create({ + const { id: id1 } = await openkey.plans.create({ id: randomUUID(), limit: 3, period: '10s' }) - const { id: id2 } = await plans.create({ + const { id: id2 } = await openkey.plans.create({ id: randomUUID(), limit: 3, period: '10s' }) - const { id: id3 } = await plans.create({ + const { id: id3 } = await openkey.plans.create({ id: randomUUID(), limit: 3, period: '10s' }) - const allPlans = await plans.list() + const allPlans = await openkey.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({ + const { id } = await openkey.plans.create({ id: randomUUID(), limit: 1, period: '1s' }) - t.true(await plans.del(id)) - t.is(await plans.retrieve(id), null) + t.true(await openkey.plans.del(id)) + t.is(await openkey.plans.retrieve(id), null) }) test('.del # error if plan does not exist', async t => { - const error = await t.throwsAsync(plans.del('id')) + const error = await t.throwsAsync(openkey.plans.del('id')) t.is(error.message, 'The plan `id` does not exist.') t.is(error.name, 'TypeError') }) test.serial('.del # error if a key is associated with the plan', async t => { - const plan = await plans.create({ + const plan = await openkey.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)) + const key = await openkey.keys.create({ plan: plan.id }) + const error = await t.throwsAsync(openkey.plans.del(plan.id)) t.is(error.message, `The plan \`${plan.id}\` is associated with the key \`${key.value}\`.`) t.is(error.name, 'TypeError') diff --git a/test/stats.js b/test/stats.js new file mode 100644 index 0000000..330c99e --- /dev/null +++ b/test/stats.js @@ -0,0 +1,61 @@ +'use strict' + +require('set-now') + +const { addDays } = require('date-fns') +const { randomUUID } = require('crypto') +const Redis = require('ioredis') +const test = require('ava') + +const { testCleanup } = require('./helpers') + +const redis = new Redis() + +const openkey = require('openkey')({ redis, prefix: 'test-stats:' }) + +testCleanup({ + test, + redis, + keys: () => + Promise.all([ + redis.keys(openkey.keys.prefixKey('*')), + redis.keys(openkey.plans.prefixKey('*')), + redis.keys(openkey.usage.prefixKey('*')) + ]) +}) + +test('.stats', async t => { + const plan = await openkey.plans.create({ + id: randomUUID(), + limit: 3, + period: '100ms' + }) + + const key = await openkey.keys.create({ plan: plan.id }) + let data = await openkey.usage.increment(key.value) + await data.pending + + Date.setNow(addDays(Date.now(), 1)) + await Promise.all( + [...Array(10).keys()].map(async () => { + data = await openkey.usage.increment(key.value) + await data.pending + }) + ) + + Date.setNow(addDays(Date.now(), 1)) + await Promise.all( + [...Array(5).keys()].map(async () => { + data = await openkey.usage.increment(key.value) + await data.pending + }) + ) + + Date.setNow() + + t.deepEqual(await openkey.stats(key.value), [ + { date: openkey.stats.formatYYYMMDDDate(), count: 1 }, + { date: openkey.stats.formatYYYMMDDDate(addDays(Date.now(), 1)), count: 10 }, + { date: openkey.stats.formatYYYMMDDDate(addDays(Date.now(), 2)), count: 5 } + ]) +}) diff --git a/test/usage.js b/test/usage.js new file mode 100644 index 0000000..c05f4fe --- /dev/null +++ b/test/usage.js @@ -0,0 +1,54 @@ +'use strict' + +const { setTimeout } = require('timers/promises') +const { randomUUID } = require('crypto') +const Redis = require('ioredis') +const test = require('ava') + +const { testCleanup } = require('./helpers') + +const redis = new Redis() + +const openkey = require('openkey')({ redis, prefix: 'test-usage:' }) + +testCleanup({ + test, + redis, + keys: () => + Promise.all([ + redis.keys(openkey.keys.prefixKey('*')), + redis.keys(openkey.plans.prefixKey('*')), + redis.keys(openkey.usage.prefixKey('*')) + ]) +}) + +test('.increment', async t => { + const plan = await openkey.plans.create({ + id: randomUUID(), + limit: 3, + period: '100ms' + }) + + const key = await openkey.keys.create({ plan: plan.id }) + let data = await openkey.usage(key.value) + t.is(data.pending, false) + t.is(data.remaining, 3) + data = await openkey.usage.increment(key.value) + await data.pending + t.is(data.remaining, 2) + data = await openkey.usage.increment(key.value) + await data.pending + t.is(data.remaining, 1) + data = await openkey.usage.increment(key.value) + await data.pending + t.is(data.remaining, 0) + data = await openkey.usage.increment(key.value) + await data.pending + t.is(data.remaining, 0) + data = await openkey.usage.increment(key.value) + await data.pending + t.is(data.remaining, 0) + await setTimeout(100) + data = await openkey.usage(key.value) + t.is(data.remaining, 3) +})