From c7fa03cd760c03021b0b2f4e6fedfb83e1572832 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sun, 2 May 2021 16:30:06 +0200 Subject: [PATCH] Test coverage for CacheManager and CacheClient Signed-off-by: Eric Peterson --- .../src/cache/CacheClient.test.ts | 141 ++++++++++++++++ .../src/cache/CacheManager.test.ts | 154 ++++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 packages/backend-common/src/cache/CacheClient.test.ts create mode 100644 packages/backend-common/src/cache/CacheManager.test.ts diff --git a/packages/backend-common/src/cache/CacheClient.test.ts b/packages/backend-common/src/cache/CacheClient.test.ts new file mode 100644 index 0000000000..aadcee7386 --- /dev/null +++ b/packages/backend-common/src/cache/CacheClient.test.ts @@ -0,0 +1,141 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConcreteCacheClient } from './CacheClient'; +import cacheManager from 'cache-manager'; + +describe('CacheClient', () => { + const pluginId = 'test'; + let client: cacheManager.Cache; + const b64 = (k: string) => Buffer.from(k).toString('base64'); + + beforeEach(() => { + client = cacheManager.caching({ store: 'none', ttl: 0 }); + client.get = jest.fn(); + client.set = jest.fn(); + client.del = jest.fn(); + }); + + afterEach(() => jest.resetAllMocks()); + + describe('CacheClient.get', () => { + it('calls client with normalized key', async () => { + const sut = new ConcreteCacheClient({ client, pluginId }); + const keyPartial = 'somekey'; + + await sut.get(keyPartial); + + expect(client.get).toHaveBeenCalledWith(b64(`${pluginId}:${keyPartial}`)); + }); + + it('calls client with normalized key (very long key)', async () => { + const sut = new ConcreteCacheClient({ client, pluginId }); + const keyPartial = 'x'.repeat(251); + + await sut.get(keyPartial); + + const spy = client.get as jest.Mock; + const actualKey = spy.mock.calls[0][0]; + expect(actualKey).not.toEqual(b64(`${pluginId}:${keyPartial}`)); + expect(actualKey.length).toBeLessThan(250); + }); + + it('performs deserialization on returned data', async () => { + const expectedValue = { some: 'value' }; + const sut = new ConcreteCacheClient({ client, pluginId }); + client.get = jest.fn().mockResolvedValue(JSON.stringify(expectedValue)); + + const actualValue = await sut.get('someKey'); + + expect(actualValue).toMatchObject(expectedValue); + }); + + it('returns null on any underlying error', async () => { + const sut = new ConcreteCacheClient({ client, pluginId }); + client.get = jest.fn().mockRejectedValue(undefined); + + const actualValue = await sut.get('someKey'); + + expect(actualValue).toStrictEqual(null); + }); + }); + + describe('CacheClient.set', () => { + it('calls client with normalized key', async () => { + const sut = new ConcreteCacheClient({ client, pluginId }); + const keyPartial = 'somekey'; + + await sut.set(keyPartial, {}); + + const spy = client.set as jest.Mock; + const actualKey = spy.mock.calls[0][0]; + expect(actualKey).toEqual(b64(`${pluginId}:${keyPartial}`)); + }); + + it('performs serialization on given data', async () => { + const sut = new ConcreteCacheClient({ client, pluginId }); + const expectedData = { some: 'value' }; + + await sut.set('someKey', expectedData); + + const spy = client.set as jest.Mock; + const actualData = spy.mock.calls[0][1]; + expect(actualData).toEqual(JSON.stringify(expectedData)); + }); + + it('passes ttl to client when given', async () => { + const sut = new ConcreteCacheClient({ client, pluginId }); + const expectedTtl = 3600; + + await sut.set('someKey', {}, expectedTtl); + + const spy = client.set as jest.Mock; + const actualOptions = spy.mock.calls[0][2]; + expect(actualOptions.ttl).toEqual(expectedTtl); + }); + + it('does not throw errors on any client error', async () => { + const sut = new ConcreteCacheClient({ client, pluginId }); + client.set = jest.fn().mockRejectedValue(undefined); + + expect(async () => { + await sut.set('someKey', {}); + }).not.toThrow(); + }); + }); + + describe('CacheClient.delete', () => { + it('calls client with normalized key', async () => { + const sut = new ConcreteCacheClient({ client, pluginId }); + const keyPartial = 'somekey'; + + await sut.delete(keyPartial); + + const spy = client.del as jest.Mock; + const actualKey = spy.mock.calls[0][0]; + expect(actualKey).toEqual(b64(`${pluginId}:${keyPartial}`)); + }); + + it('does not throw errors on any client error', async () => { + const sut = new ConcreteCacheClient({ client, pluginId }); + client.del = jest.fn().mockRejectedValue(undefined); + + expect(async () => { + await sut.delete('someKey'); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/backend-common/src/cache/CacheManager.test.ts b/packages/backend-common/src/cache/CacheManager.test.ts new file mode 100644 index 0000000000..af8450975a --- /dev/null +++ b/packages/backend-common/src/cache/CacheManager.test.ts @@ -0,0 +1,154 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigReader } from '@backstage/config'; +import cacheManager from 'cache-manager'; +import { ConcreteCacheClient } from './CacheClient'; +import { CacheManager } from './CacheManager'; + +cacheManager.caching = jest.fn() as jest.Mock; +jest.mock('./CacheClient', () => { + return { + ConcreteCacheClient: jest.fn(), + }; +}); + +describe('CacheManager', () => { + const defaultConfigOptions = { + backend: { + cache: { + store: 'memory', + }, + }, + }; + const defaultConfig = () => new ConfigReader(defaultConfigOptions); + + afterEach(() => jest.resetAllMocks()); + + describe('CacheManager.fromConfig', () => { + it('accesses the backend.cache key', () => { + const getOptionalConfig = jest.fn(); + const config = defaultConfig(); + config.getOptionalConfig = getOptionalConfig; + + CacheManager.fromConfig(config); + + expect(getOptionalConfig.mock.calls[0][0]).toEqual('backend.cache'); + }); + + it('does not require the backend.cache key', () => { + const config = new ConfigReader({ backend: {} }); + expect(() => { + CacheManager.fromConfig(config); + }).not.toThrowError(); + }); + }); + + describe('CacheManager.forPlugin', () => { + const manager = CacheManager.fromConfig(defaultConfig()); + + it('connects to a cache store scoped to the plugin', async () => { + const pluginId = 'test1'; + const expectedTtl = 3600; + manager.forPlugin(pluginId).getClient(expectedTtl); + + const client = ConcreteCacheClient as jest.Mock; + expect(client).toHaveBeenCalledTimes(1); + }); + + it('provides different plugins different cache clients', async () => { + const plugin1Id = 'test1'; + const plugin2Id = 'test2'; + const expectedTtl = 3600; + manager.forPlugin(plugin1Id).getClient(expectedTtl); + manager.forPlugin(plugin2Id).getClient(expectedTtl); + + const cache = cacheManager.caching as jest.Mock; + const client = ConcreteCacheClient as jest.Mock; + expect(cache).toHaveBeenCalledTimes(2); + expect(client).toHaveBeenCalledTimes(2); + + const plugin1CallArgs = client.mock.calls[0]; + const plugin2CallArgs = client.mock.calls[1]; + expect(plugin1CallArgs[0].pluginId).not.toEqual( + plugin2CallArgs[0].pluginId, + ); + }); + }); + + describe('CacheManager.forPlugin stores', () => { + it('returns none client when no cache is configured', () => { + const manager = CacheManager.fromConfig( + new ConfigReader({ backend: {} }), + ); + const expectedTtl = 3600; + manager.forPlugin('test').getClient(expectedTtl); + + const cache = cacheManager.caching as jest.Mock; + const mockCalls = cache.mock.calls.splice(-1); + const callArgs = mockCalls[0]; + expect(callArgs[0]).toMatchObject({ + store: 'none', + ttl: expectedTtl, + }); + }); + + it('returns memory client when configured', () => { + const manager = CacheManager.fromConfig(defaultConfig()); + const expectedTtl = 3600; + manager.forPlugin('test').getClient(expectedTtl); + + const cache = cacheManager.caching as jest.Mock; + const mockCalls = cache.mock.calls.splice(-1); + const callArgs = mockCalls[0]; + expect(callArgs[0]).toMatchObject({ + store: 'memory', + ttl: expectedTtl, + }); + }); + + it('returns a memcache client when configured', () => { + const expectedHost = '127.0.0.1:11211'; + const expectedTimeout = 1000; + const manager = CacheManager.fromConfig( + new ConfigReader({ + backend: { + cache: { + store: 'memcache', + connection: { + hosts: [expectedHost], + netTimeout: expectedTimeout, + }, + }, + }, + }), + ); + const expectedTtl = 3600; + manager.forPlugin('test').getClient(expectedTtl); + + const cache = cacheManager.caching as jest.Mock; + const mockCalls = cache.mock.calls.splice(-1); + const callArgs = mockCalls[0]; + expect(callArgs[0]).toMatchObject({ + options: { + hosts: [expectedHost], + netTimeout: expectedTimeout, + }, + ttl: expectedTtl, + }); + }); + }); +});