* Update RedisAccessor to allow for graceful GET failures, too * Add unit tests for allowGetFailures behavior
356 lines
12 KiB
JavaScript
356 lines
12 KiB
JavaScript
const { RedisClient: InMemoryRedis } = require('redis-mock')
|
|
const RedisAccessor = require('../../lib/redis-accessor')
|
|
|
|
describe('RedisAccessor', () => {
|
|
test('is a constructor', async () => {
|
|
expect(typeof RedisAccessor).toBe('function')
|
|
|
|
const instance = new RedisAccessor()
|
|
expect(instance).toBeInstanceOf(RedisAccessor)
|
|
})
|
|
|
|
test('has expected instance properties', async () => {
|
|
const instance = new RedisAccessor()
|
|
expect(Object.keys(instance).sort()).toEqual(['_allowGetFailures', '_allowSetFailures', '_client', '_prefix'])
|
|
})
|
|
|
|
test('has expected static methods', async () => {
|
|
expect(typeof RedisAccessor.translateSetArguments).toBe('function')
|
|
})
|
|
|
|
describe('#_allowGetFailures property', () => {
|
|
test('defaults to false', async () => {
|
|
const instance = new RedisAccessor()
|
|
expect(instance._allowGetFailures).toBe(false)
|
|
})
|
|
|
|
test('is expected value', async () => {
|
|
const instance = new RedisAccessor({ allowGetFailures: true })
|
|
expect(instance._allowGetFailures).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('#_allowSetFailures property', () => {
|
|
test('defaults to false', async () => {
|
|
const instance = new RedisAccessor()
|
|
expect(instance._allowSetFailures).toBe(false)
|
|
})
|
|
|
|
test('is expected value', async () => {
|
|
const instance = new RedisAccessor({ allowSetFailures: true })
|
|
expect(instance._allowSetFailures).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('#_client property', () => {
|
|
test('is expected Redis client', async () => {
|
|
const instance = new RedisAccessor()
|
|
expect(instance._client).toBeInstanceOf(InMemoryRedis)
|
|
})
|
|
})
|
|
|
|
describe('#_prefix property', () => {
|
|
test('defaults to empty string', async () => {
|
|
const instance = new RedisAccessor()
|
|
expect(instance._prefix).toBe('')
|
|
})
|
|
|
|
test('is expected value', async () => {
|
|
const instance = new RedisAccessor({ prefix: 'myPrefix' })
|
|
expect(instance._prefix).toBe('myPrefix:')
|
|
})
|
|
|
|
test('removes a trailing colon', async () => {
|
|
const instance = new RedisAccessor({ prefix: 'myPrefix:' })
|
|
expect(instance._prefix).toBe('myPrefix:')
|
|
})
|
|
|
|
test('removes multiple trailing colons', async () => {
|
|
const instance = new RedisAccessor({ prefix: 'myPrefix::' })
|
|
expect(instance._prefix).toBe('myPrefix:')
|
|
})
|
|
})
|
|
|
|
describe('#prefix method', () => {
|
|
test('returns prefixed key', async () => {
|
|
const prefix = 'myPrefix'
|
|
const instance = new RedisAccessor({ prefix })
|
|
expect(instance.prefix('myKey')).toBe('myPrefix:myKey')
|
|
})
|
|
|
|
test('returns original key if no prefix is configured', async () => {
|
|
const instance = new RedisAccessor()
|
|
expect(instance.prefix('myKey')).toBe('myKey')
|
|
})
|
|
|
|
test('throws if no key is provided', async () => {
|
|
const instance = new RedisAccessor()
|
|
expect(() => instance.prefix()).toThrow(
|
|
new TypeError('Key must be a non-empty string but was: undefined')
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('.translateSetArguments method', () => {
|
|
test('defaults to an empty list of arguments if no options are given', async () => {
|
|
expect(RedisAccessor.translateSetArguments()).toEqual([])
|
|
})
|
|
|
|
test('adds argument "NX" if option `newOnly` is set to true', async () => {
|
|
expect(RedisAccessor.translateSetArguments({ newOnly: true })).toEqual(['NX'])
|
|
})
|
|
|
|
test('adds argument "XX" if option `existingOnly` is set to true', async () => {
|
|
expect(RedisAccessor.translateSetArguments({ existingOnly: true })).toEqual(['XX'])
|
|
})
|
|
|
|
test('adds argument "PX n" if option `expireIn` is provided with a positive finite integer', async () => {
|
|
expect(RedisAccessor.translateSetArguments({ expireIn: 20 })).toEqual(['PX', 20])
|
|
})
|
|
|
|
test('adds argument "PX n" with rounded integer if option `expireIn` is provided with a positive finite non-integer', async () => {
|
|
expect(RedisAccessor.translateSetArguments({ expireIn: 20.5 })).toEqual(['PX', 21])
|
|
expect(RedisAccessor.translateSetArguments({ expireIn: 29.1 })).toEqual(['PX', 29])
|
|
})
|
|
|
|
test('adds argument "KEEPTTL" if option `rollingExpiration` is set to false', async () => {
|
|
expect(RedisAccessor.translateSetArguments({ rollingExpiration: false })).toEqual(['KEEPTTL'])
|
|
})
|
|
|
|
test('adds expected arguments if multiple options are configured', async () => {
|
|
expect(
|
|
RedisAccessor.translateSetArguments({
|
|
newOnly: true,
|
|
expireIn: 20
|
|
})
|
|
).toEqual(['NX', 'PX', 20])
|
|
|
|
expect(
|
|
RedisAccessor.translateSetArguments({
|
|
existingOnly: true,
|
|
expireIn: 20
|
|
})
|
|
).toEqual(['XX', 'PX', 20])
|
|
|
|
expect(
|
|
RedisAccessor.translateSetArguments({
|
|
existingOnly: true,
|
|
expireIn: 20,
|
|
rollingExpiration: false
|
|
})
|
|
).toEqual(['XX', 'PX', 20, 'KEEPTTL'])
|
|
|
|
expect(
|
|
RedisAccessor.translateSetArguments({
|
|
existingOnly: true,
|
|
rollingExpiration: false
|
|
})
|
|
).toEqual(['XX', 'KEEPTTL'])
|
|
})
|
|
|
|
test('throws a misconfiguration error if options `newOnly` and `existingOnly` are both set to true', async () => {
|
|
expect(
|
|
() => RedisAccessor.translateSetArguments({ newOnly: true, existingOnly: true })
|
|
).toThrowError(
|
|
new TypeError('Misconfiguration: entry cannot be both new and existing')
|
|
)
|
|
})
|
|
|
|
test('throws a misconfiguration error if option `expireIn` is set to a finite number that rounds to less than 1', async () => {
|
|
const misconfigurationError = new TypeError('Misconfiguration: cannot set a TTL of less than 1 millisecond')
|
|
|
|
expect(
|
|
() => RedisAccessor.translateSetArguments({ expireIn: 0 })
|
|
).toThrowError(misconfigurationError)
|
|
|
|
expect(
|
|
() => RedisAccessor.translateSetArguments({ expireIn: -1 })
|
|
).toThrowError(misconfigurationError)
|
|
|
|
expect(
|
|
() => RedisAccessor.translateSetArguments({ expireIn: 0.4 })
|
|
).toThrowError(misconfigurationError)
|
|
})
|
|
|
|
test('throws a misconfiguration error if option `rollingExpiration` is set to false but `newOnly` is set to true', async () => {
|
|
expect(
|
|
() => RedisAccessor.translateSetArguments({ newOnly: true, rollingExpiration: false })
|
|
).toThrowError(
|
|
new TypeError('Misconfiguration: cannot keep an existing TTL on a new entry')
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('#set method', () => {
|
|
test('resolves to true if value was successfully set', async () => {
|
|
const instance = new RedisAccessor()
|
|
expect(await instance.set('myKey', 'myValue')).toBe(true)
|
|
})
|
|
|
|
test('resolves to false if value was not set', async () => {
|
|
const instance = new RedisAccessor()
|
|
instance._client.set = jest.fn((...args) => args.pop()(null, 'NOT_OK'))
|
|
|
|
expect(await instance.set('myKey', 'myValue')).toBe(false)
|
|
})
|
|
|
|
test('sends expected key/value to Redis with #_client.set', async () => {
|
|
const instance = new RedisAccessor()
|
|
const setSpy = jest.spyOn(instance._client, 'set')
|
|
|
|
await instance.set('myKey', 'myValue')
|
|
expect(setSpy.mock.calls.length).toBe(1)
|
|
expect(setSpy.mock.calls[0].slice(0, 2)).toEqual(['myKey', 'myValue'])
|
|
})
|
|
|
|
test('resolves to false if Redis replies with an error and `allowSetFailures` option is set to true', async () => {
|
|
// Temporarily override `console.error`
|
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
|
|
|
|
const instance = new RedisAccessor({ prefix: 'myPrefix', allowSetFailures: true })
|
|
instance._client.set = jest.fn((...args) => args.pop()(new Error('Redis ReplyError')))
|
|
|
|
const result = await instance.set('myKey', 'myValue')
|
|
|
|
expect(result).toBe(false)
|
|
expect(consoleErrorSpy).toBeCalledWith(
|
|
`Failed to set value in Redis.
|
|
Key: myPrefix:myKey
|
|
Error: Redis ReplyError`
|
|
)
|
|
|
|
// Restore `console.error`
|
|
consoleErrorSpy.mockRestore()
|
|
})
|
|
|
|
test('rejects if Redis replies with an error and `allowSetFailures` option is not set to true', async () => {
|
|
// Temporarily override `console.error`
|
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
|
|
|
|
const instance = new RedisAccessor({ prefix: 'myPrefix' })
|
|
instance._client.set = jest.fn((...args) => args.pop()(new Error('Redis ReplyError')))
|
|
|
|
await expect(instance.set('myKey', 'myValue')).rejects.toThrowError(
|
|
new Error(`Failed to set value in Redis.
|
|
Key: myPrefix:myKey
|
|
Error: Redis ReplyError`
|
|
)
|
|
)
|
|
|
|
expect(consoleErrorSpy).not.toBeCalled()
|
|
|
|
// Restore `console.error`
|
|
consoleErrorSpy.mockRestore()
|
|
})
|
|
|
|
test('rejects if value is an empty string', async () => {
|
|
const instance = new RedisAccessor()
|
|
|
|
await expect(instance.set('myKey', '')).rejects.toThrow(
|
|
new TypeError('Value must be a non-empty string but was: ""')
|
|
)
|
|
})
|
|
|
|
test('rejects if value is a non-string value', async () => {
|
|
const instance = new RedisAccessor()
|
|
|
|
await expect(instance.set('myKey', true)).rejects.toThrow(
|
|
new TypeError('Value must be a non-empty string but was: true')
|
|
)
|
|
})
|
|
|
|
test('invokes .translateSetArguments before sending values to Redis', async () => {
|
|
const argSpy = jest.spyOn(RedisAccessor, 'translateSetArguments')
|
|
const instance = new RedisAccessor()
|
|
const setSpy = jest.spyOn(instance._client, 'set')
|
|
|
|
await instance.set('myKey', 'myValue', { expireIn: 20 })
|
|
expect(argSpy).toBeCalled()
|
|
expect(setSpy.mock.calls.length).toBe(1)
|
|
expect(setSpy.mock.calls[0].slice(0, 4)).toEqual(['myKey', 'myValue', 'PX', 20])
|
|
|
|
argSpy.mockRestore()
|
|
})
|
|
})
|
|
|
|
describe('#get method', () => {
|
|
test('resolves to expected value if matching entry exists in Redis', async () => {
|
|
const instance = new RedisAccessor()
|
|
|
|
await instance.set('myKey', 'myValue')
|
|
|
|
const result = await instance.get('myKey')
|
|
expect(result).toBe('myValue')
|
|
})
|
|
|
|
test('resolves to null if no matching entry exists in Redis', async () => {
|
|
const instance = new RedisAccessor()
|
|
|
|
const result = await instance.get('fakeKey')
|
|
expect(result).toBe(null)
|
|
})
|
|
|
|
test('retrieves matching entry from Redis with #_client.get', async () => {
|
|
const instance = new RedisAccessor()
|
|
let callbackSpy
|
|
const originalGet = instance._client.get.bind(instance._client)
|
|
instance._client.get = jest.fn((...args) => {
|
|
const realCallback = args.pop()
|
|
callbackSpy = jest.fn((error, value) => {
|
|
realCallback(error, value)
|
|
})
|
|
|
|
return originalGet(...args, callbackSpy)
|
|
})
|
|
|
|
await instance.set('myKey', 'myValue')
|
|
await instance.get('myKey')
|
|
|
|
expect(instance._client.get.mock.calls.length).toBe(1)
|
|
expect(instance._client.get.mock.calls[0].slice(0, 1)).toEqual(['myKey'])
|
|
|
|
expect(callbackSpy).toHaveBeenCalledWith(null, 'myValue')
|
|
})
|
|
|
|
test('resolves to null if Redis replies with an error and `allowGetFailures` option is set to true', async () => {
|
|
// Temporarily override `console.error`
|
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
|
|
|
|
const instance = new RedisAccessor({ prefix: 'myPrefix', allowGetFailures: true })
|
|
instance._client.get = jest.fn((...args) => args.pop()(new Error('Redis ReplyError')))
|
|
|
|
const result = await instance.get('myKey', 'myValue')
|
|
|
|
expect(result).toBe(null)
|
|
expect(consoleErrorSpy).toBeCalledWith(
|
|
`Failed to get value from Redis.
|
|
Key: myPrefix:myKey
|
|
Error: Redis ReplyError`
|
|
)
|
|
|
|
// Restore `console.error`
|
|
consoleErrorSpy.mockRestore()
|
|
})
|
|
|
|
test('rejects if Redis replies with an error and `allowGetFailures` option is not set to true', async () => {
|
|
// Temporarily override `console.error`
|
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
|
|
|
|
const instance = new RedisAccessor({ prefix: 'myPrefix' })
|
|
instance._client.get = jest.fn((...args) => args.pop()(new Error('Redis ReplyError')))
|
|
|
|
await expect(instance.get('myKey')).rejects.toThrowError(
|
|
new Error(`Failed to get value from Redis.
|
|
Key: myPrefix:myKey
|
|
Error: Redis ReplyError`
|
|
)
|
|
)
|
|
|
|
expect(consoleErrorSpy).not.toBeCalled()
|
|
|
|
// Restore `console.error`
|
|
consoleErrorSpy.mockRestore()
|
|
})
|
|
})
|
|
})
|