diff --git a/Procfile b/Procfile index 7575c37989d6..4bec00428708 100644 --- a/Procfile +++ b/Procfile @@ -1,3 +1 @@ web: NODE_ENV=production node server.mjs - -release: NODE_ENV=production script/release-heroku diff --git a/lib/redis-accessor.js b/lib/redis-accessor.js deleted file mode 100644 index 7b92e3442e37..000000000000 --- a/lib/redis-accessor.js +++ /dev/null @@ -1,138 +0,0 @@ -import createRedisClient from './redis/create-client.js' -import InMemoryRedis from 'redis-mock' -import { promisify } from 'util' - -const { CI, NODE_ENV, REDIS_URL } = process.env - -// Do not use real a Redis client for CI, tests, or if the REDIS_URL is not provided -const useRealRedis = !CI && NODE_ENV !== 'test' && !!REDIS_URL - -class RedisAccessor { - constructor({ - databaseNumber = 0, - prefix = null, - allowSetFailures = false, - allowGetFailures = false, - name = null, - } = {}) { - const redisClient = useRealRedis - ? createRedisClient({ - url: REDIS_URL, - db: databaseNumber, - name: name || 'redis-accessor', - }) - : InMemoryRedis.createClient() - - this._client = redisClient - - this._prefix = prefix ? prefix.replace(/:+$/, '') + ':' : '' - - // Allow for graceful failures if a Redis SET operation fails? - this._allowSetFailures = allowSetFailures === true - - // Allow for graceful failures if a Redis GET operation fails? - this._allowGetFailures = allowGetFailures === true - } - - /** @private */ - prefix(key) { - if (typeof key !== 'string' || !key) { - throw new TypeError(`Key must be a non-empty string but was: ${JSON.stringify(key)}`) - } - - return this._prefix + key - } - - static translateSetArguments(options = {}) { - const setArgs = [] - - const defaults = { - newOnly: false, - existingOnly: false, - expireIn: null, // No expiration - rollingExpiration: true, - } - const opts = { ...defaults, ...options } - - if (opts.newOnly === true) { - if (opts.existingOnly === true) { - throw new TypeError('Misconfiguration: entry cannot be both new and existing') - } - setArgs.push('NX') - } else if (opts.existingOnly === true) { - setArgs.push('XX') - } - - if (Number.isFinite(opts.expireIn)) { - const ttl = Math.round(opts.expireIn) - if (ttl < 1) { - throw new TypeError('Misconfiguration: cannot set a TTL of less than 1 millisecond') - } - setArgs.push('PX') - setArgs.push(ttl) - } - // otherwise there is no expiration - - if (opts.rollingExpiration === false) { - if (opts.newOnly === true) { - throw new TypeError('Misconfiguration: cannot keep an existing TTL on a new entry') - } - setArgs.push('KEEPTTL') - } - - return setArgs - } - - async set(key, value, options = {}) { - const setAsync = promisify(this._client.set).bind(this._client) - const fullKey = this.prefix(key) - - if (typeof value !== 'string' || !value) { - throw new TypeError(`Value must be a non-empty string but was: ${JSON.stringify(value)}`) - } - - // Handle optional arguments - const setArgs = this.constructor.translateSetArguments(options) - - try { - const result = await setAsync(fullKey, value, ...setArgs) - return result === 'OK' - } catch (err) { - const errorText = `Failed to set value in Redis. -Key: ${fullKey} -Error: ${err.message}` - - if (this._allowSetFailures === true) { - // Allow for graceful failure - console.error(errorText) - return false - } - - throw new Error(errorText) - } - } - - async get(key) { - const getAsync = promisify(this._client.get).bind(this._client) - const fullKey = this.prefix(key) - - try { - const value = await getAsync(fullKey) - return value - } catch (err) { - const errorText = `Failed to get value from Redis. -Key: ${fullKey} -Error: ${err.message}` - - if (this._allowGetFailures === true) { - // Allow for graceful failure - console.error(errorText) - return null - } - - throw new Error(errorText) - } - } -} - -export default RedisAccessor diff --git a/middleware/render-page.js b/middleware/render-page.js index 544b5069dee5..cfdcb32bcdd8 100644 --- a/middleware/render-page.js +++ b/middleware/render-page.js @@ -2,56 +2,9 @@ import { get } from 'lodash-es' import patterns from '../lib/patterns.js' import getMiniTocItems from '../lib/get-mini-toc-items.js' import Page from '../lib/page.js' -import statsd from '../lib/statsd.js' -import RedisAccessor from '../lib/redis-accessor.js' import { isConnectionDropped } from './halt-on-dropped-connection.js' import { nextApp, nextHandleRequest } from './next.js' -const { HEROKU_RELEASE_VERSION } = process.env - -const pageCacheDatabaseNumber = 1 - -const pageCache = new RedisAccessor({ - databaseNumber: pageCacheDatabaseNumber, - prefix: (HEROKU_RELEASE_VERSION ? HEROKU_RELEASE_VERSION + ':' : '') + 'rp', - // Allow for graceful failures if a Redis SET operation fails - allowSetFailures: true, - // Allow for graceful failures if a Redis GET operation fails - allowGetFailures: true, - name: 'page-cache', -}) - -// a list of query params that *do* alter the rendered page, and therefore should be cached separately -const cacheableQueries = ['learn'] - -function modifyOutput(req, text) { - return addColorMode(req, addCsrf(req, text)) -} - -function addCsrf(req, text) { - return text.replace('$CSRFTOKEN$', req.csrfToken()) -} - -function addColorMode(req, text) { - let colorMode = 'auto' - let darkTheme = 'dark' - let lightTheme = 'light' - - try { - const cookieValue = JSON.parse(decodeURIComponent(req.cookies.color_mode)) - colorMode = encodeURIComponent(cookieValue.color_mode) || colorMode - darkTheme = encodeURIComponent(cookieValue.dark_theme.name) || darkTheme - lightTheme = encodeURIComponent(cookieValue.light_theme.name) || lightTheme - } catch (e) { - // do nothing - } - - return text - .replace('$COLORMODE$', colorMode) - .replace('$DARKTHEME$', darkTheme) - .replace('$LIGHTTHEME$', lightTheme) -} - export default async function renderPage(req, res, next) { const page = req.context.page // render a 404 page @@ -64,59 +17,14 @@ export default async function renderPage(req, res, next) { return nextApp.render404(req, res) } + // Just finish fast without all the details like Content-Length if (req.method === 'HEAD') { return res.status(200).end() } - // Remove any query string (?...) and/or fragment identifier (#...) - const { pathname, searchParams } = new URL(req.originalUrl, 'https://docs.github.com') - - for (const queryKey in req.query) { - if (!cacheableQueries.includes(queryKey)) { - searchParams.delete(queryKey) - } - } - const originalUrl = pathname + ([...searchParams].length > 0 ? `?${searchParams}` : '') - // Is the request for JSON debugging info? const isRequestingJsonForDebugging = 'json' in req.query && process.env.NODE_ENV !== 'production' - // Is in an airgapped session? - const isAirgapped = Boolean(req.cookies.AIRGAP) - - // Is the request for the GraphQL Explorer page? - const isGraphQLExplorer = req.context.currentPathWithoutLanguage === '/graphql/overview/explorer' - - // Serve from the cache if possible - const isCacheable = - // Skip for CI - !process.env.CI && - // Skip for tests - process.env.NODE_ENV !== 'test' && - // Skip for HTTP methods other than GET - req.method === 'GET' && - // Skip for JSON debugging info requests - !isRequestingJsonForDebugging && - // Skip for airgapped sessions - !isAirgapped && - // Skip for the GraphQL Explorer page - !isGraphQLExplorer - - if (isCacheable) { - // Stop processing if the connection was already dropped - if (isConnectionDropped(req, res)) return - - const cachedHtml = await pageCache.get(originalUrl) - if (cachedHtml) { - // Stop processing if the connection was already dropped - if (isConnectionDropped(req, res)) return - - console.log(`Serving from cached version of ${originalUrl}`) - statsd.increment('page.sent_from_cache') - return res.send(modifyOutput(req, cachedHtml)) - } - } - // add page context const context = Object.assign({}, req.context, { page }) diff --git a/script/README.md b/script/README.md index b57a0b720de5..8f25a04aef59 100644 --- a/script/README.md +++ b/script/README.md @@ -338,13 +338,6 @@ Run this script to manually purge the Fastly cache for all language variants of --- -### [`purge-redis-pages.js`](purge-redis-pages.js) - -Run this script to manually purge the Redis rendered page cache. This will typically only be run by Heroku during the deployment process, as triggered via our Procfile's "release" phase configuration. - ---- - - ### [`reconcile-category-dirs-with-ids.js`](reconcile-category-dirs-with-ids.js) An automated test checks for discrepancies between category directory names and slugified category titles as IDs. diff --git a/script/purge-redis-pages.js b/script/purge-redis-pages.js deleted file mode 100755 index 2c185d0f423e..000000000000 --- a/script/purge-redis-pages.js +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env node -import xDotenv from 'dotenv' -import { promisify } from 'util' -import createRedisClient from '../lib/redis/create-client.js' - -// [start-readme] -// -// Run this script to manually "soft purge" the Redis rendered page cache -// by shortening the expiration window of entries. -// This will typically only be run by Heroku during the deployment process, -// as triggered via our Procfile's "release" phase configuration. -// -// [end-readme] - -xDotenv.config() - -const { REDIS_URL, HEROKU_RELEASE_VERSION, HEROKU_PRODUCTION_APP } = process.env -const isHerokuProd = HEROKU_PRODUCTION_APP === 'true' -const pageCacheDatabaseNumber = 1 -const keyScanningPattern = HEROKU_RELEASE_VERSION ? '*:rp:*' : 'rp:*' -const scanSetSize = 250 - -const startTime = Date.now() -const expirationDuration = 30 * 60 * 1000 // 30 minutes -const expirationTimestamp = startTime + expirationDuration // 30 minutes from now - -// print keys to be purged without actually purging -const dryRun = ['-d', '--dry-run'].includes(process.argv[2]) - -// verify environment variables -if (!REDIS_URL) { - if (isHerokuProd) { - console.error('Error: you must specify the REDIS_URL environment variable.\n') - process.exit(1) - } else { - console.warn('Warning: you did not specify a REDIS_URL environment variable. Exiting...\n') - process.exit(0) - } -} - -console.log({ - HEROKU_RELEASE_VERSION, - HEROKU_PRODUCTION_APP, -}) - -purgeRenderedPageCache() - -function purgeRenderedPageCache() { - const redisClient = createRedisClient({ - url: REDIS_URL, - db: pageCacheDatabaseNumber, - // These commands ARE important, so let's make sure they are all accounted for - enable_offline_queue: true, - }) - - let iteration = 0 - let potentialKeyCount = 0 - let totalKeyCount = 0 - - // Promise wrappers - const scanAsync = promisify(redisClient.scan).bind(redisClient) - const quitAsync = promisify(redisClient.quit).bind(redisClient) - - // Run it! - return scan() - - // - // Define other subroutines - // - - async function scan(cursor = '0') { - try { - // [0]: Update the cursor position for the next scan - // [1]: Get the SCAN result for this iteration - const [nextCursor, keys] = await scanAsync( - cursor, - 'MATCH', - keyScanningPattern, - 'COUNT', - scanSetSize.toString() - ) - - console.log(`\n[Iteration ${iteration++}] Received ${keys.length} keys...`) - - if (dryRun) { - console.log( - `DRY RUN! This iteration might have set TTL for up to ${ - keys.length - } keys:\n - ${keys.join('\n - ')}` - ) - } - - // NOTE: It is possible for a SCAN cursor iteration to return 0 keys when - // using a MATCH because it is applied after the elements are retrieved - // - // Remember: more or less than COUNT or no keys may be returned - // See http://redis.io/commands/scan#the-count-option - // Also, SCAN may return the same key multiple times - // See http://redis.io/commands/scan#scan-guarantees - // Additionally, you should always have the code that uses the keys - // before the code checking the cursor. - if (keys.length > 0) { - if (dryRun) { - potentialKeyCount += keys.length - } else { - totalKeyCount += await updateTtls(keys) - } - } - - // From : - // 'An iteration starts when the cursor is set to 0, - // and terminates when the cursor returned by the server is 0.' - if (nextCursor === '0') { - const dryRunTrailer = dryRun ? ` (potentially up to ${potentialKeyCount})` : '' - console.log(`\nDone purging keys; affected total: ${totalKeyCount}${dryRunTrailer}`) - console.log(`Time elapsed: ${Date.now() - startTime} ms`) - - // Close the connection - await quitAsync() - return - } - - // Tail recursion - return scan(nextCursor) - } catch (error) { - console.error('An unexpected error occurred!\n' + error.stack) - console.error('\nAborting...') - process.exit(1) - } - } - - // Find existing TTLs to ensure we aren't extending the TTL if it's already set - async function getTtls(keys) { - const pttlPipeline = redisClient.batch() - keys.forEach((key) => pttlPipeline.pttl(key)) - - const pttlPipelineExecAsync = promisify(pttlPipeline.exec).bind(pttlPipeline) - const pttlResults = await pttlPipelineExecAsync() - - if (pttlResults == null || pttlResults.length === 0) { - throw new Error('PTTL results were empty') - } - - return pttlResults - } - - async function updateTtls(keys) { - const pttlResults = await getTtls(keys) - - // Find pertinent keys to have TTLs set - let updatingKeyCount = 0 - const pexpireAtPipeline = redisClient.batch() - - keys.forEach((key, i) => { - // Only operate on -1 values or those later than our desired expiration timestamp - const pttl = pttlResults[i] - // A TTL of -1 means the entry was not configured with any TTL (expiration) - // currently and will remain as a permanent entry unless a TTL is added - const needsShortenedTtl = pttl === -1 || pttl > expirationDuration - const isOldKey = !HEROKU_RELEASE_VERSION || !key.startsWith(`${HEROKU_RELEASE_VERSION}:`) - - if (needsShortenedTtl && isOldKey) { - pexpireAtPipeline.pexpireat(key, expirationTimestamp) - updatingKeyCount += 1 - } - }) - - console.log(`Purging ${updatingKeyCount} keys...`) - - // Only update TTLs if there are records worth updating - if (updatingKeyCount === 0) return - - // Set all the TTLs - const pexpireAtPipelineExecAsync = promisify(pexpireAtPipeline.exec).bind(pexpireAtPipeline) - const pexpireAtResults = await pexpireAtPipelineExecAsync() - - if (pttlResults == null || pttlResults.length === 0) { - throw new Error('PEXPIREAT results were empty') - } - - // Count only the entries whose TTLs were successfully updated - const updatedResults = pexpireAtResults.filter((result) => result === 1) - return updatedResults.length - } -} diff --git a/script/release-heroku b/script/release-heroku deleted file mode 100755 index f070e707445a..000000000000 --- a/script/release-heroku +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash - -# [start-readme] -# -# Light Bash wrapper for the Heroku release command, which sometimes fails -# unexpectedly in staging environments when a Node installation is missing -# -# [end-readme] - -# Check for node but don't fail immediately if it's not present -./script/check-for-node -EXIT_STATUS=$? - -# If node is missing... -if [[ "$EXIT_STATUS" -ne "0" ]]; then - # Fail hard if this is our Heroku production app or if Redis is configured - if [[ "$HEROKU_PRODUCTION_APP" == "true" || -n "$REDIS_URL" ]]; then - echo "Error: cannot execute the release script without Node.js, which is fatal." - echo "Exiting..." - exit $EXIT_STATUS - # Otherwise succeed with only a warning - else - echo "Warning: although Node.js is missing, it is non-critical." - echo "Exiting..." - exit 0 - fi -else - # Execute the release script and exit with its status - node script/purge-redis-pages.js - exit $? -fi diff --git a/tests/unit/redis-accessor.js b/tests/unit/redis-accessor.js deleted file mode 100644 index 58296f1889b8..000000000000 --- a/tests/unit/redis-accessor.js +++ /dev/null @@ -1,359 +0,0 @@ -import { jest } from '@jest/globals' -import redisMock from 'redis-mock' -import RedisAccessor from '../../lib/redis-accessor.js' - -const { RedisClient: InMemoryRedis } = redisMock - -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() - }) - }) -})